From c29b3b158e7ec6d2e254ff9def016ac4e61fd494 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Fri, 2 Oct 2026 21:57:44 +0200 Subject: [PATCH 1/2] feat(webhooks): support Basic Auth credentials and read flags --- src/types.ts | 10 +++++++ src/webhook-basic-auth.spec.ts | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/webhook-basic-auth.spec.ts diff --git a/src/types.ts b/src/types.ts index 9257b4f..07cdb17 100644 --- a/src/types.ts +++ b/src/types.ts @@ -426,6 +426,11 @@ export interface StoreSuppressionData { "applies_to"?: SuppressionAppliesTo | null; } +export interface WebhookBasicAuthData { + "username": string; + "password": string; +} + export interface StoreWebhookData { "name": string; "url": string; @@ -437,6 +442,7 @@ export interface StoreWebhookData { "route_ids"?: string[]; "route_id"?: string | null; "delivery_mode_filter"?: WebhookDeliveryModeFilter | null; + basic_auth?: WebhookBasicAuthData | null; } export interface SuppressedRecipientData { @@ -591,6 +597,7 @@ export interface UpdateWebhookData { "route_ids"?: string[]; "route_id"?: string | null; "delivery_mode_filter"?: WebhookDeliveryModeFilter; + basic_auth?: WebhookBasicAuthData | null; } export interface WebhookData { @@ -608,6 +615,7 @@ export interface WebhookData { "created_at": string; "updated_at": string; "delivery_mode_filter": WebhookDeliveryModeFilter; + has_basic_auth: boolean; } export interface WebhookDeliveryData { @@ -664,6 +672,7 @@ export interface WebhookListData { "last_called_at": string | null; "created_at": string; "updated_at": string; + has_basic_auth: boolean; } export type WebhookScope = "team" | "project" | "route"; @@ -684,6 +693,7 @@ export interface WebhookSecretData { "created_at": string; "updated_at": string; "delivery_mode_filter": WebhookDeliveryModeFilter; + has_basic_auth: boolean; } export type DeliveryMode = "live" | "sandbox"; diff --git a/src/webhook-basic-auth.spec.ts b/src/webhook-basic-auth.spec.ts new file mode 100644 index 0000000..02883f8 --- /dev/null +++ b/src/webhook-basic-auth.spec.ts @@ -0,0 +1,53 @@ +import { Lettermint } from './lettermint'; +import type { WebhookBasicAuthData, WebhookStoreRequest, WebhookUpdateRequest } from './types'; + +const fetchMock = jest.fn(); +beforeEach(() => { + global.fetch = fetchMock; + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: { has_basic_auth: true } }) }); +}); + +it.each([ + ['omit', {}], + ['set', { basic_auth: { username: ' fixture user ', password: '' } }], + ['remove', { basic_auth: null }], +])('keeps the %s credential state and API Bearer authentication', async (_, state) => { + const api = Lettermint.api('fixture-token'); + const create: WebhookStoreRequest = { + name: 'Fixture', + url: 'https://example.test/hook', + events: ['message.sent'], + ...state, + }; + const update: WebhookUpdateRequest = state; + expect((await api.webhooks.create(create)).data.has_basic_auth).toBe(true); + expect((await api.webhooks.update('webhook-id', update)).data.has_basic_auth).toBe(true); + for (const [index, payload] of [create, update].entries()) { + const [url, request] = fetchMock.mock.calls[index]; + expect(url).toBe(`https://api.lettermint.co/v1/webhooks${index ? '/webhook-id' : ''}`); + expect(request.method).toBe(index ? 'PUT' : 'POST'); + expect(JSON.parse(request.body)).toEqual(payload); + expect(request.headers.Authorization).toBe('Bearer fixture-token'); + expect(request.headers).not.toHaveProperty('x-lettermint-token'); + } + const credentials: WebhookBasicAuthData = { username: 'fixture', password: '' }; + expect(credentials.password).toBe(''); +}); + +it('keeps the Free-plan Sandbox 403 response', async () => { + const body = { + error: { + code: 'FEATURE_NOT_AVAILABLE', + message: 'Sandbox mode is available only on paid plans.', + }, + }; + fetchMock.mockResolvedValueOnce({ ok: false, status: 403, json: async () => body }); + await expect( + Lettermint.email('fixture-token') + .from('from@example.test') + .to('to@example.test') + .subject('Fixture') + .send() + ).rejects.toMatchObject({ statusCode: 403, responseBody: body }); +}); From 97ec67dddab5838c6a28082e75be31979c8ba464 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Fri, 2 Oct 2026 22:15:18 +0200 Subject: [PATCH 2/2] test(types): prove migration for required webhook auth flags --- src/webhook-basic-auth.spec.ts | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/webhook-basic-auth.spec.ts b/src/webhook-basic-auth.spec.ts index 02883f8..1ab2654 100644 --- a/src/webhook-basic-auth.spec.ts +++ b/src/webhook-basic-auth.spec.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; +import ts from 'typescript'; import { Lettermint } from './lettermint'; import type { WebhookBasicAuthData, WebhookStoreRequest, WebhookUpdateRequest } from './types'; @@ -51,3 +53,51 @@ it('keeps the Free-plan Sandbox 403 response', async () => { .send() ).rejects.toMatchObject({ statusCode: 403, responseBody: body }); }); + +it('proves that old typed webhook fixtures need the required safe flag', () => { + const filename = resolve(__dirname, '__old_webhook_caller__.ts'); + const source = `import type { WebhookData, WebhookListData, WebhookSecretData } from './types'; +const legacy: Omit = { + id: 'fixture', scope: 'route', project_ids: [], route_ids: [], route_id: null, + name: 'Fixture', url: 'https://example.test/hook', events: [], enabled: true, + include_machine_events: false, last_called_at: null, created_at: '', updated_at: '', + delivery_mode_filter: 'both' +}; +const detail: WebhookData = legacy; +const list: WebhookListData = legacy; +const secret: WebhookSecretData = { ...legacy, secret: 'synthetic-signing-secret' }; +`; + const compile = (text: string) => { + const options: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + }; + const host = ts.createCompilerHost(options); + const read = host.getSourceFile.bind(host); + host.getSourceFile = (path, languageVersion, ...args) => + path === filename + ? ts.createSourceFile(path, text, languageVersion, true) + : read(path, languageVersion, ...args); + return ts.getPreEmitDiagnostics(ts.createProgram([filename], options, host)); + }; + const errors = compile(source); + expect(errors).toHaveLength(3); + for (const error of errors) { + expect(error.code).toBe(2741); + expect(ts.flattenDiagnosticMessageText(error.messageText, '\n')).toContain('has_basic_auth'); + } + expect( + compile( + source + .replace( + "delivery_mode_filter: 'both'", + "delivery_mode_filter: 'both', has_basic_auth: false" + ) + .replace("Omit", 'WebhookData') + ) + ).toHaveLength(0); +});