From f33d60613493f261d65afb8fad27096a2b09e2d7 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Fri, 29 Sep 2023 11:05:12 -0400 Subject: [PATCH 01/13] devex: helper types for webhooks --- src/util/types.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/util/types.ts b/src/util/types.ts index 6269e5fe..536eb4ee 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -1,4 +1,5 @@ import {formatList} from './lists.ts'; +import type {POSSIBLE_EVENTS} from './webhooks.ts'; /** * All methods the Hop API accepts @@ -174,6 +175,17 @@ export type InternalHopDomain = `${string}.hop`; */ export type AnyId = Id; +/** + * A union of all possible webhook groups + */ +export type PossibleWebhookGroups = keyof typeof POSSIBLE_EVENTS; + +/** + * A union of all possible webhook event IDs + */ +export type PossibleWebhookIDs = + (typeof POSSIBLE_EVENTS)[PossibleWebhookGroups][number]['id']; + /** * Checks if a string is a valid Hop ID prefix * From 6e389a12dec24ca2a8ada41ea5bc8de22ceb81e7 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Fri, 29 Sep 2023 11:05:29 -0400 Subject: [PATCH 02/13] devex: add webhook type + webhook routes --- src/rest/types/projects.ts | 100 ++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src/rest/types/projects.ts b/src/rest/types/projects.ts index 058ea9ad..eab5953f 100644 --- a/src/rest/types/projects.ts +++ b/src/rest/types/projects.ts @@ -1,4 +1,9 @@ -import type {Empty, Id, Timestamp} from '../../util/types.ts'; +import type { + Empty, + Id, + PossibleWebhookIDs, + Timestamp, +} from '../../util/types.ts'; import type {Endpoint} from '../endpoints.ts'; import type {User} from './users.ts'; @@ -204,6 +209,43 @@ export interface Secret { in_use_by: Id<'deployment'>[]; } +/** + * Webhooks are used to send an event to an endpoint when something within Hop happens. + * @public + */ +export interface Webhook { + /** + * The time this webhook was created at + */ + created_at: Timestamp; + /** + * The events that this webhook is subscribed to + */ + events: PossibleWebhookIDs; + /** + * The ID of the webhook + */ + id: Id<'webhook'>; + /** + * The ID of the project this webhook is for + */ + project_id: Id<'project'>; + /** + * The secret for this webhook + * @warning This is censored after creation + * @example whsec_xxxxxxxx + */ + secret: string; + /** + * The type of the webhook + */ + type: 'http'; + /** + * The URL that this webhook will send events to, acts as an endpoint + */ + webhook_url: string; +} + /** * The endpoints for projects * @public @@ -259,4 +301,58 @@ export type ProjectsEndpoints = | Endpoint<'GET', '/v1/projects/:project_id/secrets', {secrets: Secret[]}> | Endpoint<'GET', '/v1/projects/@this/secrets', {secrets: Secret[]}> | Endpoint<'DELETE', '/v1/projects/:project_id/secrets/:secret_id', Empty> - | Endpoint<'DELETE', '/v1/projects/@this/secrets/:secret_id', Empty>; + | Endpoint<'DELETE', '/v1/projects/@this/secrets/:secret_id', Empty> + | Endpoint<'GET', '/v1/projects/:project_id/webhooks', {webhooks: Webhook[]}> + | Endpoint<'GET', '/v1/projects/@this/webhooks', {webhooks: Webhook[]}> + | Endpoint< + 'POST', + '/v1/projects/:project_id/webhooks', + {webhook: Webhook}, + { + webhook_url: string; + events: PossibleWebhookIDs[]; + } + > + | Endpoint< + 'POST', + '/v1/projects/@this/webhooks', + { + webhook: Webhook; + }, + { + webhook_url: string; + events: PossibleWebhookIDs[]; + } + > + | Endpoint< + 'PATCH', + '/v1/projects/:project_id/webhooks/:webhook_id', + {webhook: Webhook}, + { + webhook_url: string | undefined; + events: PossibleWebhookIDs[] | undefined; + } + > + | Endpoint< + 'PATCH', + '/v1/projects/@this/webhooks/:webhook_id', + { + webhook: Webhook; + }, + { + webhook_url: string | undefined; + events: PossibleWebhookIDs[] | undefined; + } + > + | Endpoint<'DELETE', '/v1/projects/:project_id/webhooks/:webhook_id', Empty> + | Endpoint<'DELETE', '/v1/projects/@this/webhooks/:webhook_id', Empty> + | Endpoint< + 'POST', + '/v1/projects/:project_id/webhooks/:webhook_id/regenerate', + {secret: Webhook['secret']} + > + | Endpoint< + 'POST', + '/v1/projects/@this/webhooks/:webhook_id/regenerate', + {secret: Webhook['secret']} + >; From 2ef6306ae9f64e439b688b31b2cf2764b5c95f6e Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Fri, 29 Sep 2023 11:05:42 -0400 Subject: [PATCH 03/13] feat: add webhooks to project SDK --- src/sdks/projects.ts | 176 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/src/sdks/projects.ts b/src/sdks/projects.ts index ff6c7ba6..1420685b 100644 --- a/src/sdks/projects.ts +++ b/src/sdks/projects.ts @@ -1,6 +1,7 @@ import type {API, Endpoints, Id} from '../rest/index.ts'; import {Request} from '../util/fetch.ts'; import {sdk} from './create.ts'; +import type {PossibleWebhookIDs} from '../util/types.ts'; /** * Projects SDK client @@ -95,6 +96,179 @@ export const projects = sdk(client => { }, }; + const webhooks = { + async get(projectId?: Id<'project'>) { + if (client.authType !== 'ptk' && !projectId) { + throw new Error( + 'Project ID is required for bearer or PAT authentication to fetch all project members', + ); + } + + if (projectId) { + const {webhooks} = await client.get( + '/v1/projects/:project_id/webhooks', + { + project_id: projectId, + }, + ); + + return webhooks; + } + + const {webhooks} = await client.get('/v1/projects/@this/webhooks', {}); + + return webhooks; + }, + + async create( + webhook_url: string, + events: PossibleWebhookIDs[], + projectId?: Id<'project'>, + ) { + if (client.authType !== 'ptk' && !projectId) { + throw new Error( + 'Project ID is required for bearer or PAT authentication to create a webhook', + ); + } + + if (projectId) { + const {webhook} = await client.post( + '/v1/projects/:project_id/webhooks', + { + webhook_url, + events, + }, + { + project_id: projectId, + }, + ); + + return webhook; + } + + const {webhook} = await client.post( + '/v1/projects/@this/webhooks', + { + webhook_url, + events, + }, + {}, + ); + + return webhook; + }, + + async edit( + webhookId: Id<'webhook'>, + { + events, + webhookUrl, + }: { + webhookUrl: string | undefined; + events: PossibleWebhookIDs[] | undefined; + }, + projectId?: Id<'project'>, + ) { + if (client.authType !== 'ptk' && !projectId) { + throw new Error( + 'Project ID is required for bearer or PAT authentication to edit a webhook', + ); + } + + if (projectId) { + const {webhook} = await client.patch( + '/v1/projects/:project_id/webhooks/:webhook_id', + { + webhook_url: webhookUrl, + events, + }, + { + project_id: projectId, + webhook_id: webhookId, + }, + ); + + return webhook; + } + + const {webhook} = await client.patch( + '/v1/projects/@this/webhooks/:webhook_id', + { + webhook_url: webhookUrl, + events, + }, + { + webhook_id: webhookId, + }, + ); + + return webhook; + }, + + async delete(webhookId: Id<'webhook'>, projectId?: Id<'project'>) { + if (client.authType !== 'ptk' && !projectId) { + throw new Error( + 'Project ID is required for bearer or PAT authentication to delete a webhook', + ); + } + + if (projectId) { + await client.delete( + '/v1/projects/:project_id/webhooks/:webhook_id', + undefined, + { + project_id: projectId, + webhook_id: webhookId, + }, + ); + + return; + } + + await client.delete( + '/v1/projects/@this/webhooks/:webhook_id', + undefined, + { + webhook_id: webhookId, + }, + ); + }, + + async regenerateSecret( + webhookId: Id<'webhook'>, + projectId?: Id<'project'>, + ) { + if (client.authType !== 'ptk' && !projectId) { + throw new Error( + 'Project ID is required for bearer or PAT authentication to regenerate a webhook secret', + ); + } + + if (projectId) { + const {secret} = await client.post( + '/v1/projects/:project_id/webhooks/:webhook_id/regenerate', + undefined, + { + project_id: projectId, + webhook_id: webhookId, + }, + ); + + return secret; + } + + const {secret} = await client.post( + '/v1/projects/@this/webhooks/:webhook_id/regenerate', + undefined, + { + webhook_id: webhookId, + }, + ); + + return secret; + }, + }; + const projectsSDK = { async getAllMembers(projectId?: Id<'project'>) { if (client.authType !== 'ptk' && !projectId) { @@ -145,6 +319,8 @@ export const projects = sdk(client => { tokens, + webhooks, + secrets: { /** * Gets all secrets in a project From 0c9d8aaf84b240c9d1c0c7c6359cd524176f010b Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Sun, 1 Oct 2023 15:19:03 -0400 Subject: [PATCH 04/13] feat: add event id to prefixes --- src/util/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/util/types.ts b/src/util/types.ts index 536eb4ee..94255daa 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -142,6 +142,10 @@ export const ID_PREFIXES = [ prefix: 'webhook', description: 'Webhook ID for webhooks on a project.', }, + { + prefix: 'event', + description: 'Event ID for events sent by webhooks on a project.', + }, ] as const; /** From 58926737338d93a6463035103833145f06c46eb5 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Sun, 1 Oct 2023 15:19:13 -0400 Subject: [PATCH 05/13] feat: add event type --- src/rest/types/projects.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/rest/types/projects.ts b/src/rest/types/projects.ts index eab5953f..02d3c772 100644 --- a/src/rest/types/projects.ts +++ b/src/rest/types/projects.ts @@ -246,6 +246,37 @@ export interface Webhook { webhook_url: string; } +/** + * An event is sent from a webhook to an endpoint + */ +export interface Event { + /** + * The ID of the webhook that sent this event + */ + webhook_id: Id<'webhook'>; + /** + * The ID of the project that this event is for + */ + project_id: Id<'project'>; + /** + * The time this event occurred at + */ + occurred_at: string; + /** + * The ID of the event + */ + id: Id<'event'>; + /** + * The event that occurred + * @example ignite.deployment.container.updated + */ + event: T; + /** + * The data for this event + */ + data: unknown; +} + /** * The endpoints for projects * @public From 024aad41c851619e054517055ed9b46621bff831 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Sun, 1 Oct 2023 15:20:13 -0400 Subject: [PATCH 06/13] feat: constructEvent MVP (generic event returned for now) --- src/sdks/projects.ts | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/sdks/projects.ts b/src/sdks/projects.ts index 1420685b..76f77a41 100644 --- a/src/sdks/projects.ts +++ b/src/sdks/projects.ts @@ -1,4 +1,4 @@ -import type {API, Endpoints, Id} from '../rest/index.ts'; +import type {API, Endpoints, Event, Id} from '../rest/index.ts'; import {Request} from '../util/fetch.ts'; import {sdk} from './create.ts'; import type {PossibleWebhookIDs} from '../util/types.ts'; @@ -97,6 +97,43 @@ export const projects = sdk(client => { }; const webhooks = { + /** + * Utility function that returns a type-safe webhook event, throws if signature is invalid. + * + * @param body The stringed body received from the request + * @param signature The signature from the X-Hop-Hooks-Signature + * @param secret The secret provided upon webhook creation to verify the signature. (e.x: whsec_xxxxx) + */ + async constructEvent(body: string, signature: string, secret: string) { + const encoder = new TextEncoder(); + const encodedBody = encoder.encode(body); + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + {name: 'HMAC', hash: 'SHA-256'}, + false, + ['sign'], + ); + + const signatureBuffer = await crypto.subtle.sign( + 'HMAC', + key, + encodedBody, + ); + + const finalSig = Array.from(new Uint8Array(signatureBuffer)) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + + if (signature.toLowerCase() !== finalSig) { + throw new Error('Invalid signature'); + } + + const event = JSON.parse(body) as Event; + + return event; + }, async get(projectId?: Id<'project'>) { if (client.authType !== 'ptk' && !projectId) { throw new Error( From fa3177314d1b476ca00db37d1ad1239b4c993779 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Sun, 1 Oct 2023 15:32:02 -0400 Subject: [PATCH 07/13] move channel.client.connected above disconnected --- src/util/webhooks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/util/webhooks.ts b/src/util/webhooks.ts index c13f137e..fe90b0e5 100644 --- a/src/util/webhooks.ts +++ b/src/util/webhooks.ts @@ -4,10 +4,6 @@ export const POSSIBLE_EVENTS = { id: 'channel.created', name: 'Created', }, - { - id: 'channel.client.connected', - name: 'Client Connected', - }, { id: 'channel.updated', name: 'Updated', @@ -16,6 +12,10 @@ export const POSSIBLE_EVENTS = { id: 'channel.deleted', name: 'Deleted', }, + { + id: 'channel.client.connected', + name: 'Client Connected', + }, { id: 'channel.client.disconnected', name: 'Client Disconnected', From 2623e8cadeb86e1055329ec069df7da420f400aa Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Mon, 2 Oct 2023 00:41:48 -0400 Subject: [PATCH 08/13] fix: event IDs that are incorrect --- src/util/webhooks.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/util/webhooks.ts b/src/util/webhooks.ts index fe90b0e5..a009aef8 100644 --- a/src/util/webhooks.ts +++ b/src/util/webhooks.ts @@ -133,23 +133,23 @@ export const POSSIBLE_EVENTS = { name: 'Member Deleted', }, { - id: 'project.tokens.create', + id: 'project.tokens.created', name: 'Token Created', }, { - id: 'project.tokens.delete', + id: 'project.tokens.deleted', name: 'Token Deleted', }, { - id: 'project.secrets.create', + id: 'project.secrets.created', name: 'Secret Created', }, { - id: 'project.secrets.update', + id: 'project.secrets.updated', name: 'Secret Updated', }, { - id: 'project.secrets.delete', + id: 'project.secrets.deleted', name: 'Secret Deleted', }, { From bbd27da1eddd51673710c06d56fe58e56072b517 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Mon, 2 Oct 2023 00:56:15 -0400 Subject: [PATCH 09/13] fix/refactor: get -> getAll + make parameters optional in signature --- src/sdks/projects.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sdks/projects.ts b/src/sdks/projects.ts index 76f77a41..ea0b1748 100644 --- a/src/sdks/projects.ts +++ b/src/sdks/projects.ts @@ -134,7 +134,7 @@ export const projects = sdk(client => { return event; }, - async get(projectId?: Id<'project'>) { + async getAll(projectId?: Id<'project'>) { if (client.authType !== 'ptk' && !projectId) { throw new Error( 'Project ID is required for bearer or PAT authentication to fetch all project members', @@ -201,8 +201,8 @@ export const projects = sdk(client => { events, webhookUrl, }: { - webhookUrl: string | undefined; - events: PossibleWebhookIDs[] | undefined; + webhookUrl?: string | undefined; + events?: PossibleWebhookIDs[] | undefined; }, projectId?: Id<'project'>, ) { From 47f34fa58847078a094e263dd2d39bf629fc1f2f Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Mon, 2 Oct 2023 12:36:15 -0400 Subject: [PATCH 10/13] devex: webhook tests --- tests/index.ts | 85 ++++---------------------- tests/projects/members.ts | 10 +++ tests/projects/webhooks.ts | 122 +++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 73 deletions(-) create mode 100644 tests/projects/members.ts create mode 100644 tests/projects/webhooks.ts diff --git a/tests/index.ts b/tests/index.ts index cd7daf5f..71e98624 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -3,11 +3,13 @@ import 'dotenv/config'; import assert from 'node:assert/strict'; import {test} from 'node:test'; +import {Hop, id, validateId} from '../src/index.ts'; +import {webhookTests} from './projects/webhooks.ts'; +import {membersTest} from './projects/members.ts'; + // @ts-expect-error This is usually injected by tsup globalThis.TSUP_IS_NODE = true; -import {Hop, id, validateId} from '../src/index.ts'; - const BASE_URL = process.env.TEST_HOP_API_BASE_URL ?? 'https://api.hop.io'; const hop = new Hop( @@ -47,16 +49,6 @@ test('The HTTP client can make a request', async () => { await assert.doesNotReject(() => hop.client.get('/v1/channels', {})); }); -test('It fetches the project members', async () => { - const members = await hop.projects.getAllMembers(); - assert.ok(members.length > 0); -}); - -test('It gets all channels', async () => { - const channels = await hop.channels.getAll(); - assert.ok(Array.isArray(channels)); -}); - test('It validates an ID', () => { assert.ok(validateId('ptk_1234567890')); assert.ok(validateId('ptk_1234567890', 'ptk')); @@ -68,65 +60,12 @@ test('It validates that the token is valid', () => { assert(validateId('ptk_testing', 'ptk'), "Couldn't validate Project Token"); }); -// test('it creates a deployment', async t => { -// const redis = await hop.ignite.deployments.create({ -// version: '2022-05-17', -// name: 'redis', -// image: { -// name: 'redis', -// auth: null, -// gh_repo: null, -// }, -// container_strategy: 'manual', -// type: RuntimeType.PERSISTENT, -// env: {}, -// resources: { -// vcpu: 0.5, -// ram: '128MB', -// vgpu: [], -// }, -// }); - -// assert.ok( -// validateId(redis.id, 'deployment'), -// "Couldn't validate deployment ID", -// ); - -// assert.equal(redis.name, 'redis'); -// assert.equal(typeof redis.created_at, 'string'); -// assert.doesNotThrow(() => new Date(redis.created_at)); - -// t.todo('See if we can check the functions that exist on a deployment'); - -// assert.deepStrictEqual(redis, { -// config: { -// container_strategy: 'manual', -// env: {}, -// image: { -// auth: null, -// name: 'redis:latest', -// }, -// resources: { -// ram: '128mb', -// vcpu: 0.5, -// }, -// restart_policy: 'on-failure', -// type: 'persistent', -// version: '2022-05-17', -// }, -// name: 'redis', -// container_count: 0, +// Project Tests +webhookTests(hop); +membersTest(hop); -// // These values are dynamic and will change -// // so we can't really test them with .deepStrictEqual -// created_at: redis.created_at, -// id: redis.id, -// createContainer: redis.createContainer, -// createGateway: redis.createGateway, -// delete: redis.delete, -// getContainers: redis.getContainers, -// }); - -// // Cleanup -// await redis.delete(); -// }); +// Todo: Move this to a separate folder + add channel tokens and other tests. +test('It gets all channels', async () => { + const channels = await hop.channels.getAll(); + assert.ok(Array.isArray(channels)); +}); diff --git a/tests/projects/members.ts b/tests/projects/members.ts new file mode 100644 index 00000000..abdccd66 --- /dev/null +++ b/tests/projects/members.ts @@ -0,0 +1,10 @@ +import test from 'node:test'; +import type {Hop} from '../../src'; +import assert from 'node:assert'; + +export function membersTest(hop: Hop) { + test('It fetches the project members', async () => { + const members = await hop.projects.getAllMembers(); + assert.ok(members.length > 0); + }); +} diff --git a/tests/projects/webhooks.ts b/tests/projects/webhooks.ts new file mode 100644 index 00000000..40e7bd68 --- /dev/null +++ b/tests/projects/webhooks.ts @@ -0,0 +1,122 @@ +import {Hop, validateId, type Webhook} from '../../src'; +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +export function webhookTests(hop: Hop) { + let createdWebhook: Webhook; + + test('It creates a webhook', async () => { + const webhook = await hop.projects.webhooks.create( + 'https://example.com/webhook', + ['ignite.deployment.build.cancelled', 'channel.client.disconnected'], + ); + + assert.ok( + validateId(webhook.id, 'webhook'), + "Couldn't validate webhook ID", + ); + assert.ok( + validateId(webhook.project_id, 'project'), + "Couldn't validate project ID", + ); + assert.equal(webhook.type, 'http'); + assert.equal(webhook.webhook_url, 'https://example.com/webhook'); + assert.equal(webhook.events.length, 2); + assert.equal(webhook.secret.includes('*'), false); + + createdWebhook = webhook; + }); + + test('It gets all webhooks', async () => { + const webhooks = await hop.projects.webhooks.getAll(); + + assert.ok(Array.isArray(webhooks)); + assert.ok(webhooks.length > 0); + assert.ok(webhooks.some(webhook => webhook.id === createdWebhook.id)); + assert.ok(webhooks.every(webhook => webhook.secret.includes('*'))); + }); + + test('It can edit a webhook', async () => { + const webhook = await hop.projects.webhooks.edit(createdWebhook.id, { + events: ['ignite.deployment.build.started'], + }); + + assert.ok(webhook.events.includes('ignite.deployment.build.started')); + assert.ok(!webhook.events.includes('ignite.deployment.build.cancelled')); + }); + + test('It can regenerate a webhook secret', async () => { + const secret = await hop.projects.webhooks.regenerateSecret( + createdWebhook.id, + ); + + assert.ok(!secret.includes('*')); + assert.ok(secret.length > 10); + assert.ok(secret !== createdWebhook.secret); + }); + + test('It can delete a webhook', async () => { + await hop.projects.webhooks.delete(createdWebhook.id); + + const webhooks = await hop.projects.webhooks.getAll(); + + assert.ok(!webhooks.some(webhook => webhook.id === createdWebhook.id)); + }); + + test('Can verify and construct event', async () => { + // These are test values, basically made by the server but aren't actually a real instance (So secrets and signatures don't actually belong to anyone) + const randomTestSignature = + '86EBE7F54A92C809CDF8F3DC6536C566A1C074F9B001E9118DF7EC433BE435E7'; + const randomTestBody = + '{"webhook_id":"webhook_MTkzMDU5MTE4NzcyNDA0MjI3","project_id":"project_MTY1MjU5NTk1NTAwNTY4NTc3","occurred_at":"2023-10-02T15:46:13.323Z","id":"event_MTkzMzkwODU2OTQwODkyMjgx","event":"ignite.deployment.container.metrics_update","data":{"metrics":{"memory_usage_percent":"0.07","memory_usage_bytes":393216,"cpu_usage_percent":"0.00"},"container_id":"container_MTkzMDc3MjExODczMDc5MzAx"}}'; + const randomTestSecret = + 'whsec_c18zMDkxNWRmYzUwOTM4YmFiOTkwZjc3NTYwYjhhOTNkNF8xOTMwNTkxMTg3NzI0MDQyMjg'; + + const event = await hop.projects.webhooks.constructEvent( + randomTestBody, + randomTestSignature, + randomTestSecret, + ); + + assert.ok(validateId(event.id, 'event')); + assert.ok(validateId(event.webhook_id, 'webhook')); + assert.ok(validateId(event.project_id, 'project')); + assert.equal(event.event, 'ignite.deployment.container.metrics_update'); + + try { + await hop.projects.webhooks.constructEvent( + randomTestBody, + randomTestSignature, + 'wrong_secret', + ); + + assert.fail('Event succeeded with wrong secret'); + } catch { + // pass :D + } + + try { + await hop.projects.webhooks.constructEvent( + randomTestBody, + 'wrong_signature', + randomTestSecret, + ); + + assert.fail('Event succeeded with wrong signature'); + } catch { + // pass :D + } + + try { + await hop.projects.webhooks.constructEvent( + 'wrong_body', + randomTestSignature, + randomTestSecret, + ); + + assert.fail('Event succeeded with wrong body'); + } catch { + // pass :D + } + }); +} From 8300770827b9626de6b4097259b7b131b704d682 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Mon, 2 Oct 2023 12:45:06 -0400 Subject: [PATCH 11/13] refactor: move hmac verification to a helper util --- src/sdks/projects.ts | 26 +++----------------------- src/util/webhooks.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/sdks/projects.ts b/src/sdks/projects.ts index ea0b1748..86c46999 100644 --- a/src/sdks/projects.ts +++ b/src/sdks/projects.ts @@ -2,6 +2,7 @@ import type {API, Endpoints, Event, Id} from '../rest/index.ts'; import {Request} from '../util/fetch.ts'; import {sdk} from './create.ts'; import type {PossibleWebhookIDs} from '../util/types.ts'; +import {verifyHmac} from '../index.ts'; /** * Projects SDK client @@ -105,33 +106,12 @@ export const projects = sdk(client => { * @param secret The secret provided upon webhook creation to verify the signature. (e.x: whsec_xxxxx) */ async constructEvent(body: string, signature: string, secret: string) { - const encoder = new TextEncoder(); - const encodedBody = encoder.encode(body); - - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - {name: 'HMAC', hash: 'SHA-256'}, - false, - ['sign'], - ); - - const signatureBuffer = await crypto.subtle.sign( - 'HMAC', - key, - encodedBody, - ); - - const finalSig = Array.from(new Uint8Array(signatureBuffer)) - .map(byte => byte.toString(16).padStart(2, '0')) - .join(''); - - if (signature.toLowerCase() !== finalSig) { + const hmacVerified = await verifyHmac(body, signature, secret); + if (!hmacVerified) { throw new Error('Invalid signature'); } const event = JSON.parse(body) as Event; - return event; }, async getAll(projectId?: Id<'project'>) { diff --git a/src/util/webhooks.ts b/src/util/webhooks.ts index a009aef8..e20c0c13 100644 --- a/src/util/webhooks.ts +++ b/src/util/webhooks.ts @@ -159,4 +159,29 @@ export const POSSIBLE_EVENTS = { ], } as const; +export async function verifyHmac( + body: string, + signature: string, + secret: string, +) { + const encoder = new TextEncoder(); + const encodedBody = encoder.encode(body); + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + {name: 'HMAC', hash: 'SHA-256'}, + false, + ['sign'], + ); + + const signatureBuffer = await crypto.subtle.sign('HMAC', key, encodedBody); + + const finalSig = Array.from(new Uint8Array(signatureBuffer)) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + + return signature.toLowerCase() === finalSig; +} + // Todo: maybe add type-fest/readonly-deep to keep the as const but also keep a structure type From ec529a68ace6d5c2eee7aa492f5329c0fe1dee34 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Mon, 2 Oct 2023 19:40:43 -0400 Subject: [PATCH 12/13] docs(changeset): Adds webhooks SDK, types, and verification --- .changeset/blue-cars-joke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/blue-cars-joke.md diff --git a/.changeset/blue-cars-joke.md b/.changeset/blue-cars-joke.md new file mode 100644 index 00000000..eda5e3e9 --- /dev/null +++ b/.changeset/blue-cars-joke.md @@ -0,0 +1,5 @@ +--- +'@onehop/js': minor +--- + +Adds webhooks SDK, types, and verification From 85000d4737edb3417ca9518cc3bc87d7b7ad87a2 Mon Sep 17 00:00:00 2001 From: Cody Miller Date: Mon, 2 Oct 2023 20:02:56 -0400 Subject: [PATCH 13/13] fix: polyfill crypto --- package.json | 1 + src/util/crypto.ts | 4 ++++ src/util/webhooks.ts | 2 ++ yarn.lock | 8 ++++++++ 4 files changed, 15 insertions(+) create mode 100644 src/util/crypto.ts diff --git a/package.json b/package.json index 7fbb80ff..7523029a 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "dependencies": { "@onehop/json-methods": "^1.2.0", "cross-fetch": "^3.1.5", + "uncrypto": "^0.1.3", "zod": "^3.21.4" } } diff --git a/src/util/crypto.ts b/src/util/crypto.ts new file mode 100644 index 00000000..51745385 --- /dev/null +++ b/src/util/crypto.ts @@ -0,0 +1,4 @@ +import * as cryptoPonyfill from 'uncrypto'; + +export const HAS_NATIVE_CRYPTO = typeof globalThis.crypto !== 'undefined'; +export const crypto = HAS_NATIVE_CRYPTO ? globalThis.crypto : cryptoPonyfill; diff --git a/src/util/webhooks.ts b/src/util/webhooks.ts index e20c0c13..f98ed53e 100644 --- a/src/util/webhooks.ts +++ b/src/util/webhooks.ts @@ -1,3 +1,5 @@ +import {crypto} from './crypto'; + export const POSSIBLE_EVENTS = { Channels: [ { diff --git a/yarn.lock b/yarn.lock index ea418f79..ac0361e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -559,6 +559,7 @@ __metadata: typedoc-plugin-markdown: 3.14.0 typedoc-plugin-missing-exports: 1.0.0 typescript: ^5.0.2 + uncrypto: ^0.1.3 zod: ^3.21.4 languageName: unknown linkType: soft @@ -3955,6 +3956,13 @@ __metadata: languageName: node linkType: hard +"uncrypto@npm:^0.1.3": + version: 0.1.3 + resolution: "uncrypto@npm:0.1.3" + checksum: 07160e08806dd6cea16bb96c3fd54cd70fc801e02fc3c6f86980144d15c9ebbd1c55587f7280a207b3af6cd34901c0d0b77ada5a02c2f7081a033a05acf409e2 + languageName: node + linkType: hard + "unique-filename@npm:^2.0.0": version: 2.0.1 resolution: "unique-filename@npm:2.0.1"