refactor: share teams allowlist matching helpers

Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-18 01:37:18 +00:00
parent 0674f1fa3c
commit c1da78a271
5 changed files with 174 additions and 45 deletions

View File

@@ -5,6 +5,12 @@ import type {
MSTeamsReplyStyle, MSTeamsReplyStyle,
MSTeamsTeamConfig, MSTeamsTeamConfig,
} from "../../../src/config/types.js"; } from "../../../src/config/types.js";
import {
buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
} from "../../../src/channels/plugins/channel-config.js";
export type MSTeamsResolvedRouteConfig = { export type MSTeamsResolvedRouteConfig = {
teamConfig?: MSTeamsTeamConfig; teamConfig?: MSTeamsTeamConfig;
@@ -29,56 +35,53 @@ export function resolveMSTeamsRouteConfig(params: {
const conversationId = params.conversationId?.trim(); const conversationId = params.conversationId?.trim();
const channelName = params.channelName?.trim(); const channelName = params.channelName?.trim();
const teams = params.cfg?.teams ?? {}; const teams = params.cfg?.teams ?? {};
const teamKeys = Object.keys(teams); const allowlistConfigured = Object.keys(teams).length > 0;
const allowlistConfigured = teamKeys.length > 0; const teamCandidates = buildChannelKeyCandidates(
teamId,
const normalize = (value: string) => teamName,
value teamName ? normalizeChannelSlug(teamName) : undefined,
.trim() );
.toLowerCase() const teamMatch = resolveChannelEntryMatchWithFallback({
.replace(/^#/, "") entries: teams,
.replace(/[^a-z0-9]+/g, "-") keys: teamCandidates,
.replace(/^-+|-+$/g, ""); wildcardKey: "*",
normalizeKey: normalizeChannelSlug,
let teamKey: string | undefined; });
if (teamId && teams[teamId]) teamKey = teamId; const teamConfig = teamMatch.entry;
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 channels = teamConfig?.channels ?? {}; 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; const allowed = resolveNestedAllowlistDecision({
if (conversationId && channels[conversationId]) channelKey = conversationId; outerConfigured: allowlistConfigured,
if (!channelKey && channelName) { outerMatched: Boolean(teamConfig),
const slug = normalize(channelName); innerConfigured: channelAllowlistConfigured,
if (slug) { innerMatched: Boolean(channelConfig),
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));
return { return {
teamConfig, teamConfig,
channelConfig, channelConfig,
allowlistConfigured, allowlistConfigured,
allowed, allowed,
teamKey, teamKey: teamMatch.matchKey ?? teamMatch.key,
channelKey, channelKey: channelMatch.matchKey ?? channelMatch.key,
channelMatchKey: channelKey, channelMatchKey: channelMatch.matchKey,
channelMatchSource: channelKey ? (channelKey === "*" ? "wildcard" : "direct") : undefined, channelMatchSource:
channelMatch.matchSource === "direct" || channelMatch.matchSource === "wildcard"
? channelMatch.matchSource
: undefined,
}; };
} }

View File

@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import { import {
buildChannelKeyCandidates, buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatch, resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback, resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
} from "./channel-config.js"; } from "./channel-config.js";
describe("buildChannelKeyCandidates", () => { 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", () => { describe("resolveChannelEntryMatch", () => {
it("returns matched entry and wildcard metadata", () => { it("returns matched entry and wildcard metadata", () => {
const entries = { a: { allow: true }, "*": { allow: false } }; const entries = { a: { allow: true }, "*": { allow: false } };
@@ -66,4 +76,59 @@ describe("resolveChannelEntryMatchWithFallback", () => {
expect(match.matchSource).toBe("wildcard"); expect(match.matchSource).toBe("wildcard");
expect(match.matchKey).toBe("*"); 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);
});
}); });

View File

@@ -1,8 +1,5 @@
export type ChannelMatchSource = "direct" | "parent" | "wildcard"; export type ChannelMatchSource = "direct" | "parent" | "wildcard";
export function buildChannelKeyCandidates(
...keys: Array<string | undefined | null>
): string[] {
export type ChannelEntryMatch<T> = { export type ChannelEntryMatch<T> = {
entry?: T; entry?: T;
key?: string; key?: string;
@@ -14,6 +11,15 @@ export type ChannelEntryMatch<T> = {
matchSource?: ChannelMatchSource; matchSource?: ChannelMatchSource;
}; };
export function normalizeChannelSlug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/^#/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function buildChannelKeyCandidates( export function buildChannelKeyCandidates(
...keys: Array<string | undefined | null> ...keys: Array<string | undefined | null>
): string[] { ): string[] {
@@ -54,6 +60,7 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
keys: string[]; keys: string[];
parentKeys?: string[]; parentKeys?: string[];
wildcardKey?: string; wildcardKey?: string;
normalizeKey?: (value: string) => string;
}): ChannelEntryMatch<T> { }): ChannelEntryMatch<T> {
const direct = resolveChannelEntryMatch({ const direct = resolveChannelEntryMatch({
entries: params.entries, entries: params.entries,
@@ -65,6 +72,25 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
return { ...direct, matchKey: direct.key, matchSource: "direct" }; 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 ?? []; const parentKeys = params.parentKeys ?? [];
if (parentKeys.length > 0) { if (parentKeys.length > 0) {
const parent = resolveChannelEntryMatch({ entries: params.entries, keys: parentKeys }); const parent = resolveChannelEntryMatch({ entries: params.entries, keys: parentKeys });
@@ -79,6 +105,25 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
matchSource: "parent", 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) { if (direct.wildcardEntry && direct.wildcardKey) {
@@ -93,3 +138,15 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
return direct; 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;
}

View File

@@ -1,6 +1,8 @@
export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js"; export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js";
export { export {
buildChannelKeyCandidates, buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatch, resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback, resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
} from "../channel-config.js"; } from "../channel-config.js";

View File

@@ -86,8 +86,10 @@ export {
} from "./directory-config.js"; } from "./directory-config.js";
export { export {
buildChannelKeyCandidates, buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatch, resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback, resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
type ChannelEntryMatch, type ChannelEntryMatch,
type ChannelMatchSource, type ChannelMatchSource,
} from "./channel-config.js"; } from "./channel-config.js";