From c1da78a271be60cbd182f0115b666651c14f24b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 01:37:18 +0000 Subject: [PATCH] refactor: share teams allowlist matching helpers Co-authored-by: thewilloftheshadow --- extensions/msteams/src/policy.ts | 87 +++++++++++++------------- src/channels/channel-config.test.ts | 65 +++++++++++++++++++ src/channels/channel-config.ts | 63 ++++++++++++++++++- src/channels/plugins/channel-config.ts | 2 + src/channels/plugins/index.ts | 2 + 5 files changed, 174 insertions(+), 45 deletions(-) diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index 166103655..00fb08091 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -5,6 +5,12 @@ import type { MSTeamsReplyStyle, MSTeamsTeamConfig, } from "../../../src/config/types.js"; +import { + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveChannelEntryMatchWithFallback, + resolveNestedAllowlistDecision, +} from "../../../src/channels/plugins/channel-config.js"; export type MSTeamsResolvedRouteConfig = { teamConfig?: MSTeamsTeamConfig; @@ -29,56 +35,53 @@ export function resolveMSTeamsRouteConfig(params: { const conversationId = params.conversationId?.trim(); const channelName = params.channelName?.trim(); const teams = params.cfg?.teams ?? {}; - const teamKeys = Object.keys(teams); - const allowlistConfigured = teamKeys.length > 0; - - const normalize = (value: string) => - value - .trim() - .toLowerCase() - .replace(/^#/, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - let teamKey: string | undefined; - if (teamId && teams[teamId]) teamKey = teamId; - if (!teamKey && teamName) { - const slug = normalize(teamName); - if (slug) { - teamKey = teamKeys.find((key) => normalize(key) === slug); - } - } - if (!teamKey && teams["*"]) teamKey = "*"; - - const teamConfig = teamKey ? teams[teamKey] : undefined; + const allowlistConfigured = Object.keys(teams).length > 0; + const teamCandidates = buildChannelKeyCandidates( + teamId, + teamName, + teamName ? normalizeChannelSlug(teamName) : undefined, + ); + const teamMatch = resolveChannelEntryMatchWithFallback({ + entries: teams, + keys: teamCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const teamConfig = teamMatch.entry; const channels = teamConfig?.channels ?? {}; - const channelKeys = Object.keys(channels); + const channelAllowlistConfigured = Object.keys(channels).length > 0; + const channelCandidates = buildChannelKeyCandidates( + conversationId, + channelName, + channelName ? normalizeChannelSlug(channelName) : undefined, + ); + const channelMatch = resolveChannelEntryMatchWithFallback({ + entries: channels, + keys: channelCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const channelConfig = channelMatch.entry; - let channelKey: string | undefined; - if (conversationId && channels[conversationId]) channelKey = conversationId; - if (!channelKey && channelName) { - const slug = normalize(channelName); - if (slug) { - channelKey = channelKeys.find((key) => normalize(key) === slug); - } - } - if (!channelKey && channels["*"]) channelKey = "*"; - const channelConfig = channelKey ? channels[channelKey] : undefined; - const channelAllowlistConfigured = channelKeys.length > 0; - - const allowed = !allowlistConfigured - ? true - : Boolean(teamConfig) && (!channelAllowlistConfigured || Boolean(channelConfig)); + const allowed = resolveNestedAllowlistDecision({ + outerConfigured: allowlistConfigured, + outerMatched: Boolean(teamConfig), + innerConfigured: channelAllowlistConfigured, + innerMatched: Boolean(channelConfig), + }); return { teamConfig, channelConfig, allowlistConfigured, allowed, - teamKey, - channelKey, - channelMatchKey: channelKey, - channelMatchSource: channelKey ? (channelKey === "*" ? "wildcard" : "direct") : undefined, + teamKey: teamMatch.matchKey ?? teamMatch.key, + channelKey: channelMatch.matchKey ?? channelMatch.key, + channelMatchKey: channelMatch.matchKey, + channelMatchSource: + channelMatch.matchSource === "direct" || channelMatch.matchSource === "wildcard" + ? channelMatch.matchSource + : undefined, }; } diff --git a/src/channels/channel-config.test.ts b/src/channels/channel-config.test.ts index a42483404..25cee4ac2 100644 --- a/src/channels/channel-config.test.ts +++ b/src/channels/channel-config.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { buildChannelKeyCandidates, + normalizeChannelSlug, resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback, + resolveNestedAllowlistDecision, } from "./channel-config.js"; describe("buildChannelKeyCandidates", () => { @@ -12,6 +14,14 @@ describe("buildChannelKeyCandidates", () => { }); }); +describe("normalizeChannelSlug", () => { + it("normalizes names into slugs", () => { + expect(normalizeChannelSlug("My Team")).toBe("my-team"); + expect(normalizeChannelSlug("#General Chat")).toBe("general-chat"); + expect(normalizeChannelSlug(" Dev__Chat ")).toBe("dev-chat"); + }); +}); + describe("resolveChannelEntryMatch", () => { it("returns matched entry and wildcard metadata", () => { const entries = { a: { allow: true }, "*": { allow: false } }; @@ -66,4 +76,59 @@ describe("resolveChannelEntryMatchWithFallback", () => { expect(match.matchSource).toBe("wildcard"); expect(match.matchKey).toBe("*"); }); + + it("matches normalized keys when normalizeKey is provided", () => { + const entries = { "My Team": { allow: true } }; + const match = resolveChannelEntryMatchWithFallback({ + entries, + keys: ["my-team"], + normalizeKey: normalizeChannelSlug, + }); + expect(match.entry).toBe(entries["My Team"]); + expect(match.matchSource).toBe("direct"); + expect(match.matchKey).toBe("My Team"); + }); +}); + +describe("resolveNestedAllowlistDecision", () => { + it("allows when outer allowlist is disabled", () => { + expect( + resolveNestedAllowlistDecision({ + outerConfigured: false, + outerMatched: false, + innerConfigured: false, + innerMatched: false, + }), + ).toBe(true); + }); + + it("blocks when outer allowlist is configured but missing match", () => { + expect( + resolveNestedAllowlistDecision({ + outerConfigured: true, + outerMatched: false, + innerConfigured: false, + innerMatched: false, + }), + ).toBe(false); + }); + + it("requires inner match when inner allowlist is configured", () => { + expect( + resolveNestedAllowlistDecision({ + outerConfigured: true, + outerMatched: true, + innerConfigured: true, + innerMatched: false, + }), + ).toBe(false); + expect( + resolveNestedAllowlistDecision({ + outerConfigured: true, + outerMatched: true, + innerConfigured: true, + innerMatched: true, + }), + ).toBe(true); + }); }); diff --git a/src/channels/channel-config.ts b/src/channels/channel-config.ts index d4f9f516a..ce9445c51 100644 --- a/src/channels/channel-config.ts +++ b/src/channels/channel-config.ts @@ -1,8 +1,5 @@ export type ChannelMatchSource = "direct" | "parent" | "wildcard"; -export function buildChannelKeyCandidates( - ...keys: Array -): string[] { export type ChannelEntryMatch = { entry?: T; key?: string; @@ -14,6 +11,15 @@ export type ChannelEntryMatch = { matchSource?: ChannelMatchSource; }; +export function normalizeChannelSlug(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/^#/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + export function buildChannelKeyCandidates( ...keys: Array ): string[] { @@ -54,6 +60,7 @@ export function resolveChannelEntryMatchWithFallback(params: { keys: string[]; parentKeys?: string[]; wildcardKey?: string; + normalizeKey?: (value: string) => string; }): ChannelEntryMatch { const direct = resolveChannelEntryMatch({ entries: params.entries, @@ -65,6 +72,25 @@ export function resolveChannelEntryMatchWithFallback(params: { return { ...direct, matchKey: direct.key, matchSource: "direct" }; } + const normalizeKey = params.normalizeKey; + if (normalizeKey) { + const normalizedKeys = params.keys.map((key) => normalizeKey(key)).filter(Boolean); + if (normalizedKeys.length > 0) { + for (const [entryKey, entry] of Object.entries(params.entries ?? {})) { + const normalizedEntry = normalizeKey(entryKey); + if (normalizedEntry && normalizedKeys.includes(normalizedEntry)) { + return { + ...direct, + entry, + key: entryKey, + matchKey: entryKey, + matchSource: "direct", + }; + } + } + } + } + const parentKeys = params.parentKeys ?? []; if (parentKeys.length > 0) { const parent = resolveChannelEntryMatch({ entries: params.entries, keys: parentKeys }); @@ -79,6 +105,25 @@ export function resolveChannelEntryMatchWithFallback(params: { matchSource: "parent", }; } + if (normalizeKey) { + const normalizedParentKeys = parentKeys.map((key) => normalizeKey(key)).filter(Boolean); + if (normalizedParentKeys.length > 0) { + for (const [entryKey, entry] of Object.entries(params.entries ?? {})) { + const normalizedEntry = normalizeKey(entryKey); + if (normalizedEntry && normalizedParentKeys.includes(normalizedEntry)) { + return { + ...direct, + entry, + key: entryKey, + parentEntry: entry, + parentKey: entryKey, + matchKey: entryKey, + matchSource: "parent", + }; + } + } + } + } } if (direct.wildcardEntry && direct.wildcardKey) { @@ -93,3 +138,15 @@ export function resolveChannelEntryMatchWithFallback(params: { return direct; } + +export function resolveNestedAllowlistDecision(params: { + outerConfigured: boolean; + outerMatched: boolean; + innerConfigured: boolean; + innerMatched: boolean; +}): boolean { + if (!params.outerConfigured) return true; + if (!params.outerMatched) return false; + if (!params.innerConfigured) return true; + return params.innerMatched; +} diff --git a/src/channels/plugins/channel-config.ts b/src/channels/plugins/channel-config.ts index ae6ee2c65..e2bd35cdc 100644 --- a/src/channels/plugins/channel-config.ts +++ b/src/channels/plugins/channel-config.ts @@ -1,6 +1,8 @@ export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js"; export { buildChannelKeyCandidates, + normalizeChannelSlug, resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback, + resolveNestedAllowlistDecision, } from "../channel-config.js"; diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index c44313df5..b75ad9c19 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -86,8 +86,10 @@ export { } from "./directory-config.js"; export { buildChannelKeyCandidates, + normalizeChannelSlug, resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback, + resolveNestedAllowlistDecision, type ChannelEntryMatch, type ChannelMatchSource, } from "./channel-config.js";