From e35b825e38003f6c33133ee549c37aa06e88ca4b Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sun, 6 Sep 2026 17:03:48 +0530 Subject: [PATCH] Microsoft Teams accepts the new Power Automate webhook URL format --- src/lib/server/notifications/workflows.ts | 10 ++--- src/lib/utils/notification-parsers.ts | 37 ++++++++++++++++++ tests/workflows-notifications.test.ts | 46 ++++++++++++++++++++++- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/lib/server/notifications/workflows.ts b/src/lib/server/notifications/workflows.ts index 05e1a13..1c55aa4 100644 --- a/src/lib/server/notifications/workflows.ts +++ b/src/lib/server/notifications/workflows.ts @@ -1,14 +1,12 @@ /** Microsoft Power Automate Workflows (e.g. Microsoft Teams). workflows://. */ -import { parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers'; +import { resolveWorkflowsHttpUrl } from '$lib/utils/notification-parsers'; import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared'; export async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise { - const parsed = parseWorkflowsUrl(appriseUrl); - if (!parsed) { - return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' }; + const url = resolveWorkflowsHttpUrl(appriseUrl); + if (!url) { + return { success: false, error: 'Invalid Workflows URL format. Expected the full https:// webhook URL with the scheme changed to workflows://, or workflows://hostname/workflow/signature' }; } - - const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature); const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; try { diff --git a/src/lib/utils/notification-parsers.ts b/src/lib/utils/notification-parsers.ts index 712de92..a022426 100644 --- a/src/lib/utils/notification-parsers.ts +++ b/src/lib/utils/notification-parsers.ts @@ -97,3 +97,40 @@ export function parseWorkflowsUrl(appriseUrl: string): { hostname: string; workf export function buildWorkflowsHttpUrl(hostname: string, workflow: string, signature: string): string { return `https://${hostname}/powerautomate/automations/direct/workflows/${workflow}/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=${signature}`; } + +/** + * Resolve a `workflows://` channel URL to the HTTPS endpoint to POST to. + * + * Two input shapes are accepted: + * 1. A COMPLETE webhook URL with the schema swapped to `workflows://` - i.e. it + * already carries the full `/.../triggers/manual/paths/invoke?...&sig=...` path + * and query. Microsoft has changed this path over time (legacy logic.azure.com, + * then `/powerautomate/automations/direct/`, then a `/cu//` segment on + * *.powerplatform.com - #1512). We must NOT rebuild it from parts, or new + * segments and query params (api-version, cu/) get dropped -> 401. So when the + * input already has the invoke path + a sig, pass the path and query through + * verbatim, only swapping the scheme to https. + * 2. The SHORT legacy form `workflows://host//` (what the UI + * hint documents). No invoke path, so reconstruct the endpoint as before. + * + * Returns the https URL, or null when the input is neither shape. + */ +export function resolveWorkflowsHttpUrl(appriseUrl: string): string | null { + const schemeMatch = appriseUrl.match(/^workflows?:\/\/(.*)$/is); + if (!schemeMatch) return null; + const rest = schemeMatch[1]; + + // Shape 1: a full webhook URL - it carries the invoke path. Pass it through + // verbatim (only when it also has a sig=, else it's an unsigned/invalid webhook) + // so any host/path/query shape Microsoft uses survives. An invoke path present but + // no sig is invalid - do NOT fall through to short-form parsing, which would shred + // the path into a bogus workflow/signature. + if (/\/triggers\/manual\/paths\/invoke/i.test(rest)) { + return /[?&]sig=[^&]/i.test(rest) ? `https://${rest}` : null; + } + + // Shape 2: the short legacy form host//. + const parsed = parseWorkflowsUrl(appriseUrl); + if (!parsed) return null; + return buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature); +} diff --git a/tests/workflows-notifications.test.ts b/tests/workflows-notifications.test.ts index 903fdb2..e33a5cc 100644 --- a/tests/workflows-notifications.test.ts +++ b/tests/workflows-notifications.test.ts @@ -10,7 +10,7 @@ */ import { describe, test, expect } from 'bun:test'; -import { parseWorkflowsUrl, buildWorkflowsHttpUrl, parseTelegramUrl } from '../src/lib/utils/notification-parsers'; +import { parseWorkflowsUrl, buildWorkflowsHttpUrl, resolveWorkflowsHttpUrl, parseTelegramUrl } from '../src/lib/utils/notification-parsers'; // Telegram request body construction (mirrors notifications.ts) function buildTelegramBody(chatId: string, text: string, topicId?: number) { @@ -84,6 +84,50 @@ describe('Workflows HTTP URL Construction', () => { }); }); +describe('resolveWorkflowsHttpUrl (full-URL passthrough, #1512)', () => { + test('new Power Automate URL with /cu// passes through verbatim (only scheme swapped)', () => { + // The exact shape from #1512: powerplatform.com host, a /cu/11/ segment, + // api-version=1. Rebuilding from parts drops those -> 401. Passthrough keeps them. + const full = + 'workflows://myenv.11.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/11/workflows/abc123/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=THE_SIGNATURE'; + const url = resolveWorkflowsHttpUrl(full); + expect(url).toBe( + 'https://myenv.11.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/11/workflows/abc123/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=THE_SIGNATURE' + ); + }); + + test('legacy logic.azure.com full URL passes through verbatim', () => { + const full = + 'workflows://prod-161.westeurope.logic.azure.com:443/workflows/643e/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=KODu'; + const url = resolveWorkflowsHttpUrl(full)!; + expect(url).toBe('https://' + full.slice('workflows://'.length)); + expect(url).toContain('api-version=2016-06-01'); + expect(url).toContain('sig=KODu'); + }); + + test('short legacy form host/workflow/sig is still reconstructed (back-compat)', () => { + const url = resolveWorkflowsHttpUrl('workflows://prod.logic.azure.com/abc123/sig456'); + expect(url).toBe( + 'https://prod.logic.azure.com/powerautomate/automations/direct/workflows/abc123/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=sig456' + ); + }); + + test('a full invoke path WITHOUT a sig is not treated as passthrough (falls back / rejects)', () => { + // No sig= -> not a signed webhook; short-form parse also fails -> null. + expect(resolveWorkflowsHttpUrl('workflows://host/workflows/x/triggers/manual/paths/invoke')).toBeNull(); + }); + + test('non-workflows scheme returns null', () => { + expect(resolveWorkflowsHttpUrl('https://host/x/y')).toBeNull(); + expect(resolveWorkflowsHttpUrl('')).toBeNull(); + }); + + test('workflow:// (singular) full URL also passes through', () => { + const url = resolveWorkflowsHttpUrl('workflow://host:443/workflows/w/triggers/manual/paths/invoke?sig=s')!; + expect(url).toBe('https://host:443/workflows/w/triggers/manual/paths/invoke?sig=s'); + }); +}); + describe('Workflows Adaptive Card Payload', () => { test('constructs valid Adaptive Card body', () => { const title = 'Container Started';