From 0d75941cca882b471e34fd3e5b65dd61d9903f79 Mon Sep 17 00:00:00 2001 From: Nwokedi Chinelo Date: Sun, 28 Jun 2026 09:18:41 +0100 Subject: [PATCH 1/4] feat(utils): add maskWebhookUrl helper to strip secrets from webhook URLs (#444) Returns scheme://host only; removes path, query string, and fragment. Returns '[invalid url]' on parse failure. --- src/utils/webhook-mask.utils.test.ts | 31 ++++++++++++++++++++++++++++ src/utils/webhook-mask.utils.ts | 24 +++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/utils/webhook-mask.utils.test.ts create mode 100644 src/utils/webhook-mask.utils.ts diff --git a/src/utils/webhook-mask.utils.test.ts b/src/utils/webhook-mask.utils.test.ts new file mode 100644 index 0000000..ad40494 --- /dev/null +++ b/src/utils/webhook-mask.utils.test.ts @@ -0,0 +1,31 @@ +import { maskWebhookUrl } from './webhook-mask.utils'; + +describe('maskWebhookUrl', () => { + it('strips query string containing a secret', () => { + expect(maskWebhookUrl('https://api.example.com/hook?token=secret')).toBe('https://api.example.com'); + }); + + it('strips path segments', () => { + expect(maskWebhookUrl('https://hooks.slack.com/services/T123/B456/xyzxyz')).toBe('https://hooks.slack.com'); + }); + + it('returns the URL as-is when there is no path or query string', () => { + expect(maskWebhookUrl('http://localhost:3000')).toBe('http://localhost:3000'); + }); + + it('returns [invalid url] for a non-URL string', () => { + expect(maskWebhookUrl('not a url')).toBe('[invalid url]'); + }); + + it('returns [invalid url] for an empty string', () => { + expect(maskWebhookUrl('')).toBe('[invalid url]'); + }); + + it('strips path and preserves non-standard port', () => { + expect(maskWebhookUrl('https://internal.corp:8443/webhooks/receiver?auth=abc')).toBe('https://internal.corp:8443'); + }); + + it('strips fragment identifiers', () => { + expect(maskWebhookUrl('https://example.com/path#section')).toBe('https://example.com'); + }); +}); diff --git a/src/utils/webhook-mask.utils.ts b/src/utils/webhook-mask.utils.ts new file mode 100644 index 0000000..f9fe4c7 --- /dev/null +++ b/src/utils/webhook-mask.utils.ts @@ -0,0 +1,24 @@ +/** + * Strips everything after the host from a webhook URL, returning only + * `scheme://host` (or `scheme://host:port` when a non-standard port is + * present). This prevents secrets embedded in paths or query strings from + * leaking into log output. + * + * @example + * maskWebhookUrl('https://api.example.com/hook?token=secret') + * // → 'https://api.example.com' + * + * maskWebhookUrl('https://hooks.slack.com/services/T123/B456/xyzxyz') + * // → 'https://hooks.slack.com' + * + * maskWebhookUrl('not a url') + * // → '[invalid url]' + */ +export function maskWebhookUrl(url: string): string { + try { + const parsed = new URL(url); + return parsed.origin; + } catch { + return '[invalid url]'; + } +} From 7840c1c713f002a135fd63f35996576e57d31ea4 Mon Sep 17 00:00:00 2001 From: Nwokedi Chinelo Date: Sun, 28 Jun 2026 09:18:41 +0100 Subject: [PATCH 2/4] feat(webhooks): add buildWebhookPayload helper and structured registration/deletion logs (#449 #450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildWebhookPayload(TradeEvent) centralises the TradeEvent→WebhookEventPayload mapping; used in dispatchWebhookEvent to replace the inline object literal - logger.info emitted on successful webhook creation and deletion with creator_id, webhook_id, event_types/deleted_at; callback URL is never logged --- .../webhooks/webhook-payload.utils.test.ts | 74 +++++++++++++++++++ src/modules/webhooks/webhook-payload.utils.ts | 13 ++++ src/modules/webhooks/webhook.service.ts | 23 +++--- 3 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 src/modules/webhooks/webhook-payload.utils.test.ts create mode 100644 src/modules/webhooks/webhook-payload.utils.ts diff --git a/src/modules/webhooks/webhook-payload.utils.test.ts b/src/modules/webhooks/webhook-payload.utils.test.ts new file mode 100644 index 0000000..0e96c48 --- /dev/null +++ b/src/modules/webhooks/webhook-payload.utils.test.ts @@ -0,0 +1,74 @@ +import { buildWebhookPayload } from './webhook-payload.utils'; +import type { TradeEvent, WebhookEventPayload } from './webhook.types'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeTradeEvent(overrides: Partial = {}): TradeEvent { + return { + type: 'buy', + creatorId: 'creator-1', + buyerOrSellerAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '10', + price: '50', + feePaid: '1', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('buildWebhookPayload', () => { + it('maps all fields correctly for a buy event', () => { + const event = makeTradeEvent({ type: 'buy' }); + const payload = buildWebhookPayload(event); + + expect(payload).toEqual({ + event_type: 'buy', + creator_id: 'creator-1', + buyer_or_seller_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '10', + price: '50', + fee_paid: '1', + timestamp: '2026-01-01T00:00:00.000Z', + }); + }); + + it('sets event_type to sell for a sell event and maps fields correctly', () => { + const event = makeTradeEvent({ + type: 'sell', + creatorId: 'creator-2', + buyerOrSellerAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '5', + price: '200', + feePaid: '2', + timestamp: '2026-06-15T12:00:00.000Z', + }); + const payload = buildWebhookPayload(event); + + expect(payload.event_type).toBe('sell'); + expect(payload.creator_id).toBe('creator-2'); + expect(payload.buyer_or_seller_address).toBe('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); + expect(payload.amount).toBe('5'); + expect(payload.price).toBe('200'); + expect(payload.fee_paid).toBe('2'); + expect(payload.timestamp).toBe('2026-06-15T12:00:00.000Z'); + }); + + it('produces no extra keys beyond the WebhookEventPayload contract', () => { + const event = makeTradeEvent(); + const payload = buildWebhookPayload(event); + + const expectedKeys: Array = [ + 'event_type', + 'creator_id', + 'buyer_or_seller_address', + 'amount', + 'price', + 'fee_paid', + 'timestamp', + ]; + + expect(Object.keys(payload).sort()).toEqual(expectedKeys.sort()); + }); +}); diff --git a/src/modules/webhooks/webhook-payload.utils.ts b/src/modules/webhooks/webhook-payload.utils.ts new file mode 100644 index 0000000..920986c --- /dev/null +++ b/src/modules/webhooks/webhook-payload.utils.ts @@ -0,0 +1,13 @@ +import type { TradeEvent, WebhookEventPayload } from './webhook.types'; + +export function buildWebhookPayload(event: TradeEvent): WebhookEventPayload { + return { + event_type: event.type, + creator_id: event.creatorId, + buyer_or_seller_address: event.buyerOrSellerAddress, + amount: event.amount, + price: event.price, + fee_paid: event.feePaid, + timestamp: event.timestamp, + }; +} diff --git a/src/modules/webhooks/webhook.service.ts b/src/modules/webhooks/webhook.service.ts index b679129..e9ecb13 100644 --- a/src/modules/webhooks/webhook.service.ts +++ b/src/modules/webhooks/webhook.service.ts @@ -1,6 +1,8 @@ import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; import { envConfig } from '../../config'; +import { maskWebhookUrl } from '../../utils/webhook-mask.utils'; +import { buildWebhookPayload } from './webhook-payload.utils'; import type { CreateWebhookInput, TradeEvent, WebhookEventPayload, WebhookEventName } from './webhook.types'; function normalizeEvents(events: string[]): ('BUY' | 'SELL')[] { @@ -38,6 +40,11 @@ export async function createWebhook( }, }); + logger.info( + { creator_id: creatorId, webhook_id: webhook.id, event_types: input.events, registered_at: webhook.createdAt.toISOString() }, + 'Webhook registered' + ); + return { ...webhook, events: denormalizeEvents(webhook.events as ('BUY' | 'SELL')[]), @@ -66,6 +73,12 @@ export async function deleteWebhook(webhookId: string, creatorId: string) { } await prisma.webhook.delete({ where: { id: webhookId } }); + + logger.info( + { creator_id: creatorId, webhook_id: webhookId, deleted_at: new Date().toISOString() }, + 'Webhook deleted' + ); + return { id: webhookId }; } @@ -85,15 +98,7 @@ export async function dispatchWebhookEvent(tradeEvent: TradeEvent) { if (webhooks.length === 0) return; for (const webhook of webhooks) { - const payload: WebhookEventPayload = { - event_type: tradeEvent.type, - creator_id: tradeEvent.creatorId, - buyer_or_seller_address: tradeEvent.buyerOrSellerAddress, - amount: tradeEvent.amount, - price: tradeEvent.price, - fee_paid: tradeEvent.feePaid, - timestamp: tradeEvent.timestamp, - }; + const payload: WebhookEventPayload = buildWebhookPayload(tradeEvent); await prisma.webhookEvent.create({ data: { From bb8c11bdb5284a3d0b611bc314d5cf298736e8b7 Mon Sep 17 00:00:00 2001 From: Nwokedi Chinelo Date: Sun, 28 Jun 2026 09:18:41 +0100 Subject: [PATCH 3/4] feat(wallets): add GET /wallets/:address/holdings endpoint and empty-wallet integration test (#442) Add wallet-holdings.service.ts, wallet-holdings.controllers.ts, and register GET /:address/holdings in wallets.routes.ts. Integration test covers empty wallet returning 200+[] and malformed address returning 400. --- .../wallet-holdings.integration.test.ts | 104 +++++++++++++++++- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/src/modules/wallets/wallet-holdings.integration.test.ts b/src/modules/wallets/wallet-holdings.integration.test.ts index 002628d..c28af11 100644 --- a/src/modules/wallets/wallet-holdings.integration.test.ts +++ b/src/modules/wallets/wallet-holdings.integration.test.ts @@ -1,12 +1,12 @@ -// Integration test: wallet holdings endpoint (#511) +// Integration test: wallet holdings endpoint (#511, #442) // -// Covers: 400 for address that is too short, wrong prefix, invalid characters. -// Asserts the `address` field is identified in the error body and no database -// query is attempted for any invalid input. +// Covers: 400 for address that is too short, wrong prefix, invalid characters; +// empty wallet → 200 + empty array, wallet with holdings → 200 + data. // Uses Jest mocks — no database required. import { httpGetWalletHoldings } from './wallet-holdings.controllers'; import * as walletHoldingsService from './wallet-holdings.service'; +import { HoldingEntry } from './wallet-holdings.schemas'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -38,6 +38,17 @@ function assertInvalidAddress(res: any, serviceSpy: jest.SpyInstance): void { expect(serviceSpy).not.toHaveBeenCalled(); } +function makeHolding(overrides: Partial = {}): HoldingEntry { + return { + creator_id: 'creator-1', + creator_handle: null, + key_count: '10', + current_price: '0', + total_value: null, + ...overrides, + }; +} + // ── Tests ───────────────────────────────────────────────────────────────────── describe('GET /wallets/:address/holdings', () => { @@ -98,4 +109,89 @@ describe('GET /wallets/:address/holdings', () => { expect(body.data.items).toEqual([]); expect(body.data.total).toBe(0); }); + + // ── Empty wallet ────────────────────────────────────────────────────────── + + it('returns 200 with empty items array for a wallet with no holdings', async () => { + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toEqual([]); + expect(body.data.total).toBe(0); + }); + + // ── Wallet with holdings ────────────────────────────────────────────────── + + it('returns 200 with populated items for a wallet with positions', async () => { + const items: HoldingEntry[] = [ + makeHolding({ creator_id: 'creator-1', key_count: '5', current_price: '100' }), + makeHolding({ creator_id: 'creator-2', key_count: '3', current_price: '50' }), + ]; + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([items, 2]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(2); + expect(body.data.total).toBe(2); + }); + + it('each holding item includes required fields', async () => { + const holding = makeHolding({ + creator_id: 'creator-xyz', + creator_handle: 'creator-handle', + key_count: '7', + current_price: '100', + total_value: '700', + }); + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[holding], 1]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const item = res.json.mock.calls[0][0].data.items[0]; + expect(item).toMatchObject({ + creator_id: 'creator-xyz', + creator_handle: 'creator-handle', + key_count: '7', + current_price: '100', + }); + }); + + it('passes the address to the service', async () => { + const spy = jest + .spyOn(walletHoldingsService, 'fetchWalletHoldings') + .mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith(VALID_ADDRESS); + }); + + // ── Error forwarding ────────────────────────────────────────────────────── + + it('forwards service errors to next()', async () => { + const err = new Error('db down'); + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockRejectedValue(err); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + const next = makeNext(); + await httpGetWalletHoldings(req, res, next); + + expect(next).toHaveBeenCalledWith(err); + }); }); From 7bc91ab6b3112bc13af8b30caa3eacebfeda97d2 Mon Sep 17 00:00:00 2001 From: Nwokedi Chinelo Date: Sun, 28 Jun 2026 09:18:41 +0100 Subject: [PATCH 4/4] fix(lint): use maskWebhookUrl in delivery retry/failure log entries Add masked callback URL to warn and error log fields so delivery failures include the endpoint origin without leaking tokens embedded in paths or query strings. --- src/modules/webhooks/webhook.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/webhooks/webhook.service.ts b/src/modules/webhooks/webhook.service.ts index e9ecb13..2573582 100644 --- a/src/modules/webhooks/webhook.service.ts +++ b/src/modules/webhooks/webhook.service.ts @@ -174,6 +174,7 @@ async function attemptDelivery( { webhook_id: webhookId, creator_id: payload.creator_id, + callback_url: maskWebhookUrl(callbackUrl), attempt_number: attempt + 1, backoff_delay_ms: delay, last_error_code: errMsg,