refactor: share teams allowlist matching helpers
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user