@openclaw/msteams 2026.2.21 → 2026.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  DEFAULT_ACCOUNT_ID,
7
7
  MSTeamsConfigSchema,
8
8
  PAIRING_APPROVED_MESSAGE,
9
+ resolveAllowlistProviderRuntimeGroupPolicy,
10
+ resolveDefaultGroupPolicy,
9
11
  } from "openclaw/plugin-sdk";
10
12
  import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js";
11
13
  import { msteamsOnboardingAdapter } from "./onboarding.js";
@@ -127,8 +129,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
127
129
  },
128
130
  security: {
129
131
  collectWarnings: ({ cfg }) => {
130
- const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
131
- const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
132
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
133
+ const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
134
+ providerConfigPresent: cfg.channels?.msteams !== undefined,
135
+ groupPolicy: cfg.channels?.msteams?.groupPolicy,
136
+ defaultGroupPolicy,
137
+ });
132
138
  if (groupPolicy !== "open") {
133
139
  return [];
134
140
  }
@@ -1,11 +1,8 @@
1
1
  import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
2
+ import { searchGraphUsers } from "./graph-users.js";
2
3
  import {
3
- escapeOData,
4
- fetchGraphJson,
5
4
  type GraphChannel,
6
5
  type GraphGroup,
7
- type GraphResponse,
8
- type GraphUser,
9
6
  listChannelsForTeam,
10
7
  listTeamsByName,
11
8
  normalizeQuery,
@@ -24,22 +21,7 @@ export async function listMSTeamsDirectoryPeersLive(params: {
24
21
  const token = await resolveGraphToken(params.cfg);
25
22
  const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
26
23
 
27
- let users: GraphUser[] = [];
28
- if (query.includes("@")) {
29
- const escaped = escapeOData(query);
30
- const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
31
- const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
32
- const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
33
- users = res.value ?? [];
34
- } else {
35
- const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`;
36
- const res = await fetchGraphJson<GraphResponse<GraphUser>>({
37
- token,
38
- path,
39
- headers: { ConsistencyLevel: "eventual" },
40
- });
41
- users = res.value ?? [];
42
- }
24
+ const users = await searchGraphUsers({ token, query, top: limit });
43
25
 
44
26
  return users
45
27
  .map((user) => {
@@ -0,0 +1,66 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { searchGraphUsers } from "./graph-users.js";
3
+ import { fetchGraphJson } from "./graph.js";
4
+
5
+ vi.mock("./graph.js", () => ({
6
+ escapeOData: vi.fn((value: string) => value.replace(/'/g, "''")),
7
+ fetchGraphJson: vi.fn(),
8
+ }));
9
+
10
+ describe("searchGraphUsers", () => {
11
+ beforeEach(() => {
12
+ vi.mocked(fetchGraphJson).mockReset();
13
+ });
14
+
15
+ it("returns empty array for blank queries", async () => {
16
+ await expect(searchGraphUsers({ token: "token-1", query: " " })).resolves.toEqual([]);
17
+ expect(fetchGraphJson).not.toHaveBeenCalled();
18
+ });
19
+
20
+ it("uses exact mail/upn filter lookup for email-like queries", async () => {
21
+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({
22
+ value: [{ id: "user-1", displayName: "User One" }],
23
+ } as never);
24
+
25
+ const result = await searchGraphUsers({
26
+ token: "token-2",
27
+ query: "alice.o'hara@example.com",
28
+ });
29
+
30
+ expect(fetchGraphJson).toHaveBeenCalledWith({
31
+ token: "token-2",
32
+ path: "/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName",
33
+ });
34
+ expect(result).toEqual([{ id: "user-1", displayName: "User One" }]);
35
+ });
36
+
37
+ it("uses displayName search with eventual consistency and custom top", async () => {
38
+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({
39
+ value: [{ id: "user-2", displayName: "Bob" }],
40
+ } as never);
41
+
42
+ const result = await searchGraphUsers({
43
+ token: "token-3",
44
+ query: "bob",
45
+ top: 25,
46
+ });
47
+
48
+ expect(fetchGraphJson).toHaveBeenCalledWith({
49
+ token: "token-3",
50
+ path: "/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25",
51
+ headers: { ConsistencyLevel: "eventual" },
52
+ });
53
+ expect(result).toEqual([{ id: "user-2", displayName: "Bob" }]);
54
+ });
55
+
56
+ it("falls back to default top and empty value handling", async () => {
57
+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
58
+
59
+ await expect(searchGraphUsers({ token: "token-4", query: "carol" })).resolves.toEqual([]);
60
+ expect(fetchGraphJson).toHaveBeenCalledWith({
61
+ token: "token-4",
62
+ path: "/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10",
63
+ headers: { ConsistencyLevel: "eventual" },
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,29 @@
1
+ import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js";
2
+
3
+ export async function searchGraphUsers(params: {
4
+ token: string;
5
+ query: string;
6
+ top?: number;
7
+ }): Promise<GraphUser[]> {
8
+ const query = params.query.trim();
9
+ if (!query) {
10
+ return [];
11
+ }
12
+
13
+ if (query.includes("@")) {
14
+ const escaped = escapeOData(query);
15
+ const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
16
+ const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
17
+ const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token: params.token, path });
18
+ return res.value ?? [];
19
+ }
20
+
21
+ const top = typeof params.top === "number" && params.top > 0 ? params.top : 10;
22
+ const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${top}`;
23
+ const res = await fetchGraphJson<GraphResponse<GraphUser>>({
24
+ token: params.token,
25
+ path,
26
+ headers: { ConsistencyLevel: "eventual" },
27
+ });
28
+ return res.value ?? [];
29
+ }
package/src/graph.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { MSTeamsConfig } from "openclaw/plugin-sdk";
2
2
  import { GRAPH_ROOT } from "./attachments/shared.js";
3
3
  import { loadMSTeamsSdkWithAuth } from "./sdk.js";
4
+ import { readAccessToken } from "./token-response.js";
4
5
  import { resolveMSTeamsCredentials } from "./token.js";
5
6
 
6
7
  export type GraphUser = {
@@ -22,18 +23,6 @@ export type GraphChannel = {
22
23
 
23
24
  export type GraphResponse<T> = { value?: T[] };
24
25
 
25
- function readAccessToken(value: unknown): string | null {
26
- if (typeof value === "string") {
27
- return value;
28
- }
29
- if (value && typeof value === "object") {
30
- const token =
31
- (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
32
- return typeof token === "string" ? token : null;
33
- }
34
- return null;
35
- }
36
-
37
26
  export function normalizeQuery(value?: string | null): string {
38
27
  return value?.trim() ?? "";
39
28
  }
@@ -49,6 +49,28 @@ const runtimeStub = {
49
49
  },
50
50
  } as unknown as PluginRuntime;
51
51
 
52
+ const createNoopAdapter = (): MSTeamsAdapter => ({
53
+ continueConversation: async () => {},
54
+ process: async () => {},
55
+ });
56
+
57
+ const createRecordedSendActivity = (
58
+ sink: string[],
59
+ failFirstWithStatusCode?: number,
60
+ ): ((activity: unknown) => Promise<{ id: string }>) => {
61
+ let attempts = 0;
62
+ return async (activity: unknown) => {
63
+ const { text } = activity as { text?: string };
64
+ const content = text ?? "";
65
+ sink.push(content);
66
+ attempts += 1;
67
+ if (failFirstWithStatusCode !== undefined && attempts === 1) {
68
+ throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode });
69
+ }
70
+ return { id: `id:${content}` };
71
+ };
72
+ };
73
+
52
74
  describe("msteams messenger", () => {
53
75
  beforeEach(() => {
54
76
  setMSTeamsRuntime(runtimeStub);
@@ -117,17 +139,9 @@ describe("msteams messenger", () => {
117
139
  it("sends thread messages via the provided context", async () => {
118
140
  const sent: string[] = [];
119
141
  const ctx = {
120
- sendActivity: async (activity: unknown) => {
121
- const { text } = activity as { text?: string };
122
- sent.push(text ?? "");
123
- return { id: `id:${text ?? ""}` };
124
- },
125
- };
126
-
127
- const adapter: MSTeamsAdapter = {
128
- continueConversation: async () => {},
129
- process: async () => {},
142
+ sendActivity: createRecordedSendActivity(sent),
130
143
  };
144
+ const adapter = createNoopAdapter();
131
145
 
132
146
  const ids = await sendMSTeamsMessages({
133
147
  replyStyle: "thread",
@@ -149,11 +163,7 @@ describe("msteams messenger", () => {
149
163
  continueConversation: async (_appId, reference, logic) => {
150
164
  seen.reference = reference;
151
165
  await logic({
152
- sendActivity: async (activity: unknown) => {
153
- const { text } = activity as { text?: string };
154
- seen.texts.push(text ?? "");
155
- return { id: `id:${text ?? ""}` };
156
- },
166
+ sendActivity: createRecordedSendActivity(seen.texts),
157
167
  });
158
168
  },
159
169
  process: async () => {},
@@ -192,10 +202,7 @@ describe("msteams messenger", () => {
192
202
  },
193
203
  };
194
204
 
195
- const adapter: MSTeamsAdapter = {
196
- continueConversation: async () => {},
197
- process: async () => {},
198
- };
205
+ const adapter = createNoopAdapter();
199
206
 
200
207
  const ids = await sendMSTeamsMessages({
201
208
  replyStyle: "thread",
@@ -242,20 +249,9 @@ describe("msteams messenger", () => {
242
249
  const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
243
250
 
244
251
  const ctx = {
245
- sendActivity: async (activity: unknown) => {
246
- const { text } = activity as { text?: string };
247
- attempts.push(text ?? "");
248
- if (attempts.length === 1) {
249
- throw Object.assign(new Error("throttled"), { statusCode: 429 });
250
- }
251
- return { id: `id:${text ?? ""}` };
252
- },
253
- };
254
-
255
- const adapter: MSTeamsAdapter = {
256
- continueConversation: async () => {},
257
- process: async () => {},
252
+ sendActivity: createRecordedSendActivity(attempts, 429),
258
253
  };
254
+ const adapter = createNoopAdapter();
259
255
 
260
256
  const ids = await sendMSTeamsMessages({
261
257
  replyStyle: "thread",
@@ -280,10 +276,7 @@ describe("msteams messenger", () => {
280
276
  },
281
277
  };
282
278
 
283
- const adapter: MSTeamsAdapter = {
284
- continueConversation: async () => {},
285
- process: async () => {},
286
- };
279
+ const adapter = createNoopAdapter();
287
280
 
288
281
  await expect(
289
282
  sendMSTeamsMessages({
@@ -303,18 +296,7 @@ describe("msteams messenger", () => {
303
296
 
304
297
  const adapter: MSTeamsAdapter = {
305
298
  continueConversation: async (_appId, _reference, logic) => {
306
- await logic({
307
- sendActivity: async (activity: unknown) => {
308
- const { text } = activity as { text?: string };
309
- attempts.push(text ?? "");
310
- if (attempts.length === 1) {
311
- throw Object.assign(new Error("server error"), {
312
- statusCode: 503,
313
- });
314
- }
315
- return { id: `id:${text ?? ""}` };
316
- },
317
- });
299
+ await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
318
300
  },
319
301
  process: async () => {},
320
302
  };
package/src/messenger.ts CHANGED
@@ -441,11 +441,7 @@ export async function sendMSTeamsMessages(params: {
441
441
  }
442
442
  };
443
443
 
444
- if (params.replyStyle === "thread") {
445
- const ctx = params.context;
446
- if (!ctx) {
447
- throw new Error("Missing context for replyStyle=thread");
448
- }
444
+ const sendMessagesInContext = async (ctx: SendContext): Promise<string[]> => {
449
445
  const messageIds: string[] = [];
450
446
  for (const [idx, message] of messages.entries()) {
451
447
  const response = await sendWithRetry(
@@ -464,6 +460,14 @@ export async function sendMSTeamsMessages(params: {
464
460
  messageIds.push(extractMessageId(response) ?? "unknown");
465
461
  }
466
462
  return messageIds;
463
+ };
464
+
465
+ if (params.replyStyle === "thread") {
466
+ const ctx = params.context;
467
+ if (!ctx) {
468
+ throw new Error("Missing context for replyStyle=thread");
469
+ }
470
+ return await sendMessagesInContext(ctx);
467
471
  }
468
472
 
469
473
  const baseRef = buildConversationReference(params.conversationRef);
@@ -474,22 +478,7 @@ export async function sendMSTeamsMessages(params: {
474
478
 
475
479
  const messageIds: string[] = [];
476
480
  await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
477
- for (const [idx, message] of messages.entries()) {
478
- const response = await sendWithRetry(
479
- async () =>
480
- await ctx.sendActivity(
481
- await buildActivity(
482
- message,
483
- params.conversationRef,
484
- params.tokenProvider,
485
- params.sharePointSiteId,
486
- params.mediaMaxBytes,
487
- ),
488
- ),
489
- { messageIndex: idx, messageCount: messages.length },
490
- );
491
- messageIds.push(extractMessageId(response) ?? "unknown");
492
- }
481
+ messageIds.push(...(await sendMessagesInContext(ctx)));
493
482
  });
494
483
  return messageIds;
495
484
  }
@@ -5,6 +5,8 @@ import {
5
5
  logInboundDrop,
6
6
  recordPendingHistoryEntryIfEnabled,
7
7
  resolveControlCommandGate,
8
+ resolveDefaultGroupPolicy,
9
+ isDangerousNameMatchingEnabled,
8
10
  resolveMentionGating,
9
11
  formatAllowlistMatchMeta,
10
12
  type HistoryEntry,
@@ -124,16 +126,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
124
126
 
125
127
  const senderName = from.name ?? from.id;
126
128
  const senderId = from.aadObjectId ?? from.id;
127
- const storedAllowFrom = await core.channel.pairing
128
- .readAllowFromStore("msteams")
129
- .catch(() => []);
129
+ const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
130
+ const storedAllowFrom =
131
+ dmPolicy === "allowlist"
132
+ ? []
133
+ : await core.channel.pairing.readAllowFromStore("msteams").catch(() => []);
130
134
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
131
135
 
132
136
  // Check DM policy for direct messages.
133
137
  const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
134
138
  const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom];
135
139
  if (isDirectMessage && msteamsCfg) {
136
- const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
137
140
  const allowFrom = dmAllowFrom;
138
141
 
139
142
  if (dmPolicy === "disabled") {
@@ -143,10 +146,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
143
146
 
144
147
  if (dmPolicy !== "open") {
145
148
  const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
149
+ const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
146
150
  const allowMatch = resolveMSTeamsAllowlistMatch({
147
151
  allowFrom: effectiveAllowFrom,
148
152
  senderId,
149
153
  senderName,
154
+ allowNameMatching,
150
155
  });
151
156
 
152
157
  if (!allowMatch.allowed) {
@@ -173,7 +178,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
173
178
  }
174
179
  }
175
180
 
176
- const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
181
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
177
182
  const groupPolicy =
178
183
  !isDirectMessage && msteamsCfg
179
184
  ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
@@ -224,10 +229,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
224
229
  return;
225
230
  }
226
231
  if (effectiveGroupAllowFrom.length > 0) {
232
+ const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
227
233
  const allowMatch = resolveMSTeamsAllowlistMatch({
228
234
  allowFrom: effectiveGroupAllowFrom,
229
235
  senderId,
230
236
  senderName,
237
+ allowNameMatching,
231
238
  });
232
239
  if (!allowMatch.allowed) {
233
240
  log.debug?.("dropping group message (not in groupAllowFrom)", {
@@ -246,12 +253,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
246
253
  allowFrom: effectiveDmAllowFrom,
247
254
  senderId,
248
255
  senderName,
256
+ allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
249
257
  });
250
258
  const groupAllowedForCommands = isMSTeamsGroupAllowed({
251
259
  groupPolicy: "allowlist",
252
260
  allowFrom: effectiveGroupAllowFrom,
253
261
  senderId,
254
262
  senderName,
263
+ allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
255
264
  });
256
265
  const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
257
266
  const commandGate = resolveControlCommandGate({
@@ -184,7 +184,7 @@ describe("msteams policy", () => {
184
184
  ).toBe(true);
185
185
  });
186
186
 
187
- it("allows allowlist when sender name matches", () => {
187
+ it("blocks sender-name allowlist matches by default", () => {
188
188
  expect(
189
189
  isMSTeamsGroupAllowed({
190
190
  groupPolicy: "allowlist",
@@ -192,6 +192,18 @@ describe("msteams policy", () => {
192
192
  senderId: "other",
193
193
  senderName: "User",
194
194
  }),
195
+ ).toBe(false);
196
+ });
197
+
198
+ it("allows sender-name allowlist matches when explicitly enabled", () => {
199
+ expect(
200
+ isMSTeamsGroupAllowed({
201
+ groupPolicy: "allowlist",
202
+ allowFrom: ["user"],
203
+ senderId: "other",
204
+ senderName: "User",
205
+ allowNameMatching: true,
206
+ }),
195
207
  ).toBe(true);
196
208
  });
197
209
 
package/src/policy.ts CHANGED
@@ -209,6 +209,7 @@ export function resolveMSTeamsAllowlistMatch(params: {
209
209
  allowFrom: Array<string | number>;
210
210
  senderId: string;
211
211
  senderName?: string | null;
212
+ allowNameMatching?: boolean;
212
213
  }): MSTeamsAllowlistMatch {
213
214
  return resolveAllowlistMatchSimple(params);
214
215
  }
@@ -245,6 +246,7 @@ export function isMSTeamsGroupAllowed(params: {
245
246
  allowFrom: Array<string | number>;
246
247
  senderId: string;
247
248
  senderName?: string | null;
249
+ allowNameMatching?: boolean;
248
250
  }): boolean {
249
251
  const { groupPolicy } = params;
250
252
  if (groupPolicy === "disabled") {
package/src/probe.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk";
2
2
  import { formatUnknownError } from "./errors.js";
3
3
  import { loadMSTeamsSdkWithAuth } from "./sdk.js";
4
+ import { readAccessToken } from "./token-response.js";
4
5
  import { resolveMSTeamsCredentials } from "./token.js";
5
6
 
6
7
  export type ProbeMSTeamsResult = BaseProbeResult<string> & {
@@ -13,18 +14,6 @@ export type ProbeMSTeamsResult = BaseProbeResult<string> & {
13
14
  };
14
15
  };
15
16
 
16
- function readAccessToken(value: unknown): string | null {
17
- if (typeof value === "string") {
18
- return value;
19
- }
20
- if (value && typeof value === "object") {
21
- const token =
22
- (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
23
- return typeof token === "string" ? token : null;
24
- }
25
- return null;
26
- }
27
-
28
17
  function decodeJwtPayload(token: string): Record<string, unknown> | null {
29
18
  const parts = token.split(".");
30
19
  if (parts.length < 2) {
@@ -1,8 +1,5 @@
1
+ import { searchGraphUsers } from "./graph-users.js";
1
2
  import {
2
- escapeOData,
3
- fetchGraphJson,
4
- type GraphResponse,
5
- type GraphUser,
6
3
  listChannelsForTeam,
7
4
  listTeamsByName,
8
5
  normalizeQuery,
@@ -182,22 +179,7 @@ export async function resolveMSTeamsUserAllowlist(params: {
182
179
  results.push({ input, resolved: true, id: query });
183
180
  continue;
184
181
  }
185
- let users: GraphUser[] = [];
186
- if (query.includes("@")) {
187
- const escaped = escapeOData(query);
188
- const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
189
- const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
190
- const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
191
- users = res.value ?? [];
192
- } else {
193
- const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`;
194
- const res = await fetchGraphJson<GraphResponse<GraphUser>>({
195
- token,
196
- path,
197
- headers: { ConsistencyLevel: "eventual" },
198
- });
199
- users = res.value ?? [];
200
- }
182
+ const users = await searchGraphUsers({ token, query, top: 10 });
201
183
  const match = users[0];
202
184
  if (!match?.id) {
203
185
  results.push({ input, resolved: false });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readAccessToken } from "./token-response.js";
3
+
4
+ describe("readAccessToken", () => {
5
+ it("returns raw string token values", () => {
6
+ expect(readAccessToken("abc")).toBe("abc");
7
+ });
8
+
9
+ it("returns accessToken from object value", () => {
10
+ expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token");
11
+ });
12
+
13
+ it("returns token fallback from object value", () => {
14
+ expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token");
15
+ });
16
+
17
+ it("returns null for unsupported values", () => {
18
+ expect(readAccessToken({ accessToken: 123 })).toBeNull();
19
+ expect(readAccessToken({ token: false })).toBeNull();
20
+ expect(readAccessToken(null)).toBeNull();
21
+ expect(readAccessToken(undefined)).toBeNull();
22
+ });
23
+ });
@@ -0,0 +1,11 @@
1
+ export function readAccessToken(value: unknown): string | null {
2
+ if (typeof value === "string") {
3
+ return value;
4
+ }
5
+ if (value && typeof value === "object") {
6
+ const token =
7
+ (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
8
+ return typeof token === "string" ? token : null;
9
+ }
10
+ return null;
11
+ }