diff --git a/.changeset/light-starfishes-build.md b/.changeset/light-starfishes-build.md new file mode 100644 index 0000000..7e1a313 --- /dev/null +++ b/.changeset/light-starfishes-build.md @@ -0,0 +1,11 @@ +--- +'@team-plain/typescript-sdk': major +--- + +Upgrade the SDK webhook parsing to support webhook version '2024-09-18'. + +### Breaking Changes +If your Plain webhook target is on the legacy/unversioned version, the SDK will no longer be able to parse the webhook payload. You must update your webhook target to '2024-09-18'. Refer to [our docs](https://www.plain.com/docs/api-reference/webhooks/versions) for more information and instructions on how to safely upgrade your webhook target. + +### Added +`verifyPlainWebhook` method to verify the webhook signature, with support for replay attack protection, and parse the webhook payload. Refer to the [README](./README.md) for usage instructions. diff --git a/README.md b/README.md index 7152553..0082a2f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @team-plain/typescript-sdk -[Changelog]('./CHANGELOG.md') +[Changelog](./CHANGELOG.md) ## Plain Client @@ -104,16 +104,40 @@ Fallback error type when something unexpected happens. ## Webhooks -This package also provides functionality to validate our [Webhook payloads](https://www.plain.com/docs/api-reference/webhooks). +Plain signs the [webhooks](https://www.plain.com/docs/api-reference/webhooks) it sends to your endpoint, +allowing you to validate that they were not sent by a third-party. You can read more about it [here](https://www.plain.com/docs/api-reference/request-signing). +The SDK provides a convenient helper function to verify the signature, prevent replay attacks, and parse the payload to a typed object. ```ts -import { parsePlainWebhook } from '@team-plain/typescript-sdk'; - -const payload = { ... }; - -if(parsePlainWebhook(payload)) { - // payload is now typed! - doYourThing(payload); +import { + PlainWebhookSignatureVerificationError, + PlainWebhookVersionMismatchError, + verifyPlainWebhook, +} from '@team-plain/typescript-sdk'; + +// You must pass the raw request body, exactly as received from Plain, +// this will not work with a parsed (i.e., JSON) request body. +const payload = '...'; + +// The value of the `Plain-Request-Signature` header from the webhook request. +const signature = '...'; + +// Plain Request Signature Secret. You can find this in Plain's settings. +const secret = '...'; + +const webhookResult = verifyPlainWebhook(payload, signature, secret); +if (webhookResult.error instanceof PlainWebhookSignatureVerificationError) { + // Signature verification failed. +} else if (webhookResult.error instanceof PlainWebhookVersionMismatchError) { + // The SDK is not compatible with the received webhook version. + // Consider updating the SDK and the webhook target to the latest version. + // Consult the changelog or https://plain.com/docs/api-reference/webhooks/versions for more information. +} else if (webhookResult.error) { + // Unexpected error. Most likely due to an error in Plain's webhook server or a bug in the SDK. + // Treat this as a 500 response from Plain. + // We also recommend logging the error and sharing it with Plain's support team. +} else { + // webhookResult.data is now a typed object. } ``` diff --git a/biome.json b/biome.json index 56c646d..bc67624 100644 --- a/biome.json +++ b/biome.json @@ -23,7 +23,12 @@ } }, "files": { - "include": ["biome.json", "vitest.*.js", "src/**/*.ts", "src/**/*.gql"], + "include": [ + "biome.json", + "vitest.*.js", + "src/**/*.ts", + "src/**/*.gql" + ], "ignore": [ "dist/**", "node_modules/**", @@ -59,4 +64,4 @@ "organizeImports": { "enabled": true } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 77d4a80..7ad4d8f 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "@graphql-codegen/typescript-document-nodes": "^3.0.4", "@graphql-codegen/typescript-operations": "^3.0.4", "@rollup/plugin-json": "^6.1.0", + "@types/lodash.get": "^4.4.9", + "@types/node": "^22.7.2", "esbuild": "^0.17.18", "json-schema-to-typescript": "^13.1.2", "rollup": "^3.21.5", @@ -46,6 +48,7 @@ "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "graphql": "^16.6.0", + "lodash.get": "^4.4.2", "zod": "3.22.4" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96ef898..e6d0d69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ dependencies: graphql: specifier: ^16.6.0 version: 16.6.0 + lodash.get: + specifier: ^4.4.2 + version: 4.4.2 zod: specifier: 3.22.4 version: 3.22.4 @@ -33,7 +36,7 @@ devDependencies: version: 4.0.1(graphql@16.6.0) '@graphql-codegen/cli': specifier: ^3.3.1 - version: 3.3.1(@babel/core@7.22.8)(graphql@16.6.0) + version: 3.3.1(@babel/core@7.22.8)(@types/node@22.7.2)(graphql@16.6.0) '@graphql-codegen/typed-document-node': specifier: ^4.0.1 version: 4.0.1(graphql@16.6.0) @@ -49,6 +52,12 @@ devDependencies: '@rollup/plugin-json': specifier: ^6.1.0 version: 6.1.0(rollup@3.21.5) + '@types/lodash.get': + specifier: ^4.4.9 + version: 4.4.9 + '@types/node': + specifier: ^22.7.2 + version: 22.7.2 esbuild: specifier: ^0.17.18 version: 0.17.18 @@ -1384,7 +1393,7 @@ packages: tslib: 2.5.3 dev: true - /@graphql-codegen/cli@3.3.1(@babel/core@7.22.8)(graphql@16.6.0): + /@graphql-codegen/cli@3.3.1(@babel/core@7.22.8)(@types/node@22.7.2)(graphql@16.6.0): resolution: {integrity: sha512-4Es8Y9zFeT0Zx2qRL7L3qXDbbqvXK6aID+8v8lP6gaYD+uWx3Jd4Hsq5vxwVBR+6flm0BW/C85Qm0cvmT7O6LA==} hasBin: true peerDependencies: @@ -1398,12 +1407,12 @@ packages: '@graphql-tools/apollo-engine-loader': 7.3.26(graphql@16.6.0) '@graphql-tools/code-file-loader': 7.3.23(@babel/core@7.22.8)(graphql@16.6.0) '@graphql-tools/git-loader': 7.3.0(@babel/core@7.22.8)(graphql@16.6.0) - '@graphql-tools/github-loader': 7.3.28(@babel/core@7.22.8)(graphql@16.6.0) + '@graphql-tools/github-loader': 7.3.28(@babel/core@7.22.8)(@types/node@22.7.2)(graphql@16.6.0) '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.6.0) '@graphql-tools/json-file-loader': 7.4.18(graphql@16.6.0) '@graphql-tools/load': 7.8.14(graphql@16.6.0) - '@graphql-tools/prisma-loader': 7.2.72(graphql@16.6.0) - '@graphql-tools/url-loader': 7.17.18(graphql@16.6.0) + '@graphql-tools/prisma-loader': 7.2.72(@types/node@22.7.2)(graphql@16.6.0) + '@graphql-tools/url-loader': 7.17.18(@types/node@22.7.2)(graphql@16.6.0) '@graphql-tools/utils': 9.2.1(graphql@16.6.0) '@parcel/watcher': 2.2.0 '@whatwg-node/fetch': 0.8.8 @@ -1412,7 +1421,7 @@ packages: debounce: 1.2.1 detect-indent: 6.1.0 graphql: 16.6.0 - graphql-config: 4.5.0(graphql@16.6.0) + graphql-config: 4.5.0(@types/node@22.7.2)(graphql@16.6.0) inquirer: 8.2.5 is-glob: 4.0.3 jiti: 1.19.1 @@ -1633,7 +1642,7 @@ packages: - utf-8-validate dev: true - /@graphql-tools/executor-http@0.1.10(graphql@16.6.0): + /@graphql-tools/executor-http@0.1.10(@types/node@22.7.2)(graphql@16.6.0): resolution: {integrity: sha512-hnAfbKv0/lb9s31LhWzawQ5hghBfHS+gYWtqxME6Rl0Aufq9GltiiLBcl7OVVOnkLF0KhwgbYP1mB5VKmgTGpg==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 @@ -1644,7 +1653,7 @@ packages: dset: 3.1.2 extract-files: 11.0.0 graphql: 16.6.0 - meros: 1.3.0 + meros: 1.3.0(@types/node@22.7.2) tslib: 2.6.0 value-or-promise: 1.0.12 transitivePeerDependencies: @@ -1697,13 +1706,13 @@ packages: - supports-color dev: true - /@graphql-tools/github-loader@7.3.28(@babel/core@7.22.8)(graphql@16.6.0): + /@graphql-tools/github-loader@7.3.28(@babel/core@7.22.8)(@types/node@22.7.2)(graphql@16.6.0): resolution: {integrity: sha512-OK92Lf9pmxPQvjUNv05b3tnVhw0JRfPqOf15jZjyQ8BfdEUrJoP32b4dRQQem/wyRL24KY4wOfArJNqzpsbwCA==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/executor-http': 0.1.10(graphql@16.6.0) + '@graphql-tools/executor-http': 0.1.10(@types/node@22.7.2)(graphql@16.6.0) '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.22.8)(graphql@16.6.0) '@graphql-tools/utils': 9.2.1(graphql@16.6.0) '@whatwg-node/fetch': 0.8.8 @@ -1801,12 +1810,12 @@ packages: tslib: 2.5.3 dev: true - /@graphql-tools/prisma-loader@7.2.72(graphql@16.6.0): + /@graphql-tools/prisma-loader@7.2.72(@types/node@22.7.2)(graphql@16.6.0): resolution: {integrity: sha512-0a7uV7Fky6yDqd0tI9+XMuvgIo6GAqiVzzzFV4OSLry4AwiQlI3igYseBV7ZVOGhedOTqj/URxjpiv07hRcwag==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@graphql-tools/url-loader': 7.17.18(graphql@16.6.0) + '@graphql-tools/url-loader': 7.17.18(@types/node@22.7.2)(graphql@16.6.0) '@graphql-tools/utils': 9.2.1(graphql@16.6.0) '@types/js-yaml': 4.0.5 '@types/json-stable-stringify': 1.0.34 @@ -1859,7 +1868,7 @@ packages: value-or-promise: 1.0.12 dev: true - /@graphql-tools/url-loader@7.17.18(graphql@16.6.0): + /@graphql-tools/url-loader@7.17.18(@types/node@22.7.2)(graphql@16.6.0): resolution: {integrity: sha512-ear0CiyTj04jCVAxi7TvgbnGDIN2HgqzXzwsfcqiVg9cvjT40NcMlZ2P1lZDgqMkZ9oyLTV8Bw6j+SyG6A+xPw==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 @@ -1867,7 +1876,7 @@ packages: '@ardatan/sync-fetch': 0.0.1 '@graphql-tools/delegate': 9.0.35(graphql@16.6.0) '@graphql-tools/executor-graphql-ws': 0.0.14(graphql@16.6.0) - '@graphql-tools/executor-http': 0.1.10(graphql@16.6.0) + '@graphql-tools/executor-http': 0.1.10(@types/node@22.7.2)(graphql@16.6.0) '@graphql-tools/executor-legacy-ws': 0.0.11(graphql@16.6.0) '@graphql-tools/utils': 9.2.1(graphql@16.6.0) '@graphql-tools/wrap': 9.4.2(graphql@16.6.0) @@ -2201,7 +2210,7 @@ packages: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.4.1 + '@types/node': 22.7.2 dev: true /@types/is-ci@3.0.0: @@ -2222,6 +2231,12 @@ packages: resolution: {integrity: sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw==} dev: true + /@types/lodash.get@4.4.9: + resolution: {integrity: sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==} + dependencies: + '@types/lodash': 4.14.202 + dev: true + /@types/lodash@4.14.202: resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} dev: true @@ -2238,8 +2253,10 @@ packages: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} dev: true - /@types/node@20.4.1: - resolution: {integrity: sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==} + /@types/node@22.7.2: + resolution: {integrity: sha512-866lXSrpGpgyHBZUa2m9YNWqHDjjM0aBTJlNtYaGEw4rqY/dcD7deRVTbBBAJelfA7oaGDbNftXF/TL/A6RgoA==} + dependencies: + undici-types: 6.19.8 dev: true /@types/normalize-package-data@2.4.1: @@ -2261,7 +2278,7 @@ packages: /@types/ws@8.5.5: resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} dependencies: - '@types/node': 20.4.1 + '@types/node': 22.7.2 dev: true /@vitest/expect@0.31.0: @@ -3542,7 +3559,7 @@ packages: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} dev: true - /graphql-config@4.5.0(graphql@16.6.0): + /graphql-config@4.5.0(@types/node@22.7.2)(graphql@16.6.0): resolution: {integrity: sha512-x6D0/cftpLUJ0Ch1e5sj1TZn6Wcxx4oMfmhaG9shM0DKajA9iR+j1z86GSTQ19fShbGvrSSvbIQsHku6aQ6BBw==} engines: {node: '>= 10.0.0'} peerDependencies: @@ -3556,7 +3573,7 @@ packages: '@graphql-tools/json-file-loader': 7.4.18(graphql@16.6.0) '@graphql-tools/load': 7.8.14(graphql@16.6.0) '@graphql-tools/merge': 8.4.2(graphql@16.6.0) - '@graphql-tools/url-loader': 7.17.18(graphql@16.6.0) + '@graphql-tools/url-loader': 7.17.18(@types/node@22.7.2)(graphql@16.6.0) '@graphql-tools/utils': 9.2.1(graphql@16.6.0) cosmiconfig: 8.0.0 graphql: 16.6.0 @@ -4157,6 +4174,10 @@ packages: p-locate: 5.0.0 dev: true + /lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + dev: false + /lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} dev: true @@ -4308,7 +4329,7 @@ packages: engines: {node: '>= 8'} dev: true - /meros@1.3.0: + /meros@1.3.0(@types/node@22.7.2): resolution: {integrity: sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==} engines: {node: '>=13'} peerDependencies: @@ -4316,6 +4337,8 @@ packages: peerDependenciesMeta: '@types/node': optional: true + dependencies: + '@types/node': 22.7.2 dev: true /micromatch@4.0.5: @@ -5463,6 +5486,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + dev: true + /universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -5524,7 +5551,7 @@ packages: engines: {node: '>=12'} dev: true - /vite-node@0.31.0(@types/node@20.4.1): + /vite-node@0.31.0(@types/node@22.7.2): resolution: {integrity: sha512-8x1x1LNuPvE2vIvkSB7c1mApX5oqlgsxzHQesYF7l5n1gKrEmrClIiZuOFbFDQcjLsmcWSwwmrWrcGWm9Fxc/g==} engines: {node: '>=v14.18.0'} hasBin: true @@ -5534,7 +5561,7 @@ packages: mlly: 1.4.0 pathe: 1.1.1 picocolors: 1.0.0 - vite: 4.4.2(@types/node@20.4.1) + vite: 4.4.2(@types/node@22.7.2) transitivePeerDependencies: - '@types/node' - less @@ -5546,7 +5573,7 @@ packages: - terser dev: true - /vite@4.4.2(@types/node@20.4.1): + /vite@4.4.2(@types/node@22.7.2): resolution: {integrity: sha512-zUcsJN+UvdSyHhYa277UHhiJ3iq4hUBwHavOpsNUGsTgjBeoBlK8eDt+iT09pBq0h9/knhG/SPrZiM7cGmg7NA==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -5574,7 +5601,7 @@ packages: terser: optional: true dependencies: - '@types/node': 20.4.1 + '@types/node': 22.7.2 esbuild: 0.18.11 postcss: 8.4.25 rollup: 3.26.2 @@ -5615,7 +5642,7 @@ packages: dependencies: '@types/chai': 4.3.5 '@types/chai-subset': 1.3.3 - '@types/node': 20.4.1 + '@types/node': 22.7.2 '@vitest/expect': 0.31.0 '@vitest/runner': 0.31.0 '@vitest/snapshot': 0.31.0 @@ -5635,8 +5662,8 @@ packages: strip-literal: 1.0.1 tinybench: 2.5.0 tinypool: 0.5.0 - vite: 4.4.2(@types/node@20.4.1) - vite-node: 0.31.0(@types/node@20.4.1) + vite: 4.4.2(@types/node@22.7.2) + vite-node: 0.31.0(@types/node@22.7.2) why-is-node-running: 2.2.2 transitivePeerDependencies: - less diff --git a/scripts/codegen-webhooks.sh b/scripts/codegen-webhooks.sh index e099583..da38302 100644 --- a/scripts/codegen-webhooks.sh +++ b/scripts/codegen-webhooks.sh @@ -1,7 +1,6 @@ #!/bin/bash - # Download the JSON schema -curl https://core-api.uk.plain.com/webhooks/schema.json -o ./src/webhooks/webhook-schema.json +curl https://core-api.uk.plain.com/webhooks/schema/latest.json -o ./src/webhooks/webhook-schema.json -./node_modules/.bin/json2ts --input ./src/webhooks/webhook-schema.json --output ./src/webhooks/webhook-schema.ts \ No newline at end of file +./node_modules/.bin/json2ts --input ./src/webhooks/webhook-schema.json --output ./src/webhooks/webhook-schema.ts diff --git a/src/index.ts b/src/index.ts index 2a3ccec..2d469df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,15 @@ export { PlainClient } from './client'; -export { parsePlainWebhook } from './webhooks'; +export { + parsePlainWebhook, + verifyPlainWebhook, + PlainWebhookError, + PlainWebhookPayloadError, + PlainWebhookSignatureVerificationError, + PlainWebhookVersionMismatchError, +} from './webhooks'; + export type { WebhooksSchemaDefinition, CustomerChangedPayload, diff --git a/src/result.ts b/src/result.ts index 16b49af..d79c102 100644 --- a/src/result.ts +++ b/src/result.ts @@ -11,3 +11,7 @@ type Err = { }; export type Result = NonNullable | Err>; + +export const isErr = (result: Result): result is Err => { + return !!result.error; +}; diff --git a/src/tests/parse-webhook.test.ts b/src/tests/parse-webhook.test.ts index 42673e5..a633283 100644 --- a/src/tests/parse-webhook.test.ts +++ b/src/tests/parse-webhook.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, it, test } from 'vitest'; +import { PlainWebhookPayloadError, PlainWebhookVersionMismatchError } from '../webhooks/errors'; import { parsePlainWebhook } from '../webhooks/parse'; import customerCreatedPayload from './webhook-payloads/customer-created'; import emailReceivedPayload from './webhook-payloads/email-received'; @@ -24,4 +25,45 @@ describe('Parse webhook', () => { test('should fail to validate an invalid payload', () => { expect(parsePlainWebhook(invalidWebhook).error).toBeTruthy(); }); + + it('accepts a stringified payload', () => { + const result = parsePlainWebhook(JSON.stringify(threadCreatedPayload)); + expect(result.data).toBeTruthy(); + }); + + it('returns a human-readable error message when the payload is not a valid webhook payload', () => { + const invalidPayload = { + ...threadCreatedPayload, + payload: { + ...threadCreatedPayload.payload, + thread: { + ...threadCreatedPayload.payload.thread, + title: undefined, + }, + }, + }; + + const result = parsePlainWebhook(invalidPayload); + + expect(result.error).instanceOf(PlainWebhookPayloadError); + expect(result.error?.message).toBe("data/payload/thread must have required property 'title'"); + }); + + it('returns a version mismatch error', () => { + const invalidWebhook = { + ...threadCreatedPayload, + + webhookMetadata: { + ...threadCreatedPayload.webhookMetadata, + webhookTargetVersion: 'NEW_VERSION', + }, + }; + + const result = parsePlainWebhook(invalidWebhook); + + expect(result.error).instanceOf(PlainWebhookVersionMismatchError); + expect(result.error?.message).toBe( + 'The webhook payload (version=NEW_VERSION) is incompatible with the current version of the SDK. Please upgrade both the SDK and the webhook target to the latest version. Refer to https://www.plain.com/docs/api-reference/webhooks/versions for more information. Original error: data/webhookMetadata/webhookTargetVersion must be equal to constant' + ); + }); }); diff --git a/src/tests/verify-plain-webhook.test.ts b/src/tests/verify-plain-webhook.test.ts new file mode 100644 index 0000000..f438404 --- /dev/null +++ b/src/tests/verify-plain-webhook.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + PlainWebhookPayloadError, + PlainWebhookSignatureVerificationError, +} from '../webhooks/errors'; +import { verifyPlainWebhook } from '../webhooks/verify'; +import threadCreatedPayload from './webhook-payloads/thread-created'; + +describe('verifyPlainWebhook', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns an error when the payload is empty', () => { + const result = verifyPlainWebhook('', 'signature', 'secret'); + + expect(result.error).instanceOf(PlainWebhookSignatureVerificationError); + expect(result.error?.message).toBe('No webhook payload was provided.'); + }); + + it('returns an error when the signature is empty', () => { + const result = verifyPlainWebhook('payload', '', 'secret'); + + expect(result.error).instanceOf(PlainWebhookSignatureVerificationError); + expect(result.error?.message).toBe( + 'No signature header value was provided. Please pass the value of the "Plain-Request-Signature" header.' + ); + }); + + it('returns an error when the secret is empty', () => { + const result = verifyPlainWebhook('payload', 'signature', ''); + + expect(result.error).instanceOf(PlainWebhookSignatureVerificationError); + expect(result.error?.message).toBe( + 'No webhook secret was provided. You can find your webhook secret in your workspace settings.' + ); + }); + + it('returns an error when the signature does not match', () => { + const result = verifyPlainWebhook('payload', 'signature', 'secret'); + + expect(result.error).instanceOf(PlainWebhookSignatureVerificationError); + expect(result.error?.message).toBe('The signature provided is invalid.'); + }); + + it('returns an error when the signature matches but the timestamp is too far in the past', () => { + const result = verifyPlainWebhook( + JSON.stringify(threadCreatedPayload), + '22f44d327c69161903b4656717862e5a535e93248e70f0d42c4d0a52962ce0e9', + 'secret' + ); + + expect(result.error).instanceOf(PlainWebhookSignatureVerificationError); + expect(result.error?.message).toBe( + 'The timestamp provided in the webhook payload is too far in the past. The maximum allowed difference is 300 seconds.' + ); + }); + + it("doesn't return an error when the signature matches and the timestamp is within the tolerance", () => { + // +5 minutes - 1 second + vi.setSystemTime(new Date(Date.UTC(2023, 9, 19, 14, 17, 26))); + + const result = verifyPlainWebhook( + JSON.stringify(threadCreatedPayload), + '22f44d327c69161903b4656717862e5a535e93248e70f0d42c4d0a52962ce0e9', + 'secret' + ); + + expect(result.error).toBeUndefined(); + expect(result.data?.type).toBe('thread.thread_created'); + }); + + it('returns an error when the payload is not a valid JSON object', () => { + const result = verifyPlainWebhook( + 'hello-world', + '1bff4699de4fb5202a4b1e6cefd7b5fdfb02d19a67a1eb371dd417a45b0a47df', + 'secret' + ); + + expect(result.error).instanceOf(PlainWebhookPayloadError); + expect(result.error?.message).toBe('The webhook payload is not a valid JSON object.'); + }); + + it('returns an error when the payload is not a valid webhook payload', () => { + const invalidPayload = { + ...threadCreatedPayload, + payload: { + ...threadCreatedPayload.payload, + thread: { + ...threadCreatedPayload.payload.thread, + title: undefined, + }, + }, + }; + + const result = verifyPlainWebhook( + JSON.stringify(invalidPayload), + 'd7476d183d9e9a52dd7796c769641b89fe61443f62ca8d68c720815a9cf43ca6', + 'secret' + ); + + expect(result.error).instanceOf(PlainWebhookPayloadError); + expect(result.error?.message).toBe("data/payload/thread must have required property 'title'"); + }); +}); diff --git a/src/tests/webhook-payloads/customer-created.ts b/src/tests/webhook-payloads/customer-created.ts index 7c1cda6..ae2e2b1 100644 --- a/src/tests/webhook-payloads/customer-created.ts +++ b/src/tests/webhook-payloads/customer-created.ts @@ -15,8 +15,6 @@ export default { shortName: 'Peter', markedAsSpamAt: null, markedAsSpamBy: null, - assignedAt: null, - assignedToUser: null, customerGroupMemberships: [], createdAt: '2023-10-04T14:17:41.991Z', createdBy: { @@ -33,6 +31,7 @@ export default { id: 'pEv_01HD44FHDPMZ3YJB5GEB1EZKQV', webhookMetadata: { webhookTargetId: 'whTarget_01HD4400VTDJQ646V6RY37SR7K', + webhookTargetVersion: '2024-09-18', webhookDeliveryAttemptId: 'whAttempt_01HD44FJ45FJKVFHM3MDVYPGRS', webhookDeliveryAttemptNumber: 1, webhookDeliveryAttemptTimestamp: '2023-10-19T14:12:25.861Z', diff --git a/src/tests/webhook-payloads/email-received.ts b/src/tests/webhook-payloads/email-received.ts index d7b95fe..0047dda 100644 --- a/src/tests/webhook-payloads/email-received.ts +++ b/src/tests/webhook-payloads/email-received.ts @@ -17,8 +17,6 @@ export default { shortName: 'John', markedAsSpamAt: null, markedAsSpamBy: null, - assignedAt: null, - assignedToUser: null, customerGroupMemberships: [], createdAt: '2023-05-17T09:41:55.717Z', createdBy: { actorType: 'system', system: 'email_inbound_handler' }, @@ -107,6 +105,7 @@ export default { id: 'pEv_01HR9W91EMR655WS6VC2867D3C', webhookMetadata: { webhookTargetId: 'whTarget_01HR9VYX2GYKX1XCTFXRG1K3MX', + webhookTargetVersion: '2024-09-18', webhookDeliveryAttemptId: 'whAttempt_01HR9W92RSJZA4011XDNHJ5VK7', webhookDeliveryAttemptNumber: 1, webhookDeliveryAttemptTimestamp: '2024-03-06T12:37:11.577Z', diff --git a/src/tests/webhook-payloads/invalid.ts b/src/tests/webhook-payloads/invalid.ts index 65919cb..d415084 100644 --- a/src/tests/webhook-payloads/invalid.ts +++ b/src/tests/webhook-payloads/invalid.ts @@ -48,6 +48,7 @@ export default { id: 'pEv_01HR9W25SFVMS2Y4Q8W75M86G4', webhookMetadata: { webhookTargetId: 'whTarget_01HR9VYX2GYKX1XCTFXRG1K3MX', + webhookTargetVersion: '2024-09-18', webhookDeliveryAttemptId: 'whAttempt_01HR9W26906XCJ64JQZG8RJCCQ', webhookDeliveryAttemptNumber: 1, webhookDeliveryAttemptTimestamp: '2024-03-06T12:33:25.792Z', diff --git a/src/tests/webhook-payloads/thread-assignment-transitioned.ts b/src/tests/webhook-payloads/thread-assignment-transitioned.ts index 20ec84c..479a8ec 100644 --- a/src/tests/webhook-payloads/thread-assignment-transitioned.ts +++ b/src/tests/webhook-payloads/thread-assignment-transitioned.ts @@ -15,8 +15,6 @@ export default { externalId: null, fullName: 'John Smith', shortName: 'John', - assignedAt: null, - assignedToUser: null, markedAsSpamAt: null, markedAsSpamBy: null, customerGroupMemberships: [], @@ -72,8 +70,6 @@ export default { shortName: 'John', markedAsSpamAt: null, markedAsSpamBy: null, - assignedAt: null, - assignedToUser: null, customerGroupMemberships: [], createdAt: '2023-12-05T14:07:27.142Z', createdBy: { actorType: 'system', system: 'email_inbound_handler' }, @@ -136,6 +132,7 @@ export default { id: 'pEv_01HR9W4K5QEVAFQSYCKN2D8198', webhookMetadata: { webhookTargetId: 'whTarget_01HR9VYX2GYKX1XCTFXRG1K3MX', + webhookTargetVersion: '2024-09-18', webhookDeliveryAttemptId: 'whAttempt_01HR9W4KV3RHX435FPZJ46P5WY', webhookDeliveryAttemptNumber: 1, webhookDeliveryAttemptTimestamp: '2024-03-06T12:34:45.219Z', diff --git a/src/tests/webhook-payloads/thread-created.ts b/src/tests/webhook-payloads/thread-created.ts index d7081e0..77b80d3 100644 --- a/src/tests/webhook-payloads/thread-created.ts +++ b/src/tests/webhook-payloads/thread-created.ts @@ -15,8 +15,6 @@ export default { externalId: null, fullName: 'Peter Santos', shortName: 'Peter', - assignedAt: null, - assignedToUser: null, markedAsSpamAt: null, markedAsSpamBy: null, customerGroupMemberships: [], @@ -68,6 +66,7 @@ export default { id: 'pEv_01HD44FHHJ0YABSNGKWMG3CJ5J', webhookMetadata: { webhookTargetId: 'whTarget_01HD4400VTDJQ646V6RY37SR7K', + webhookTargetVersion: '2024-09-18', webhookDeliveryAttemptId: 'whAttempt_01HD44FJASQM23MNHYDYPAXEG8', webhookDeliveryAttemptNumber: 1, webhookDeliveryAttemptTimestamp: '2023-10-19T14:12:26.073Z', diff --git a/src/tests/webhook-payloads/thread-status-transitioned.ts b/src/tests/webhook-payloads/thread-status-transitioned.ts index 01ed950..c7342c4 100644 --- a/src/tests/webhook-payloads/thread-status-transitioned.ts +++ b/src/tests/webhook-payloads/thread-status-transitioned.ts @@ -15,8 +15,6 @@ export default { externalId: null, fullName: 'John Smith', shortName: 'John', - assignedAt: null, - assignedToUser: null, markedAsSpamAt: null, markedAsSpamBy: null, customerGroupMemberships: [], @@ -72,8 +70,6 @@ export default { shortName: 'John', markedAsSpamAt: null, markedAsSpamBy: null, - assignedAt: null, - assignedToUser: null, customerGroupMemberships: [], createdAt: '2023-12-05T14:07:27.142Z', createdBy: { actorType: 'system', system: 'email_inbound_handler' }, @@ -117,6 +113,7 @@ export default { id: 'pEv_01HR9W25SFVMS2Y4Q8W75M86G4', webhookMetadata: { webhookTargetId: 'whTarget_01HR9VYX2GYKX1XCTFXRG1K3MX', + webhookTargetVersion: '2024-09-18', webhookDeliveryAttemptId: 'whAttempt_01HR9W26906XCJ64JQZG8RJCCQ', webhookDeliveryAttemptNumber: 1, webhookDeliveryAttemptTimestamp: '2024-03-06T12:33:25.792Z', diff --git a/src/webhooks/errors.ts b/src/webhooks/errors.ts new file mode 100644 index 0000000..17d6811 --- /dev/null +++ b/src/webhooks/errors.ts @@ -0,0 +1,13 @@ +export abstract class PlainWebhookError extends Error {} + +export class PlainWebhookSignatureVerificationError extends PlainWebhookError {} + +export class PlainWebhookVersionMismatchError extends PlainWebhookError { + constructor(error: string, payloadVersion: string) { + super( + `The webhook payload (version=${payloadVersion}) is incompatible with the current version of the SDK. Please upgrade both the SDK and the webhook target to the latest version. Refer to https://www.plain.com/docs/api-reference/webhooks/versions for more information. Original error: ${error}` + ); + } +} + +export class PlainWebhookPayloadError extends PlainWebhookError {} diff --git a/src/webhooks/index.ts b/src/webhooks/index.ts index d1c8775..4697d35 100644 --- a/src/webhooks/index.ts +++ b/src/webhooks/index.ts @@ -1,4 +1,12 @@ export { parsePlainWebhook } from './parse'; +export { verifyPlainWebhook } from './verify'; +export { + PlainWebhookError, + PlainWebhookSignatureVerificationError, + PlainWebhookVersionMismatchError, + PlainWebhookPayloadError, +} from './errors'; + export type { CustomerChangedPayload, CustomerCreatedPublicEventPayload, diff --git a/src/webhooks/parse.ts b/src/webhooks/parse.ts index 31c1791..556cffb 100644 --- a/src/webhooks/parse.ts +++ b/src/webhooks/parse.ts @@ -1,32 +1,135 @@ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; +import _get from 'lodash.get'; -import type { Result } from '../result'; +import { type Result, isErr } from '../result'; +import { + type PlainWebhookError, + PlainWebhookPayloadError, + PlainWebhookVersionMismatchError, +} from './errors'; import type { WebhooksSchemaDefinition } from './webhook-schema'; import webhookSchema from './webhook-schema.json'; -export function parsePlainWebhook(payload: unknown): Result { +export function parsePlainWebhook( + unknownPayload: unknown +): Result { + const payloadResult = (() => { + if (typeof unknownPayload === 'string') { + try { + return { data: JSON.parse(unknownPayload) }; + } catch (e) { + return { + error: new PlainWebhookPayloadError('The webhook payload is not a valid JSON object.'), + }; + } + } + + return { data: unknownPayload }; + })(); + + if (isErr(payloadResult)) { + return payloadResult; + } + + const payload = payloadResult.data; + const ajv = new Ajv({ unicodeRegExp: false, }); addFormats(ajv); + if (ajv.validate(webhookSchema, payload)) { + return { data: payload }; + } + try { - if (ajv.validate(webhookSchema, payload)) { - return { - data: payload, - }; - } + return { error: getParseError(payload, ajv.errorsText()) }; } catch (e) { return { - error: - e instanceof Error - ? e - : new Error('An unknown error occurred while parsing the Plain webhook payload'), + error: new PlainWebhookPayloadError( + `Failed to parse the webhook payload: ${ajv.errorsText()}` + ), }; } +} + +function getParseError( + payload: Record, + originalAjvError: string +): PlainWebhookError { + const errorMessage = getErrorMessageForHumans(payload, originalAjvError); + + if (isVersionMismatch(payload)) { + return new PlainWebhookVersionMismatchError(errorMessage, getPayloadVersion(payload) ?? ''); + } + + return new PlainWebhookPayloadError(errorMessage); +} + +function isVersionMismatch(payload: Record): boolean { + const payloadVersion = getPayloadVersion(payload); + return typeof payloadVersion === 'string' && payloadVersion !== getSchemaVersion(); +} + +function getErrorMessageForHumans( + payload: Record, + originalAjvError: string +): string { + try { + const eventType = payload.type; + if (typeof eventType !== 'string') { + return originalAjvError; + } + + const payloadDefinitionKey = (() => { + for (const [key, definition] of Object.entries(webhookSchema.definitions)) { + if (_get(definition, 'properties.eventType.const') === eventType) { + return key; + } + } + return null; + })(); + + if (payloadDefinitionKey === null) { + return originalAjvError; + } + + const updatedSchema = { + ...webhookSchema, + properties: { + ...webhookSchema.properties, + payload: { + $ref: `#/definitions/${payloadDefinitionKey}`, + }, + }, + }; + + const ajv = new Ajv({ + unicodeRegExp: false, + }); + addFormats(ajv); + + if (ajv.validate(updatedSchema, payload)) { + return originalAjvError; + } + + return ajv.errorsText(); + } catch (e) { + return originalAjvError; + } +} + +function getPayloadVersion(payload: Record): string | null { + const ret = _get(payload, 'webhookMetadata.webhookTargetVersion'); + return typeof ret === 'string' ? ret : null; +} + +function getSchemaVersion(): string | null { + const ret = _get( + webhookSchema, + 'definitions.webhookMetadata.properties.webhookTargetVersion.const' + ); - return { - error: new Error('Plain webhook payload failed to parse: ' + ajv.errorsText()), - }; + return typeof ret === 'string' ? ret : null; } diff --git a/src/webhooks/verify.ts b/src/webhooks/verify.ts new file mode 100644 index 0000000..25122cf --- /dev/null +++ b/src/webhooks/verify.ts @@ -0,0 +1,99 @@ +import * as crypto from 'node:crypto'; + +import { type Result, isErr } from '../result'; +import { + type PlainWebhookError, + PlainWebhookPayloadError, + PlainWebhookSignatureVerificationError, +} from './errors'; +import { parsePlainWebhook } from './parse'; +import type { WebhooksSchemaDefinition } from './webhook-schema'; + +/** + * Verifies and validates a Plain webhook payload. + * + * @param payload - The raw request body. + * @param signature - The value of the 'Plain-Request-Signature' header. + * @param secret - Your Plain webhook signing secret. + * @param tolerance - (Optional) Maximum allowed difference in seconds between the timestamp and the current time. Defaults to 300 seconds. This is helpful to prevent replay attacks. + * @returns A Result object containing the validated payload or an error. + */ +export function verifyPlainWebhook( + payloadRaw: string, + signature: string, + secret: string, + tolerance = 300 // 5 minutes +): Result { + const signatureVerificationResult = verifyWebhookSignature(payloadRaw, signature, secret); + if (isErr(signatureVerificationResult)) { + return signatureVerificationResult; + } + + const payloadResult = parsePlainWebhook(payloadRaw); + if (isErr(payloadResult)) { + return payloadResult; + } + + const payload = payloadResult.data; + const timestamp = Date.parse(payload.webhookMetadata.webhookDeliveryAttemptTimestamp); + + if (Number.isNaN(timestamp)) { + return { + error: new PlainWebhookPayloadError( + `Invalid timestamp provided in the webhook payload: ${timestamp}. This is likely a bug in the Plain API (eventId: ${payload.id}).` + ), + }; + } + + const currentTime = Date.now(); + if (Math.abs(currentTime - timestamp) > tolerance * 1000) { + return { + error: new PlainWebhookSignatureVerificationError( + `The timestamp provided in the webhook payload is too far in the past. The maximum allowed difference is ${tolerance} seconds.` + ), + }; + } + + return { data: payload }; +} + +function verifyWebhookSignature( + payload: string, + signature: string, + secret: string +): Result { + if (payload.length === 0) { + return { + error: new PlainWebhookSignatureVerificationError('No webhook payload was provided.'), + }; + } + + if (signature.length === 0) { + return { + error: new PlainWebhookSignatureVerificationError( + 'No signature header value was provided. Please pass the value of the "Plain-Request-Signature" header.' + ), + }; + } + + if (secret.length === 0) { + return { + error: new PlainWebhookSignatureVerificationError( + 'No webhook secret was provided. You can find your webhook secret in your workspace settings.' + ), + }; + } + + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(payload, 'utf8') + .digest('hex'); + + if (signature !== expectedSignature) { + return { + error: new PlainWebhookSignatureVerificationError('The signature provided is invalid.'), + }; + } + + return { data: true }; +} diff --git a/src/webhooks/webhook-schema.json b/src/webhooks/webhook-schema.json index 30ba9aa..b60273d 100644 --- a/src/webhooks/webhook-schema.json +++ b/src/webhooks/webhook-schema.json @@ -78,6 +78,9 @@ }, { "$ref": "#/definitions/customerDeletedPublicEventPayload" + }, + { + "$ref": "#/definitions/threadNoteCreatedEventPayload" } ] }, @@ -125,7 +128,7 @@ "type", "webhookMetadata" ], - "additionalProperties": false, + "additionalProperties": true, "description": "Webhook request", "definitions": { "id": { @@ -164,7 +167,7 @@ "actorType", "userId" ], - "additionalProperties": false + "additionalProperties": true }, "customerActor": { "type": "object", @@ -181,7 +184,7 @@ "actorType", "customerId" ], - "additionalProperties": false + "additionalProperties": true }, "systemActor": { "type": "object", @@ -198,7 +201,7 @@ "actorType", "system" ], - "additionalProperties": false + "additionalProperties": true }, "machineUserActor": { "type": "object", @@ -215,10 +218,23 @@ "actorType", "machineUserId" ], - "additionalProperties": false + "additionalProperties": true }, "internalActor": { "anyOf": [ + { + "type": "object", + "properties": { + "actorType": { + "type": "string", + "const": "UNKNOWN" + } + }, + "required": [ + "actorType" + ], + "additionalProperties": true + }, { "$ref": "#/definitions/userActor" }, @@ -233,7 +249,7 @@ "actor": { "anyOf": [ { - "$ref": "#/definitions/customerActor" + "$ref": "#/definitions/internalActor/anyOf/0" }, { "$ref": "#/definitions/userActor" @@ -243,6 +259,9 @@ }, { "$ref": "#/definitions/systemActor" + }, + { + "$ref": "#/definitions/customerActor" } ] }, @@ -266,7 +285,8 @@ "enum": [ "ONLINE", "OFFLINE", - "BREAK" + "BREAK", + "UNKNOWN_USER_STATUS" ] }, "statusChangedAt": { @@ -319,7 +339,7 @@ "deletedAt", "deletedBy" ], - "additionalProperties": false + "additionalProperties": true }, "machineUser": { "type": "object", @@ -384,7 +404,7 @@ "deletedAt", "deletedBy" ], - "additionalProperties": false + "additionalProperties": true }, "customerGroup": { "type": "object", @@ -433,7 +453,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "customerGroupMembership": { "type": "object", @@ -473,7 +493,7 @@ "updatedBy", "customerGroup" ], - "additionalProperties": false + "additionalProperties": true }, "customer": { "type": "object", @@ -506,7 +526,7 @@ "isVerified", "verifiedAt" ], - "additionalProperties": false + "additionalProperties": true }, "externalId": { "type": [ @@ -523,26 +543,6 @@ "null" ] }, - "assignedAt": { - "anyOf": [ - { - "$ref": "#/definitions/datetime" - }, - { - "type": "null" - } - ] - }, - "assignedToUser": { - "anyOf": [ - { - "$ref": "#/definitions/user" - }, - { - "type": "null" - } - ] - }, "markedAsSpamAt": { "anyOf": [ { @@ -590,15 +590,13 @@ "externalId", "fullName", "shortName", - "assignedAt", - "assignedToUser", "customerGroupMemberships", "createdAt", "createdBy", "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "attachment": { "type": "object", @@ -648,18 +646,22 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "emailAuthenticity": { "type": "string", "enum": [ "PASS", "FAIL", - "UNKNOWN" + "UNKNOWN", + "UNKNOWN_EMAIL_AUTHENTICITY" ] }, "emailActor": { "anyOf": [ + { + "$ref": "#/definitions/internalActor/anyOf/0" + }, { "$ref": "#/definitions/userActor" }, @@ -681,7 +683,7 @@ "actorType", "supportEmailAddress" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -698,7 +700,7 @@ "actorType", "customerId" ], - "additionalProperties": false + "additionalProperties": true } ] }, @@ -730,7 +732,7 @@ "name", "emailActor" ], - "additionalProperties": false + "additionalProperties": true }, "email": { "type": "object", @@ -856,7 +858,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "chat": { "type": "object", @@ -919,7 +921,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "customEntryAttachment": { "type": "object", @@ -971,7 +973,7 @@ "updatedBy", "type" ], - "additionalProperties": false + "additionalProperties": true }, "chatEntryAttachment": { "type": "object", @@ -1023,7 +1025,7 @@ "updatedBy", "type" ], - "additionalProperties": false + "additionalProperties": true }, "emailEntryAttachment": { "type": "object", @@ -1079,14 +1081,15 @@ "type", "emailContentId" ], - "additionalProperties": false + "additionalProperties": true }, "componentTextSize": { "type": "string", "enum": [ "S", "M", - "L" + "L", + "UNKNOWN_COMPONENT_TEXT_SIZE" ] }, "componentTextColor": { @@ -1096,7 +1099,8 @@ "MUTED", "SUCCESS", "WARNING", - "ERROR" + "ERROR", + "UNKNOWN_COMPONENT_TEXT_COLOR" ] }, "componentPlainTextSize": { @@ -1104,7 +1108,8 @@ "enum": [ "S", "M", - "L" + "L", + "UNKNOWN_COMPONENT_PLAIN_TEXT_SIZE" ] }, "componentPlainTextColor": { @@ -1114,7 +1119,8 @@ "MUTED", "SUCCESS", "WARNING", - "ERROR" + "ERROR", + "UNKNOWN_COMPONENT_PLAIN_TEXT_COLOR" ] }, "componentSpacerSize": { @@ -1124,7 +1130,8 @@ "S", "M", "L", - "XL" + "XL", + "UNKNOWN_COMPONENT_SPACER_SIZE" ] }, "componentDividerSpacingSize": { @@ -1134,7 +1141,8 @@ "S", "M", "L", - "XL" + "XL", + "UNKNOWN_COMPONENT_DIVIDER_SPACING_SIZE" ] }, "componentBadgeColor": { @@ -1144,7 +1152,8 @@ "GREEN", "YELLOW", "RED", - "BLUE" + "BLUE", + "UNKNOWN_COMPONENT_BADGE_COLOR" ] }, "componentText": { @@ -1157,7 +1166,7 @@ "textSize": { "anyOf": [ { - "$ref": "#/definitions/componentPlainTextSize" + "$ref": "#/definitions/componentTextSize" }, { "type": "null" @@ -1167,7 +1176,7 @@ "textColor": { "anyOf": [ { - "$ref": "#/definitions/componentPlainTextColor" + "$ref": "#/definitions/componentTextColor" }, { "type": "null" @@ -1186,7 +1195,7 @@ "textColor", "text" ], - "additionalProperties": false + "additionalProperties": true }, "componentPlainText": { "type": "object", @@ -1227,13 +1236,13 @@ "plainText", "type" ], - "additionalProperties": false + "additionalProperties": true }, "componentSpacer": { "type": "object", "properties": { "spacerSize": { - "$ref": "#/definitions/componentDividerSpacingSize" + "$ref": "#/definitions/componentSpacerSize" }, "type": { "type": "string", @@ -1244,7 +1253,7 @@ "spacerSize", "type" ], - "additionalProperties": false + "additionalProperties": true }, "componentDivider": { "type": "object", @@ -1268,7 +1277,7 @@ "dividerSpacingSize", "type" ], - "additionalProperties": false + "additionalProperties": true }, "componentLinkButton": { "type": "object", @@ -1291,7 +1300,7 @@ "linkButtonLabel", "type" ], - "additionalProperties": false + "additionalProperties": true }, "componentBadge": { "type": "object", @@ -1320,7 +1329,7 @@ "badgeColor", "type" ], - "additionalProperties": false + "additionalProperties": true }, "componentCopyButton": { "type": "object", @@ -1350,10 +1359,23 @@ "copyButtonTooltipLabel", "type" ], - "additionalProperties": false + "additionalProperties": true }, "componentRowContent": { "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "UNKNOWN" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, { "$ref": "#/definitions/componentText" }, @@ -1402,10 +1424,13 @@ "rowMainContent", "rowAsideContent" ], - "additionalProperties": false + "additionalProperties": true }, "componentContainerContent": { "anyOf": [ + { + "$ref": "#/definitions/componentRowContent/anyOf/0" + }, { "$ref": "#/definitions/componentText" }, @@ -1450,10 +1475,13 @@ "type", "containerContent" ], - "additionalProperties": false + "additionalProperties": true }, "component": { "anyOf": [ + { + "$ref": "#/definitions/componentRowContent/anyOf/0" + }, { "$ref": "#/definitions/componentText" }, @@ -1548,7 +1576,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "label": { "type": "object", @@ -1580,7 +1608,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "tier": { "type": "object", @@ -1641,13 +1669,16 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "threadPriority": { "type": "number" }, "threadAssignee": { "anyOf": [ + { + "$ref": "#/definitions/componentRowContent/anyOf/0" + }, { "$ref": "#/definitions/user" }, @@ -1664,7 +1695,7 @@ "required": [ "id" ], - "additionalProperties": false + "additionalProperties": true } ] }, @@ -1673,7 +1704,8 @@ "enum": [ "TODO", "DONE", - "SNOOZED" + "SNOOZED", + "UNKNOWN_THREAD_STATUS" ] }, "threadMessageInfo": { @@ -1688,7 +1720,9 @@ "CHAT", "EMAIL", "API", - "SLACK" + "SLACK", + "MS_TEAMS", + "UNKNOWN_THREAD_MESSAGE_INFO_MESSAGE_SOURCE" ] }, "actorId": { @@ -1720,10 +1754,13 @@ "timestamp", "messageSource" ], - "additionalProperties": false + "additionalProperties": true }, "threadStatusDetail": { "anyOf": [ + { + "$ref": "#/definitions/componentRowContent/anyOf/0" + }, { "type": "object", "properties": { @@ -1739,101 +1776,151 @@ "type", "createdAt" ], - "additionalProperties": false + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "NEW_REPLY" + } + }, + "required": [ + "type" + ], + "additionalProperties": true }, { "type": "object", "properties": { "type": { "type": "string", - "const": "SNOOZED" + "const": "IN_PROGRESS" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "WAITING_FOR_CUSTOMER" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "WAITING_FOR_DURATION" }, - "snoozedAt": { + "waitingUntil": { "$ref": "#/definitions/datetime" }, - "snoozedUntil": { - "$ref": "#/definitions/datetime" + "durationSeconds": { + "type": "number" } }, "required": [ "type", - "snoozedAt", - "snoozedUntil" + "waitingUntil", + "durationSeconds" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", "properties": { "type": { "type": "string", - "const": "UNSNOOZED" + "const": "THREAD_LINK_UPDATED" }, - "snoozedAt": { - "$ref": "#/definitions/datetime" + "linear": { + "type": "object", + "properties": { + "issueId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "issueId" + ], + "additionalProperties": true } }, "required": [ - "type", - "snoozedAt" + "type" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", "properties": { "type": { "type": "string", - "const": "NEW_REPLY" - }, - "newReplyAt": { - "$ref": "#/definitions/datetime" + "const": "DONE_MANUALLY_SET" } }, "required": [ - "type", - "newReplyAt" + "type" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", "properties": { "type": { "type": "string", - "const": "LINK_LINEAR_UPDATED" - }, - "updatedAt": { - "$ref": "#/definitions/datetime" - }, - "linearIssueId": { + "const": "IGNORED" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { "type": "string", - "format": "uuid" + "const": "DONE_AUTOMATICALLY_SET" + }, + "afterSeconds": { + "type": "number" } }, "required": [ - "type", - "updatedAt", - "linearIssueId" + "type" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", "properties": { "type": { "type": "string", - "const": "REPLIED" + "const": "THREAD_DISCUSSION_RESOLVED" }, - "repliedAt": { - "$ref": "#/definitions/datetime" + "threadDiscussionId": { + "$ref": "#/definitions/id" } }, "required": [ - "type", - "repliedAt" + "type" ], - "additionalProperties": false + "additionalProperties": true } ] }, @@ -1854,7 +1941,8 @@ "enum": [ "STRING", "BOOL", - "ENUM" + "ENUM", + "UNKNOWN_THREAD_FIELD_SCHEMA_TYPE" ] }, "stringValue": { @@ -1894,7 +1982,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "thread": { "type": "object", @@ -2071,7 +2159,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "noteEntry": { "type": "object", @@ -2107,7 +2195,7 @@ "text", "markdown" ], - "additionalProperties": false + "additionalProperties": true }, "chatEntry": { "type": "object", @@ -2149,7 +2237,7 @@ "attachments", "customerReadAt" ], - "additionalProperties": false + "additionalProperties": true }, "emailEntry": { "type": "object", @@ -2261,7 +2349,7 @@ "inReplyToEmailId", "isStartOfThread" ], - "additionalProperties": false + "additionalProperties": true }, "customEntry": { "type": "object", @@ -2313,7 +2401,7 @@ "components", "attachments" ], - "additionalProperties": false + "additionalProperties": true }, "timelineEntry": { "type": "object", @@ -2343,6 +2431,19 @@ }, "entry": { "anyOf": [ + { + "type": "object", + "properties": { + "entryType": { + "type": "string", + "const": "UNKNOWN" + } + }, + "required": [ + "entryType" + ], + "additionalProperties": true + }, { "$ref": "#/definitions/noteEntry" }, @@ -2365,10 +2466,23 @@ "actor", "entry" ], - "additionalProperties": false + "additionalProperties": true }, "serviceLevelAgreementStatusDetail": { "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "UNKNOWN" + } + }, + "required": [ + "status" + ], + "additionalProperties": true + }, { "type": "object", "properties": { @@ -2384,7 +2498,7 @@ "breachTime", "status" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -2401,7 +2515,7 @@ "achievedAt", "status" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -2418,7 +2532,7 @@ "breachTime", "status" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -2435,7 +2549,7 @@ "breachedAt", "status" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -2456,7 +2570,7 @@ "completedAt", "status" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -2473,12 +2587,15 @@ "cancelledAt", "status" ], - "additionalProperties": false + "additionalProperties": true } ] }, "serviceLevelAgreement": { "anyOf": [ + { + "$ref": "#/definitions/componentRowContent/anyOf/0" + }, { "type": "object", "properties": { @@ -2529,7 +2646,7 @@ "type", "firstResponseTimeMinutes" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -2541,7 +2658,7 @@ "$ref": "#/definitions/tier" }, "useBusinessHoursOnly": { - "$ref": "#/definitions/serviceLevelAgreement/anyOf/0/properties/useBusinessHoursOnly" + "$ref": "#/definitions/serviceLevelAgreement/anyOf/1/properties/useBusinessHoursOnly" }, "threadPriorityFilter": { "type": "array", @@ -2581,7 +2698,7 @@ "type", "nextResponseTimeMinutes" ], - "additionalProperties": false + "additionalProperties": true } ] }, @@ -2642,7 +2759,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "msTeamsMessage": { "type": "object", @@ -2727,7 +2844,7 @@ "updatedAt", "updatedBy" ], - "additionalProperties": false + "additionalProperties": true }, "customerChangedPayload": { "type": "object", @@ -2763,7 +2880,7 @@ "customer", "previousCustomer" ], - "additionalProperties": false + "additionalProperties": true }, "timelineEntryChangedPayload": { "type": "object", @@ -2807,7 +2924,7 @@ "timelineEntry", "changeType" ], - "additionalProperties": false + "additionalProperties": true }, "webhookMetadata": { "type": "object", @@ -2815,6 +2932,10 @@ "webhookTargetId": { "$ref": "#/definitions/id" }, + "webhookTargetVersion": { + "type": "string", + "const": "2024-09-18" + }, "webhookDeliveryAttemptId": { "$ref": "#/definitions/id" }, @@ -2828,11 +2949,12 @@ }, "required": [ "webhookTargetId", + "webhookTargetVersion", "webhookDeliveryAttemptId", "webhookDeliveryAttemptNumber", "webhookDeliveryAttemptTimestamp" ], - "additionalProperties": false + "additionalProperties": true }, "customerGroupMembershipsChangedPayload": { "type": "object", @@ -2861,7 +2983,7 @@ "customer", "previousCustomer" ], - "additionalProperties": false + "additionalProperties": true }, "customerGroupChangedPayload": { "anyOf": [ @@ -2885,7 +3007,7 @@ "eventType", "customerGroup" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -2911,7 +3033,7 @@ "customerGroup", "previousCustomerGroup" ], - "additionalProperties": false + "additionalProperties": true }, { "type": "object", @@ -2933,7 +3055,7 @@ "eventType", "previousCustomerGroup" ], - "additionalProperties": false + "additionalProperties": true } ] }, @@ -2952,7 +3074,7 @@ "eventType", "thread" ], - "additionalProperties": false + "additionalProperties": true }, "threadStatusTransitionedPublicEventPayload": { "type": "object", @@ -2973,7 +3095,7 @@ "previousThread", "thread" ], - "additionalProperties": false + "additionalProperties": true }, "threadAssignmentTransitionedPublicEventPayload": { "type": "object", @@ -2994,7 +3116,7 @@ "previousThread", "thread" ], - "additionalProperties": false + "additionalProperties": true }, "threadEmailReceivedPublicEventPayload": { "type": "object", @@ -3015,7 +3137,7 @@ "thread", "email" ], - "additionalProperties": false + "additionalProperties": true }, "threadEmailSentPublicEventPayload": { "type": "object", @@ -3036,7 +3158,7 @@ "thread", "email" ], - "additionalProperties": false + "additionalProperties": true }, "threadLabelsChangedPublicEventPayload": { "type": "object", @@ -3061,7 +3183,7 @@ "thread", "previousThread" ], - "additionalProperties": false + "additionalProperties": true }, "threadPriorityChangedPublicEventPayload": { "type": "object", @@ -3082,7 +3204,7 @@ "previousThread", "thread" ], - "additionalProperties": false + "additionalProperties": true }, "threadFieldCreatedPublicEventPayload": { "type": "object", @@ -3103,7 +3225,7 @@ "thread", "threadField" ], - "additionalProperties": false + "additionalProperties": true }, "threadFieldUpdatedPublicEventPayload": { "type": "object", @@ -3128,7 +3250,7 @@ "previousThreadField", "threadField" ], - "additionalProperties": false + "additionalProperties": true }, "threadFieldDeletedPublicEventPayload": { "type": "object", @@ -3149,7 +3271,7 @@ "thread", "previousThreadField" ], - "additionalProperties": false + "additionalProperties": true }, "threadChatSentPublicEventPayload": { "type": "object", @@ -3170,7 +3292,7 @@ "chat", "thread" ], - "additionalProperties": false + "additionalProperties": true }, "threadNoteCreatedEventPayload": { "type": "object", @@ -3253,7 +3375,7 @@ "deletedAt", "deletedBy" ], - "additionalProperties": false + "additionalProperties": true } }, "required": [ @@ -3261,7 +3383,7 @@ "thread", "note" ], - "additionalProperties": false + "additionalProperties": true }, "threadServiceLevelAgreementStatusTransitionedPayload": { "type": "object", @@ -3290,7 +3412,7 @@ "previousServiceLevelAgreementStatusDetail", "serviceLevelAgreementStatusDetail" ], - "additionalProperties": false + "additionalProperties": true }, "threadSlackMessageReceivedEventPayload": { "type": "object", @@ -3311,7 +3433,7 @@ "thread", "slackMessage" ], - "additionalProperties": false + "additionalProperties": true }, "threadSlackMessageSentEventPayload": { "type": "object", @@ -3332,7 +3454,7 @@ "thread", "msTeamsMessage" ], - "additionalProperties": false + "additionalProperties": true }, "threadMSTeamsMessageReceivedEventPayload": { "type": "object", @@ -3353,7 +3475,7 @@ "thread", "msTeamsMessage" ], - "additionalProperties": false + "additionalProperties": true }, "threadMSTeamsMessageSentEventPayload": { "type": "object", @@ -3374,7 +3496,7 @@ "thread", "slackMessage" ], - "additionalProperties": false + "additionalProperties": true }, "customerCreatedPublicEventPayload": { "type": "object", @@ -3391,7 +3513,7 @@ "eventType", "customer" ], - "additionalProperties": false + "additionalProperties": true }, "customerUpdatedPublicEventPayload": { "type": "object", @@ -3412,7 +3534,7 @@ "customer", "previousCustomer" ], - "additionalProperties": false + "additionalProperties": true }, "customerDeletedPublicEventPayload": { "type": "object", @@ -3429,7 +3551,7 @@ "eventType", "previousCustomer" ], - "additionalProperties": false + "additionalProperties": true } }, "$schema": "http://json-schema.org/draft-07/schema#" diff --git a/src/webhooks/webhook-schema.ts b/src/webhooks/webhook-schema.ts index 9c9f2bd..55a7c6c 100644 --- a/src/webhooks/webhook-schema.ts +++ b/src/webhooks/webhook-schema.ts @@ -8,21 +8,46 @@ export type Datetime = string; export type Id = string; export type EmailAddress = string; -export type InternalActor = UserActor | MachineUserActor | SystemActor; -export type Actor = CustomerActor | UserActor | MachineUserActor | SystemActor; +export type InternalActor = + | { + actorType: "UNKNOWN"; + [k: string]: unknown; + } + | UserActor + | MachineUserActor + | SystemActor; +export type Actor = + | { + actorType: "UNKNOWN"; + [k: string]: unknown; + } + | UserActor + | MachineUserActor + | SystemActor + | CustomerActor; export type EmailActor = + | { + actorType: "UNKNOWN"; + [k: string]: unknown; + } | UserActor | CustomerActor | { actorType: "supportEmailAddress"; supportEmailAddress: string; + [k: string]: unknown; } | { actorType: "deletedCustomer"; customerId: Id; + [k: string]: unknown; }; -export type EmailAuthenticity = "PASS" | "FAIL" | "UNKNOWN"; +export type EmailAuthenticity = "PASS" | "FAIL" | "UNKNOWN" | "UNKNOWN_EMAIL_AUTHENTICITY"; export type Component = + | { + type: "UNKNOWN"; + [k: string]: unknown; + } | ComponentText | ComponentPlainText | ComponentSpacer @@ -32,11 +57,24 @@ export type Component = | ComponentCopyButton | ComponentRow | ComponentContainer; -export type ComponentPlainTextSize = "S" | "M" | "L"; -export type ComponentPlainTextColor = "NORMAL" | "MUTED" | "SUCCESS" | "WARNING" | "ERROR"; -export type ComponentDividerSpacingSize = "XS" | "S" | "M" | "L" | "XL"; -export type ComponentBadgeColor = "GREY" | "GREEN" | "YELLOW" | "RED" | "BLUE"; +export type ComponentTextSize = "S" | "M" | "L" | "UNKNOWN_COMPONENT_TEXT_SIZE"; +export type ComponentTextColor = "NORMAL" | "MUTED" | "SUCCESS" | "WARNING" | "ERROR" | "UNKNOWN_COMPONENT_TEXT_COLOR"; +export type ComponentPlainTextSize = "S" | "M" | "L" | "UNKNOWN_COMPONENT_PLAIN_TEXT_SIZE"; +export type ComponentPlainTextColor = + | "NORMAL" + | "MUTED" + | "SUCCESS" + | "WARNING" + | "ERROR" + | "UNKNOWN_COMPONENT_PLAIN_TEXT_COLOR"; +export type ComponentSpacerSize = "XS" | "S" | "M" | "L" | "XL" | "UNKNOWN_COMPONENT_SPACER_SIZE"; +export type ComponentDividerSpacingSize = "XS" | "S" | "M" | "L" | "XL" | "UNKNOWN_COMPONENT_DIVIDER_SPACING_SIZE"; +export type ComponentBadgeColor = "GREY" | "GREEN" | "YELLOW" | "RED" | "BLUE" | "UNKNOWN_COMPONENT_BADGE_COLOR"; export type ComponentRowContent = + | { + type: "UNKNOWN"; + [k: string]: unknown; + } | ComponentText | ComponentPlainText | ComponentSpacer @@ -45,6 +83,10 @@ export type ComponentRowContent = | ComponentBadge | ComponentCopyButton; export type ComponentContainerContent = + | { + type: "UNKNOWN"; + [k: string]: unknown; + } | ComponentText | ComponentPlainText | ComponentSpacer @@ -58,54 +100,93 @@ export type CustomerGroupChangedPayload = changeType: "ADDED"; eventType: "customer.customer_group_changed"; customerGroup: CustomerGroup; + [k: string]: unknown; } | { changeType: "UPDATED"; eventType: "customer.customer_group_changed"; customerGroup: CustomerGroup; previousCustomerGroup: CustomerGroup; + [k: string]: unknown; } | { changeType: "REMOVED"; eventType: "customer.customer_group_changed"; previousCustomerGroup: CustomerGroup; + [k: string]: unknown; }; export type ThreadPriority = number; -export type ThreadStatus = "TODO" | "DONE" | "SNOOZED"; +export type ThreadStatus = "TODO" | "DONE" | "SNOOZED" | "UNKNOWN_THREAD_STATUS"; export type ThreadStatusDetail = + | { + type: "UNKNOWN"; + [k: string]: unknown; + } | { type: "CREATED"; createdAt: Datetime; + [k: string]: unknown; } | { - type: "SNOOZED"; - snoozedAt: Datetime; - snoozedUntil: Datetime; + type: "NEW_REPLY"; + [k: string]: unknown; } | { - type: "UNSNOOZED"; - snoozedAt: Datetime; + type: "IN_PROGRESS"; + [k: string]: unknown; } | { - type: "NEW_REPLY"; - newReplyAt: Datetime; + type: "WAITING_FOR_CUSTOMER"; + [k: string]: unknown; } | { - type: "LINK_LINEAR_UPDATED"; - updatedAt: Datetime; - linearIssueId: string; + type: "WAITING_FOR_DURATION"; + waitingUntil: Datetime; + durationSeconds: number; + [k: string]: unknown; + } + | { + type: "THREAD_LINK_UPDATED"; + linear?: { + issueId: string; + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + type: "DONE_MANUALLY_SET"; + [k: string]: unknown; + } + | { + type: "IGNORED"; + [k: string]: unknown; + } + | { + type: "DONE_AUTOMATICALLY_SET"; + afterSeconds?: number; + [k: string]: unknown; } | { - type: "REPLIED"; - repliedAt: Datetime; + type: "THREAD_DISCUSSION_RESOLVED"; + threadDiscussionId?: Id; + [k: string]: unknown; }; export type ThreadAssignee = + | { + type: "UNKNOWN"; + [k: string]: unknown; + } | User | MachineUser | { id: string; + [k: string]: unknown; }; export type ServiceLevelAgreement = + | { + type: "UNKNOWN"; + [k: string]: unknown; + } | { id: Id; tier: Tier; @@ -117,6 +198,7 @@ export type ServiceLevelAgreement = updatedBy: InternalActor; type: "FIRST_RESPONSE_TIME"; firstResponseTimeMinutes: number; + [k: string]: unknown; } | { id: Id; @@ -129,32 +211,43 @@ export type ServiceLevelAgreement = updatedBy: InternalActor; type: "NEXT_RESPONSE_TIME"; nextResponseTimeMinutes: number; + [k: string]: unknown; }; export type ServiceLevelAgreementStatusDetail = + | { + status: "UNKNOWN"; + [k: string]: unknown; + } | { breachTime: Datetime; status: "PENDING"; + [k: string]: unknown; } | { achievedAt: Datetime; status: "ACHIEVED"; + [k: string]: unknown; } | { breachTime: Datetime; status: "IMMINENT_BREACH"; + [k: string]: unknown; } | { breachedAt: Datetime; status: "BREACHING"; + [k: string]: unknown; } | { breachedAt: Datetime; completedAt: Datetime; status: "BREACHED"; + [k: string]: unknown; } | { cancelledAt: Datetime; status: "CANCELLED"; + [k: string]: unknown; }; /** @@ -186,7 +279,8 @@ export interface WebhooksSchemaDefinition { | ThreadServiceLevelAgreementStatusTransitionedPayload | CustomerCreatedPublicEventPayload | CustomerUpdatedPublicEventPayload - | CustomerDeletedPublicEventPayload; + | CustomerDeletedPublicEventPayload + | ThreadNoteCreatedEventPayload; id: Id; type: | "thread.thread_created" @@ -214,12 +308,14 @@ export interface WebhooksSchemaDefinition { | "customer.customer_group_memberships_changed" | "timeline.timeline_entry_changed"; webhookMetadata: WebhookMetadata; + [k: string]: unknown; } export interface CustomerChangedPayload { changeType: "ADDED" | "UPDATED"; eventType: "customer.customer_changed"; customer: Customer; previousCustomer: Customer | null; + [k: string]: unknown; } export interface Customer { id: Id; @@ -227,12 +323,11 @@ export interface Customer { email: EmailAddress; isVerified: boolean; verifiedAt: Datetime | null; + [k: string]: unknown; }; externalId: string | null; fullName: string; shortName: string | null; - assignedAt: Datetime | null; - assignedToUser: User | null; markedAsSpamAt?: Datetime | null; markedAsSpamBy?: InternalActor | null; customerGroupMemberships: CustomerGroupMembership[]; @@ -240,32 +335,22 @@ export interface Customer { createdBy: Actor; updatedAt: Datetime; updatedBy: Actor; -} -export interface User { - id: Id; - email: EmailAddress; - fullName: string; - publicName: string; - status: "ONLINE" | "OFFLINE" | "BREAK"; - statusChangedAt: Datetime; - createdAt: Datetime; - createdBy: InternalActor; - updatedAt: Datetime; - updatedBy: InternalActor; - deletedAt: Datetime | null; - deletedBy: InternalActor | null; + [k: string]: unknown; } export interface UserActor { actorType: "user"; userId: Id; + [k: string]: unknown; } export interface MachineUserActor { actorType: "machineUser"; machineUserId: Id; + [k: string]: unknown; } export interface SystemActor { actorType: "system"; system: string; + [k: string]: unknown; } export interface CustomerGroupMembership { customerId: Id; @@ -276,6 +361,7 @@ export interface CustomerGroupMembership { updatedAt: Datetime; updatedBy: InternalActor; customerGroup: CustomerGroup; + [k: string]: unknown; } export interface CustomerGroup { id: Id; @@ -287,22 +373,26 @@ export interface CustomerGroup { createdBy: InternalActor; updatedAt: Datetime; updatedBy: InternalActor; + [k: string]: unknown; } export interface CustomerActor { actorType: "customer"; customerId: Id; + [k: string]: unknown; } export interface CustomerGroupMembershipsChangedPayload { eventType: "customer.customer_group_memberships_changed"; changeType: "ADDED" | "REMOVED"; customer: Customer; previousCustomer: Customer; + [k: string]: unknown; } export interface TimelineEntryChangedPayload { eventType: "timeline.timeline_entry_changed"; previousTimelineEntry: TimelineEntry | null; timelineEntry: TimelineEntry | null; changeType: "ADDED" | "UPDATED" | "REMOVED"; + [k: string]: unknown; } export interface TimelineEntry { id: Id; @@ -310,13 +400,23 @@ export interface TimelineEntry { threadId?: Id | null; timestamp: Datetime; actor: Actor; - entry: NoteEntry | ChatEntry | EmailEntry | CustomEntry; + entry: + | { + entryType: "UNKNOWN"; + [k: string]: unknown; + } + | NoteEntry + | ChatEntry + | EmailEntry + | CustomEntry; + [k: string]: unknown; } export interface NoteEntry { entryType: "note"; noteId: Id; text: string; markdown: string | null; + [k: string]: unknown; } export interface ChatEntry { entryType: "chat"; @@ -324,6 +424,7 @@ export interface ChatEntry { text: string | null; attachments: ChatEntryAttachment[]; customerReadAt: Datetime | null; + [k: string]: unknown; } export interface ChatEntryAttachment { id: Id; @@ -336,6 +437,7 @@ export interface ChatEntryAttachment { updatedAt: Datetime; updatedBy: Actor; type: "CHAT"; + [k: string]: unknown; } export interface EmailEntry { entryType: "email"; @@ -355,11 +457,13 @@ export interface EmailEntry { attachments: EmailEntryAttachment[]; inReplyToEmailId: string | null; isStartOfThread: boolean; + [k: string]: unknown; } export interface EmailParticipant { email: string; name: string | null; emailActor: EmailActor | null; + [k: string]: unknown; } export interface EmailEntryAttachment { id: Id; @@ -373,6 +477,7 @@ export interface EmailEntryAttachment { updatedBy: Actor; type: "EMAIL"; emailContentId: string; + [k: string]: unknown; } export interface CustomEntry { entryType: "custom"; @@ -381,50 +486,60 @@ export interface CustomEntry { type: string | null; components: Component[]; attachments: CustomEntryAttachment[]; + [k: string]: unknown; } export interface ComponentText { type: "text"; - textSize: ComponentPlainTextSize | null; - textColor: ComponentPlainTextColor | null; + textSize: ComponentTextSize | null; + textColor: ComponentTextColor | null; text: string; + [k: string]: unknown; } export interface ComponentPlainText { plainTextSize: ComponentPlainTextSize | null; plainTextColor: ComponentPlainTextColor | null; plainText: string; type: "plainText"; + [k: string]: unknown; } export interface ComponentSpacer { - spacerSize: ComponentDividerSpacingSize; + spacerSize: ComponentSpacerSize; type: "spacer"; + [k: string]: unknown; } export interface ComponentDivider { dividerSpacingSize: ComponentDividerSpacingSize | null; type: "divider"; + [k: string]: unknown; } export interface ComponentLinkButton { linkButtonUrl: string; linkButtonLabel: string; type: "linkButton"; + [k: string]: unknown; } export interface ComponentBadge { badgeLabel: string; badgeColor: ComponentBadgeColor | null; type: "badge"; + [k: string]: unknown; } export interface ComponentCopyButton { copyButtonValue: string; copyButtonTooltipLabel: string | null; type: "copyButton"; + [k: string]: unknown; } export interface ComponentRow { type: "row"; rowMainContent: ComponentRowContent[]; rowAsideContent: ComponentRowContent[]; + [k: string]: unknown; } export interface ComponentContainer { type: "container"; containerContent: ComponentContainerContent[]; + [k: string]: unknown; } export interface CustomEntryAttachment { id: Id; @@ -437,10 +552,12 @@ export interface CustomEntryAttachment { updatedAt: Datetime; updatedBy: Actor; type: "CUSTOM_TIMELINE_ENTRY"; + [k: string]: unknown; } export interface ThreadCreatedPublicEventPayload { eventType: "thread.thread_created"; thread: Thread; + [k: string]: unknown; } export interface Thread { id: Id; @@ -465,6 +582,22 @@ export interface Thread { createdBy: Actor; updatedAt: Datetime; updatedBy: Actor; + [k: string]: unknown; +} +export interface User { + id: Id; + email: EmailAddress; + fullName: string; + publicName: string; + status: "ONLINE" | "OFFLINE" | "BREAK" | "UNKNOWN_USER_STATUS"; + statusChangedAt: Datetime; + createdAt: Datetime; + createdBy: InternalActor; + updatedAt: Datetime; + updatedBy: InternalActor; + deletedAt: Datetime | null; + deletedBy: InternalActor | null; + [k: string]: unknown; } export interface MachineUser { id: Id; @@ -477,6 +610,7 @@ export interface MachineUser { updatedBy: InternalActor; deletedAt: Datetime | null; deletedBy: InternalActor | null; + [k: string]: unknown; } export interface Label { id: Id; @@ -485,6 +619,7 @@ export interface Label { createdBy: Actor; updatedAt: Datetime; updatedBy: Actor; + [k: string]: unknown; } export interface LabelType { id: Id; @@ -497,27 +632,32 @@ export interface LabelType { createdBy: InternalActor; updatedAt: Datetime; updatedBy: InternalActor; + [k: string]: unknown; } export interface ThreadMessageInfo { timestamp: Datetime; - messageSource: "CHAT" | "EMAIL" | "API" | "SLACK"; + messageSource: "CHAT" | "EMAIL" | "API" | "SLACK" | "MS_TEAMS" | "UNKNOWN_THREAD_MESSAGE_INFO_MESSAGE_SOURCE"; actorId?: string | null; actorType?: ("user" | "machineUser" | "customer" | "system") | null; + [k: string]: unknown; } export interface ThreadStatusTransitionedPublicEventPayload { eventType: "thread.thread_status_transitioned"; previousThread: Thread; thread: Thread; + [k: string]: unknown; } export interface ThreadAssignmentTransitionedPublicEventPayload { eventType: "thread.thread_assignment_transitioned"; previousThread: Thread; thread: Thread; + [k: string]: unknown; } export interface ThreadEmailReceivedPublicEventPayload { eventType: "thread.email_received"; thread: Thread; email: Email; + [k: string]: unknown; } export interface Email { timelineEntryId: Id; @@ -540,6 +680,7 @@ export interface Email { createdBy: Actor; updatedAt: Datetime; updatedBy: Actor; + [k: string]: unknown; } export interface Attachment { id: Id; @@ -551,16 +692,19 @@ export interface Attachment { createdBy: Actor; updatedAt: Datetime; updatedBy: Actor; + [k: string]: unknown; } export interface ThreadEmailSentPublicEventPayload { eventType: "thread.email_sent"; thread: Thread; email: Email; + [k: string]: unknown; } export interface ThreadSlackMessageReceivedEventPayload { eventType: "thread.slack_message_received"; thread: Thread; slackMessage: SlackMessage; + [k: string]: unknown; } export interface SlackMessage { timelineEntryId: Id; @@ -575,11 +719,13 @@ export interface SlackMessage { createdBy: Actor; updatedAt: Datetime; updatedBy: Actor; + [k: string]: unknown; } export interface ThreadSlackMessageSentEventPayload { eventType: "thread.ms_teams_message_sent"; thread: Thread; msTeamsMessage: MsTeamsMessage; + [k: string]: unknown; } export interface MsTeamsMessage { timelineEntryId: Id; @@ -594,60 +740,70 @@ export interface MsTeamsMessage { createdBy: Actor; updatedAt: Datetime; updatedBy: Actor; + [k: string]: unknown; } export interface ThreadMSTeamsMessageReceivedEventPayload { eventType: "thread.ms_teams_message_received"; thread: Thread; msTeamsMessage: MsTeamsMessage; + [k: string]: unknown; } export interface ThreadMSTeamsMessageSentEventPayload { eventType: "thread.slack_message_sent"; thread: Thread; slackMessage: SlackMessage; + [k: string]: unknown; } export interface ThreadLabelsChangedPublicEventPayload { eventType: "thread.thread_labels_changed"; changeType: "ADDED" | "REMOVED"; thread: Thread; previousThread: Thread; + [k: string]: unknown; } export interface ThreadPriorityChangedPublicEventPayload { eventType: "thread.thread_priority_changed"; previousThread: Thread; thread: Thread; + [k: string]: unknown; } export interface ThreadFieldCreatedPublicEventPayload { eventType: "thread.thread_field_created"; thread: Thread; threadField: ThreadField; + [k: string]: unknown; } export interface ThreadField { id: Id; threadId: Id; key: string; - type: "STRING" | "BOOL" | "ENUM"; + type: "STRING" | "BOOL" | "ENUM" | "UNKNOWN_THREAD_FIELD_SCHEMA_TYPE"; stringValue: string | null; booleanValue: boolean | null; createdAt: Datetime; createdBy: InternalActor; updatedAt: Datetime; updatedBy: InternalActor; + [k: string]: unknown; } export interface ThreadFieldUpdatedPublicEventPayload { eventType: "thread.thread_field_updated"; thread: Thread; previousThreadField: ThreadField; threadField: ThreadField; + [k: string]: unknown; } export interface ThreadFieldDeletedPublicEventPayload { eventType: "thread.thread_field_deleted"; thread: Thread; previousThreadField: ThreadField; + [k: string]: unknown; } export interface ThreadChatSentPublicEventPayload { eventType: "thread.chat_sent"; chat: Chat; thread: Thread; + [k: string]: unknown; } export interface Chat { timelineEntryId: Id; @@ -659,6 +815,7 @@ export interface Chat { createdBy: Actor; updatedAt: Datetime; updatedBy: Actor; + [k: string]: unknown; } export interface ThreadServiceLevelAgreementStatusTransitionedPayload { eventType: "thread.service_level_agreement_status_transitioned"; @@ -666,6 +823,7 @@ export interface ThreadServiceLevelAgreementStatusTransitionedPayload { serviceLevelAgreement: ServiceLevelAgreement; previousServiceLevelAgreementStatusDetail: ServiceLevelAgreementStatusDetail; serviceLevelAgreementStatusDetail: ServiceLevelAgreementStatusDetail; + [k: string]: unknown; } export interface Tier { id: Id; @@ -678,23 +836,47 @@ export interface Tier { createdBy: InternalActor; updatedAt: Datetime; updatedBy: InternalActor; + [k: string]: unknown; } export interface CustomerCreatedPublicEventPayload { eventType: "customer.customer_created"; customer: Customer; + [k: string]: unknown; } export interface CustomerUpdatedPublicEventPayload { eventType: "customer.customer_updated"; customer: Customer; previousCustomer: Customer; + [k: string]: unknown; } export interface CustomerDeletedPublicEventPayload { eventType: "customer.customer_deleted"; previousCustomer: Customer; + [k: string]: unknown; +} +export interface ThreadNoteCreatedEventPayload { + eventType: "thread.note_created"; + thread: Thread; + note: { + timelineEntryId: Id; + id: Id; + text: string; + markdown: string | null; + createdAt: Datetime; + createdBy: InternalActor; + updatedAt: Datetime; + updatedBy: InternalActor; + deletedAt: Datetime | null; + deletedBy: InternalActor | null; + [k: string]: unknown; + }; + [k: string]: unknown; } export interface WebhookMetadata { webhookTargetId: Id; + webhookTargetVersion: "2024-09-18"; webhookDeliveryAttemptId: Id; webhookDeliveryAttemptNumber: number; webhookDeliveryAttemptTimestamp: Datetime; + [k: string]: unknown; } diff --git a/tsconfig.json b/tsconfig.json index 1f54bd3..caf3d18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "esModuleInterop": true, "moduleResolution": "node", "verbatimModuleSyntax": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] } }