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 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/rest/types/projects.ts b/src/rest/types/projects.ts index 058ea9ad..02d3c772 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,74 @@ 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; +} + +/** + * 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 @@ -259,4 +332,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']} + >; diff --git a/src/sdks/projects.ts b/src/sdks/projects.ts index ff6c7ba6..86c46999 100644 --- a/src/sdks/projects.ts +++ b/src/sdks/projects.ts @@ -1,6 +1,8 @@ -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'; +import {verifyHmac} from '../index.ts'; /** * Projects SDK client @@ -95,6 +97,195 @@ 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 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'>) { + 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 +336,8 @@ export const projects = sdk(client => { tokens, + webhooks, + secrets: { /** * Gets all secrets in a project 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/types.ts b/src/util/types.ts index 34c3a384..fbaffff9 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 @@ -145,6 +146,10 @@ export const ID_PREFIXES = [ prefix: 'deployment_group', description: 'Group ID for Ignite deployments', }, + { + prefix: 'event', + description: 'Event ID for events sent by webhooks on a project.', + }, ] as const; /** @@ -178,6 +183,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 * diff --git a/src/util/webhooks.ts b/src/util/webhooks.ts index 7b83ea6b..f98ed53e 100644 --- a/src/util/webhooks.ts +++ b/src/util/webhooks.ts @@ -1,13 +1,11 @@ +import {crypto} from './crypto'; + export const POSSIBLE_EVENTS = { Channels: [ { id: 'channel.created', name: 'Created', }, - { - id: 'channel.client.connected', - name: 'Client Connected', - }, { id: 'channel.updated', name: 'Updated', @@ -16,6 +14,10 @@ export const POSSIBLE_EVENTS = { id: 'channel.deleted', name: 'Deleted', }, + { + id: 'channel.client.connected', + name: 'Client Connected', + }, { id: 'channel.client.disconnected', name: 'Client Disconnected', @@ -159,4 +161,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 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 + } + }); +} 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"