@open-everywhere/provider-teams 0.0.0-stage → 0.1.0

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.
Files changed (146) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +46 -2
  4. package/agent/channels/teams-bot-name.ts +162 -0
  5. package/agent/channels/teams-catch-up.ts +244 -0
  6. package/agent/channels/teams-connector.ts +183 -0
  7. package/agent/channels/teams-conversation-ref.ts +398 -0
  8. package/agent/channels/teams-directory.ts +193 -0
  9. package/agent/channels/teams-engagement.ts +391 -0
  10. package/agent/channels/teams-fire-eligibility.ts +152 -0
  11. package/agent/channels/teams-front.ts +1404 -0
  12. package/agent/channels/teams-graph-boundary.ts +178 -0
  13. package/agent/channels/teams-graph-team-id.ts +106 -0
  14. package/agent/channels/teams-id-restore.ts +131 -0
  15. package/agent/channels/teams-identity.ts +395 -0
  16. package/agent/channels/teams-input-response.ts +129 -0
  17. package/agent/channels/teams-message-ops.ts +79 -0
  18. package/agent/channels/teams-origin-codec.ts +104 -0
  19. package/agent/channels/teams-post-arm.ts +323 -0
  20. package/agent/channels/teams-reaction-feedback.ts +127 -0
  21. package/agent/channels/teams-read.ts +510 -0
  22. package/agent/channels/teams-roster.ts +136 -0
  23. package/agent/channels/teams-stop.ts +319 -0
  24. package/agent/channels/teams-supervisor.ts +638 -0
  25. package/agent/channels/teams.ts +270 -0
  26. package/agent/contributions.ts +31 -0
  27. package/agent/docs-generated.ts +15 -0
  28. package/agent/ops.ts +129 -0
  29. package/agent/provider-events.ts +69 -0
  30. package/agent/register.ts +219 -0
  31. package/agent/surface-parity.ts +112 -0
  32. package/dist/agent/channels/teams-bot-name.d.ts +36 -0
  33. package/dist/agent/channels/teams-bot-name.d.ts.map +1 -0
  34. package/dist/agent/channels/teams-bot-name.js +141 -0
  35. package/dist/agent/channels/teams-bot-name.js.map +1 -0
  36. package/dist/agent/channels/teams-catch-up.d.ts +53 -0
  37. package/dist/agent/channels/teams-catch-up.d.ts.map +1 -0
  38. package/dist/agent/channels/teams-catch-up.js +221 -0
  39. package/dist/agent/channels/teams-catch-up.js.map +1 -0
  40. package/dist/agent/channels/teams-connector.d.ts +57 -0
  41. package/dist/agent/channels/teams-connector.d.ts.map +1 -0
  42. package/dist/agent/channels/teams-connector.js +132 -0
  43. package/dist/agent/channels/teams-connector.js.map +1 -0
  44. package/dist/agent/channels/teams-conversation-ref.d.ts +123 -0
  45. package/dist/agent/channels/teams-conversation-ref.d.ts.map +1 -0
  46. package/dist/agent/channels/teams-conversation-ref.js +368 -0
  47. package/dist/agent/channels/teams-conversation-ref.js.map +1 -0
  48. package/dist/agent/channels/teams-directory.d.ts +11 -0
  49. package/dist/agent/channels/teams-directory.d.ts.map +1 -0
  50. package/dist/agent/channels/teams-directory.js +179 -0
  51. package/dist/agent/channels/teams-directory.js.map +1 -0
  52. package/dist/agent/channels/teams-engagement.d.ts +174 -0
  53. package/dist/agent/channels/teams-engagement.d.ts.map +1 -0
  54. package/dist/agent/channels/teams-engagement.js +336 -0
  55. package/dist/agent/channels/teams-engagement.js.map +1 -0
  56. package/dist/agent/channels/teams-fire-eligibility.d.ts +26 -0
  57. package/dist/agent/channels/teams-fire-eligibility.d.ts.map +1 -0
  58. package/dist/agent/channels/teams-fire-eligibility.js +126 -0
  59. package/dist/agent/channels/teams-fire-eligibility.js.map +1 -0
  60. package/dist/agent/channels/teams-front.d.ts +249 -0
  61. package/dist/agent/channels/teams-front.d.ts.map +1 -0
  62. package/dist/agent/channels/teams-front.js +1143 -0
  63. package/dist/agent/channels/teams-front.js.map +1 -0
  64. package/dist/agent/channels/teams-graph-boundary.d.ts +45 -0
  65. package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -0
  66. package/dist/agent/channels/teams-graph-boundary.js +168 -0
  67. package/dist/agent/channels/teams-graph-boundary.js.map +1 -0
  68. package/dist/agent/channels/teams-graph-team-id.d.ts +30 -0
  69. package/dist/agent/channels/teams-graph-team-id.d.ts.map +1 -0
  70. package/dist/agent/channels/teams-graph-team-id.js +89 -0
  71. package/dist/agent/channels/teams-graph-team-id.js.map +1 -0
  72. package/dist/agent/channels/teams-id-restore.d.ts +34 -0
  73. package/dist/agent/channels/teams-id-restore.d.ts.map +1 -0
  74. package/dist/agent/channels/teams-id-restore.js +123 -0
  75. package/dist/agent/channels/teams-id-restore.js.map +1 -0
  76. package/dist/agent/channels/teams-identity.d.ts +148 -0
  77. package/dist/agent/channels/teams-identity.d.ts.map +1 -0
  78. package/dist/agent/channels/teams-identity.js +336 -0
  79. package/dist/agent/channels/teams-identity.js.map +1 -0
  80. package/dist/agent/channels/teams-input-response.d.ts +23 -0
  81. package/dist/agent/channels/teams-input-response.d.ts.map +1 -0
  82. package/dist/agent/channels/teams-input-response.js +102 -0
  83. package/dist/agent/channels/teams-input-response.js.map +1 -0
  84. package/dist/agent/channels/teams-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/teams-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/teams-message-ops.js +72 -0
  87. package/dist/agent/channels/teams-message-ops.js.map +1 -0
  88. package/dist/agent/channels/teams-origin-codec.d.ts +16 -0
  89. package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/teams-origin-codec.js +99 -0
  91. package/dist/agent/channels/teams-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/teams-post-arm.d.ts +37 -0
  93. package/dist/agent/channels/teams-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/teams-post-arm.js +280 -0
  95. package/dist/agent/channels/teams-post-arm.js.map +1 -0
  96. package/dist/agent/channels/teams-reaction-feedback.d.ts +22 -0
  97. package/dist/agent/channels/teams-reaction-feedback.d.ts.map +1 -0
  98. package/dist/agent/channels/teams-reaction-feedback.js +108 -0
  99. package/dist/agent/channels/teams-reaction-feedback.js.map +1 -0
  100. package/dist/agent/channels/teams-read.d.ts +127 -0
  101. package/dist/agent/channels/teams-read.d.ts.map +1 -0
  102. package/dist/agent/channels/teams-read.js +424 -0
  103. package/dist/agent/channels/teams-read.js.map +1 -0
  104. package/dist/agent/channels/teams-roster.d.ts +54 -0
  105. package/dist/agent/channels/teams-roster.d.ts.map +1 -0
  106. package/dist/agent/channels/teams-roster.js +121 -0
  107. package/dist/agent/channels/teams-roster.js.map +1 -0
  108. package/dist/agent/channels/teams-stop.d.ts +74 -0
  109. package/dist/agent/channels/teams-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/teams-stop.js +252 -0
  111. package/dist/agent/channels/teams-stop.js.map +1 -0
  112. package/dist/agent/channels/teams-supervisor.d.ts +351 -0
  113. package/dist/agent/channels/teams-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/teams-supervisor.js +366 -0
  115. package/dist/agent/channels/teams-supervisor.js.map +1 -0
  116. package/dist/agent/channels/teams.d.ts +5 -0
  117. package/dist/agent/channels/teams.d.ts.map +1 -0
  118. package/dist/agent/channels/teams.js +246 -0
  119. package/dist/agent/channels/teams.js.map +1 -0
  120. package/dist/agent/contributions.d.ts +12 -0
  121. package/dist/agent/contributions.d.ts.map +1 -0
  122. package/dist/agent/contributions.js +31 -0
  123. package/dist/agent/contributions.js.map +1 -0
  124. package/dist/agent/docs-generated.d.ts +3 -0
  125. package/dist/agent/docs-generated.d.ts.map +1 -0
  126. package/dist/agent/docs-generated.js +13 -0
  127. package/dist/agent/docs-generated.js.map +1 -0
  128. package/dist/agent/ops.d.ts +14 -0
  129. package/dist/agent/ops.d.ts.map +1 -0
  130. package/dist/agent/ops.js +122 -0
  131. package/dist/agent/ops.js.map +1 -0
  132. package/dist/agent/provider-events.d.ts +3 -0
  133. package/dist/agent/provider-events.d.ts.map +1 -0
  134. package/dist/agent/provider-events.js +67 -0
  135. package/dist/agent/provider-events.js.map +1 -0
  136. package/dist/agent/register.d.ts +16 -0
  137. package/dist/agent/register.d.ts.map +1 -0
  138. package/dist/agent/register.js +198 -0
  139. package/dist/agent/register.js.map +1 -0
  140. package/dist/agent/surface-parity.d.ts +3 -0
  141. package/dist/agent/surface-parity.d.ts.map +1 -0
  142. package/dist/agent/surface-parity.js +106 -0
  143. package/dist/agent/surface-parity.js.map +1 -0
  144. package/package.json +183 -4
  145. package/tsconfig.build.json +22 -0
  146. package/tsconfig.json +15 -0
@@ -0,0 +1,424 @@
1
+ // Microsoft Teams channel/thread READ adapter for the generic read verbs (channel-read.ts) — the
2
+ // third wired surface after Slack (slack-read.ts, reused verbatim) and Discord (inline in
3
+ // channel-read.ts). Teams was DECLARED UNSUPPORTED at the read core's introduction because eve
4
+ // exposes no Teams message-history API; this module closes that gap via Microsoft GRAPH, the API
5
+ // that actually carries Teams channel/thread history (Bot Framework does not).
6
+ //
7
+ // WHY A SEPARATE GRAPH TOKEN (not eve's own Teams token): eve's `resolveTeamsAccessToken`
8
+ // (eve/channels/teams, used by reply-post.ts's sendTeamsActivity/replyToTeamsActivity) mints a
9
+ // token scoped to `https://api.botframework.com/.default` — the Bot Framework CONNECTOR audience,
10
+ // which can POST activities but exposes no history-read endpoint. Reading channel messages / thread
11
+ // replies instead needs `https://graph.microsoft.com/.default` — a DIFFERENT audience, so it needs
12
+ // its OWN token. We reuse the EXACT SAME client-credential FLOW + env resolution eve's mint uses —
13
+ // same `MICROSOFT_APP_ID`/`MICROSOFT_APP_PASSWORD`/`MICROSOFT_TENANT_ID` (with the `TEAMS_APP_*`
14
+ // fallbacks), same `login.microsoftonline.com/{tenant}/oauth2/v2.0/token` endpoint, same
15
+ // `grant_type=client_credentials` — by calling eve's OWN public `resolveTeamsAppId` /
16
+ // `resolveTeamsAppPassword` / `resolveTeamsTenantId` for id/password/tenant resolution (never
17
+ // re-implemented), and only requesting Graph's scope instead of the Connector's. The app
18
+ // registration must ALSO hold a Graph permission that can read channel messages
19
+ // (`ChannelMessage.Read.All`, admin-consented) OR already have the per-team RSC grant
20
+ // `ChannelMessage.Read.Group` this deployment's own Teams app manifest already requests for
21
+ // ambient watching (teams-app/manifest.json, docs/deploy/surfaces/teams.md) — when a team already
22
+ // has that grant, THIS adapter needs no extra Azure configuration at all. Undocumented/absent
23
+ // either way surfaces as a Graph 401/403, fail-loud (see below), never a silent empty read.
24
+ //
25
+ // SCOPE — TEAM CHANNELS ONLY. The manifest also carries the CHAT RSC grant `ChatMessage.Read.Chat`,
26
+ // whose Graph analogue is `/chats/{chat-id}/messages`: a different id shape (a chat has no team, so
27
+ // the TWO-LEVEL ID note below doesn't apply) and a different allowlist grain. Reading a group
28
+ // chat's history is therefore NOT wired here — that grant exists for the delivery side
29
+ // (teams-front.ts). A chat-read adapter would be its own arm, not a parameter of these calls.
30
+ //
31
+ // TWO-LEVEL ID: unlike Slack/Discord's single channel id, a Graph channel read needs BOTH a team id
32
+ // AND a channel id (`/teams/{team}/channels/{channel}/…`). The domain allowlist (like every other
33
+ // platform) lists only the CHANNEL id — the same stable per-channel grain memory resolution already
34
+ // keys on (teams-identity.ts's `domainSurface`; see memory/resolve.ts's domainKeyOf comment) — so
35
+ // the team id is resolved from the LIVE turn's OWN binding (always known on an engaged Teams
36
+ // session; see identity/binding.ts's bindTeams `target.teamId`), never tracked per allowed channel.
37
+ // 🔴 AND IT IS RESOLVED AGAIN BEFORE IT REACHES A URL. The binding carries the BOT CONNECTOR's team
38
+ // id (`19:…@thread.tacv2`); Graph's `/teams/{id}` segment takes the team's Microsoft 365 GROUP id and
39
+ // nothing else. `channels/teams-graph-team-id.ts` maps the one to the other over the Connector and
40
+ // caches it (OPE-1808) — `currentGraphTeamId` below is the only place these reads take a team id from.
41
+ // Reading a channel OTHER than the current one therefore assumes it lives in the SAME team as the
42
+ // turn (the common case — a domain's Teams channels are one team's channels, exactly like a Slack
43
+ // domain's channels being one workspace's); a genuine cross-team domain surfaces a clear Graph
44
+ // 404/403 rather than silently reading the wrong team.
45
+ //
46
+ // GRAPH QUIRKS (verified against Microsoft's own docs, 2026-07):
47
+ // - Channel messages (`GET .../channels/{id}/messages`) and thread replies (`GET
48
+ // .../messages/{id}/replies`) support `$top` (max 50) but NOT `$orderby`/`$filter` — so unlike
49
+ // Slack/Discord's guaranteed history order, we sort explicitly by `createdDateTime` before
50
+ // rendering "oldest first" rather than trusting the API's order.
51
+ // - Both endpoints observably return NEWEST-first (root messages by the whole reply chain's last-
52
+ // modified time; replies by their own createdDateTime) — the OPPOSITE of Slack's
53
+ // conversations.replies (oldest-first). Our explicit sort makes this a non-issue either way.
54
+ // - Pagination is `@odata.nextLink` (an absolute follow-up URL), bounded here at
55
+ // {@link TEAMS_GRAPH_MAX_PAGES} pages so a busy channel can't fan out unboundedly (mirrors
56
+ // discord-backscroll.ts's DISCORD_RAW_FETCH_MAX_PAGES).
57
+ // - Message bodies are always `body.contentType:"html"`; `stripHtml` below strips tags (mention
58
+ // `<at>` spans included) and decodes the common entities — a self-contained copy of the Teams
59
+ // e2e driver's tag-strip (scripts/e2e/teams-driver.ts), kept LOCAL rather than imported (agent/
60
+ // code must not depend on scripts/, the same DAG discipline discord-backscroll.ts's
61
+ // isDiscordBackscrollVisible note documents for Slack-vs-Discord).
62
+ // - A `deletedDateTime` message or a non-"message" `messageType` (systemEventMessage, chatEvent,
63
+ // …) is dropped — the Graph analogue of Slack's `subtype` filter / Discord's `isConversationType`.
64
+ //
65
+ // FAIL LOUD (unlike Discord backscroll's fail-soft posture): a read must surface its error, never
66
+ // silently return an empty page — matching discordReadAdapter's posture in channel-read.ts.
67
+ import { resolveTeamsAppId, resolveTeamsAppPassword, resolveTeamsTenantId } from "eve/channels/teams";
68
+ import { auditChannelReadRefusal, bindFrom, resolvePartition, domainKeyOf, domainForChannel, getConfig, refutedReadAuthorIds, ChannelScopeError, warmCredential, domainRoster, } from "@open-everywhere/core/agent/adapter-api.js";
69
+ import { noteTeamsReadBoundary, teamsReadBoundary, _resetTeamsReadBoundaryForTest } from "./teams-graph-boundary.js";
70
+ import { teamsGraphTeamId } from "./teams-graph-team-id.js";
71
+ // OPE-929 — the reply-chain half of a channel conversation id; the suffix is spelled once, there.
72
+ import { teamsReplyChainRoot } from "./teams-conversation-ref.js";
73
+ const GRAPH_BASE = "https://graph.microsoft.com/v1.0";
74
+ const GRAPH_TOKEN_SCOPE = "https://graph.microsoft.com/.default";
75
+ const TOKEN_LOGIN_BASE = "https://login.microsoftonline.com";
76
+ /** Refresh the cached Graph token this long before its stated expiry, so a call never races it. */
77
+ const TOKEN_SKEW_MS = 60_000;
78
+ /** Microsoft's documented app-only token lifetime — used only when a token response omits
79
+ * `expires_in` (every real one carries it). */
80
+ const GRAPH_TOKEN_FALLBACK_TTL_SEC = 3600;
81
+ const TEAMS_CHANNEL_DEFAULT_LIMIT = 50;
82
+ const TEAMS_THREAD_DEFAULT_LIMIT = 100;
83
+ /** Graph's own hard cap on `$top` for both the channel-messages and thread-replies endpoints. */
84
+ const TEAMS_GRAPH_PAGE_SIZE = 50;
85
+ /** Bounds a busy channel's fetch at ≤5 pages (≤250 messages) — mirrors Discord's page bound. */
86
+ const TEAMS_GRAPH_MAX_PAGES = 5;
87
+ // --- Graph app-only token (client-credentials, Graph scope) --------------------------------
88
+ /**
89
+ * Mint an app-only Microsoft Graph access token via the client-credentials flow — see the file
90
+ * header for why this can't reuse eve's `resolveTeamsAccessToken` (wrong audience). Fails LOUD
91
+ * (throws) on a missing tenant id or a failed token exchange; `resolveTeamsAppId` /
92
+ * `resolveTeamsAppPassword` already throw their own actionable "MICROSOFT_APP_ID is required" etc.
93
+ * errors when unset, so this only adds the tenant-id check (eve's Connector flow tolerates an absent
94
+ * tenant by falling back to the `botframework.com` placeholder tenant; Graph's client-credentials
95
+ * flow has no such fallback — it is genuinely tenant-scoped). Never echoes the app password or the
96
+ * minted token: this message is written to an audit row.
97
+ */
98
+ async function mintTeamsGraphTokenOnce() {
99
+ const [appId, appPassword, tenantId] = await Promise.all([resolveTeamsAppId(), resolveTeamsAppPassword(), resolveTeamsTenantId()]);
100
+ if (!tenantId) {
101
+ throw new Error("read: Teams channel reads need MICROSOFT_TENANT_ID (or TEAMS_TENANT_ID) set — Graph's " +
102
+ "client-credential token is tenant-scoped (unlike the Bot Connector token, which tolerates an absent tenant).");
103
+ }
104
+ const body = new URLSearchParams({ client_id: appId, client_secret: appPassword, grant_type: "client_credentials", scope: GRAPH_TOKEN_SCOPE });
105
+ const res = await fetch(`${TOKEN_LOGIN_BASE}/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`, {
106
+ method: "POST",
107
+ headers: { "content-type": "application/x-www-form-urlencoded" },
108
+ body: body.toString(),
109
+ });
110
+ const json = (await res.json().catch(() => ({})));
111
+ if (!res.ok || !json.access_token) {
112
+ throw new Error(`read: Teams Graph token request failed — ${json.error_description ?? json.error ?? `HTTP ${res.status}`}.`);
113
+ }
114
+ // WHICH BOUNDARY GOVERNS THIS DEPLOYMENT'S READS (OPE-428). The mint is the ONE place the answer
115
+ // is knowable without an extra Graph call — the token's own `roles` claim says whether a
116
+ // TENANT-WIDE application permission is consented, which is what removes Microsoft's per-team
117
+ // check. teams-graph-boundary.ts holds the determination for the refusal rows and audits it when
118
+ // it changes. Best-effort by construction: it never throws, so a read can't fail over a marker.
119
+ await noteTeamsReadBoundary(json.access_token);
120
+ return { value: json.access_token, expiresAtMs: Date.now() + (json.expires_in ?? GRAPH_TOKEN_FALLBACK_TTL_SEC) * 1000 };
121
+ }
122
+ /**
123
+ * The Graph app-only token, held warm by the SHARED seam (core credentials/warm.ts, OPE-134).
124
+ *
125
+ * ⚠️ THE NO-REFRESH RHYTHM IS EXACTLY THE `"re-mint"` POSTURE. An app-only token lives ~1h and
126
+ * carries NO refresh token — the only renewal is another client-credentials exchange from the app
127
+ * registration's own secret, which is what `"re-mint"` means (contrast `"refresh-token"`, the OAuth
128
+ * dance the seam deliberately refuses). "No refresh" describes the absent refresh TOKEN, not an
129
+ * absent renewal: nothing about this credential needs a rhythm the seam doesn't already have.
130
+ *
131
+ * Keyed by nothing — one deployment = one app registration = one tenant. The 60s margin is kept
132
+ * from the pre-unification code rather than taking the seam's 5-minute default: this credential's
133
+ * behaviour must not change under a refactor, and 60s is a defensible margin for a 1h token.
134
+ *
135
+ * WHAT UNIFYING BOUGHT THIS SURFACE: single-flight (concurrent reads in one turn used to each
136
+ * kick off their own token exchange) and, far more importantly, a LOUD failure — a broken app
137
+ * password now leaves a durable, queryable `teams.token_mint_failed` audit row instead of only an
138
+ * exception inside whichever read happened to trip over it.
139
+ */
140
+ const teamsGraphToken = warmCredential({
141
+ name: "teams.graph-app-only",
142
+ platform: "teams",
143
+ auditAction: "teams.token_mint_failed",
144
+ refresh: "re-mint",
145
+ refreshMarginMs: TOKEN_SKEW_MS,
146
+ fallbackTtlSeconds: GRAPH_TOKEN_FALLBACK_TTL_SEC,
147
+ mint: mintTeamsGraphTokenOnce,
148
+ });
149
+ /** Test-only reset — drops the held Graph token AND the boundary determination it produced (the
150
+ * two are minted together; leaving the boundary behind would let one test's token decide the next
151
+ * test's audit marker). */
152
+ export function _resetTeamsGraphTokenCache() {
153
+ teamsGraphToken.reset();
154
+ _resetTeamsReadBoundaryForTest();
155
+ }
156
+ /** Exported (OPE-522) so channels/teams-fire-eligibility.ts's Graph probes reuse the SAME token
157
+ * mint + GET plumbing rather than standing up a second one — the routine fire-eligibility leg's
158
+ * requests are ordinary Graph reads, no different in shape from a channel-history read. Callers
159
+ * that need "unresolvable" rather than a thrown error (fire-eligibility's fail-open posture) catch
160
+ * around it themselves — this function's own fail-LOUD contract is unchanged. */
161
+ export const liveTeamsGraphRequest = async (url) => {
162
+ const token = await teamsGraphToken.get();
163
+ const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
164
+ if (!res.ok) {
165
+ const text = await res.text().catch(() => "");
166
+ throw new Error(`read: Teams Graph GET failed (HTTP ${res.status}) for ${url}${text ? ` — ${text.slice(0, 200)}` : ""}`);
167
+ }
168
+ return res.json();
169
+ };
170
+ /** HTML message body → plain text: drop tags (mention `<at>…</at>` spans included), decode the
171
+ * five common entities, collapse whitespace. Pure — a self-contained copy of the Teams e2e
172
+ * driver's stripHtml (scripts/e2e/teams-driver.ts); see the file header for why it's not shared. */
173
+ export function stripHtml(html) {
174
+ return html
175
+ .replace(/<[^>]*>/g, " ")
176
+ .replace(/&amp;/g, "&")
177
+ .replace(/&lt;/g, "<")
178
+ .replace(/&gt;/g, ">")
179
+ .replace(/&quot;/g, '"')
180
+ .replace(/&#39;/g, "'")
181
+ .replace(/\s+/g, " ")
182
+ .trim();
183
+ }
184
+ function authorOf(m) {
185
+ return m.from?.user?.displayName ?? m.from?.user?.id ?? m.from?.application?.displayName ?? m.from?.application?.id ?? "(unknown)";
186
+ }
187
+ /** A message kept as ordinary conversation content: not deleted, and either no `messageType` or
188
+ * exactly "message" (system events — teamDescriptionUpdated, chatEvent, etc. — carry other
189
+ * values and are dropped, the Graph analogue of Slack's `subtype` filter). */
190
+ function isConversationMessage(m) {
191
+ if (m.deletedDateTime)
192
+ return false;
193
+ return m.messageType === undefined || m.messageType === "message";
194
+ }
195
+ function toIntermediate(m) {
196
+ if (!isConversationMessage(m))
197
+ return undefined;
198
+ const text = stripHtml(m.body?.content ?? "");
199
+ if (!text)
200
+ return undefined;
201
+ const parsed = m.createdDateTime ? Date.parse(m.createdDateTime) : Number.NaN;
202
+ // OPE-474 grading fields, additive: `author` (the display string) is untouched. A `from.user.id`
203
+ // is the person; a `from.application` with no user is a machine; neither present is unattributable.
204
+ const userId = m.from?.user?.id;
205
+ const isApp = m.from?.user === undefined && m.from?.application !== undefined;
206
+ return {
207
+ author: authorOf(m),
208
+ ts: m.id ?? "",
209
+ text,
210
+ createdMs: Number.isFinite(parsed) ? parsed : 0,
211
+ ...(isApp ? { bot: true } : typeof userId === "string" && userId.length > 0 ? { authorId: userId } : {}),
212
+ };
213
+ }
214
+ /**
215
+ * Normalize a raw Graph messages array (either endpoint's `value`) into oldest-first
216
+ * {@link TeamsRenderedLine}[] — dropping deleted/system messages and empty bodies, HTML-stripped.
217
+ * Graph guarantees neither endpoint's order (no `$orderby` support, and both are observed
218
+ * newest-first in practice — see the file header), so this ALWAYS sorts by `createdDateTime`
219
+ * rather than trusting API order. Pure; exported for the render test (mirrors discordLinesFromRaw).
220
+ */
221
+ export function teamsLinesFromRaw(raw) {
222
+ // `createdMs` is the sort key and stops here; the OPE-474 grading fields ride on.
223
+ return teamsTimedLinesFromRaw(raw).map(({ createdMs: _createdMs, ...line }) => line);
224
+ }
225
+ /**
226
+ * The same normalization, with each line's CREATE TIME kept (epoch ms). The read verbs do not need it
227
+ * — a rendered line carries the message id in `ts`, not a timestamp — but the seat catch-up does: it
228
+ * bounds its window by time and core orders the whole window by it (channels/seat-catch-up.ts).
229
+ * Exported so `teams-catch-up.ts` shares this one normalization rather than re-deriving the sender
230
+ * kind and the system-message filter, which is the whole way those two could disagree about a
231
+ * message. `createdMs` is 0 when Graph sent no readable `createdDateTime`; the caller drops it.
232
+ */
233
+ export function teamsTimedLinesFromRaw(raw) {
234
+ if (!Array.isArray(raw))
235
+ return [];
236
+ const lines = raw.map(toIntermediate).filter((l) => l !== undefined);
237
+ lines.sort((a, b) => a.createdMs - b.createdMs);
238
+ return lines;
239
+ }
240
+ /** Follow `@odata.nextLink` until `limit` messages are in hand or {@link TEAMS_GRAPH_MAX_PAGES}
241
+ * pages are exhausted — a hard bound so a busy channel/thread can't fan out unboundedly (mirrors
242
+ * discord-backscroll.ts's DISCORD_RAW_FETCH_MAX_PAGES). Returns the raw messages, unsorted
243
+ * (teamsLinesFromRaw sorts). Propagates any requester error (fail-loud). */
244
+ export async function fetchGraphPages(req, initialUrl, limit) {
245
+ const all = [];
246
+ let url = initialUrl;
247
+ for (let page = 0; page < TEAMS_GRAPH_MAX_PAGES && url && all.length < limit; page++) {
248
+ const json = (await req(url));
249
+ all.push(...(Array.isArray(json.value) ? json.value : []));
250
+ url = typeof json["@odata.nextLink"] === "string" ? json["@odata.nextLink"] : undefined;
251
+ }
252
+ return all;
253
+ }
254
+ export function channelMessagesUrl(teamId, channelId) {
255
+ return `${GRAPH_BASE}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages?$top=${TEAMS_GRAPH_PAGE_SIZE}`;
256
+ }
257
+ export function threadRepliesUrl(teamId, channelId, rootMessageId) {
258
+ return `${GRAPH_BASE}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(rootMessageId)}/replies?$top=${TEAMS_GRAPH_PAGE_SIZE}`;
259
+ }
260
+ // --- the Teams-specific channel allowlist (channel grain, not the ephemeral conversation grain) --
261
+ //
262
+ // channel-read.ts's shared allowedSurfaceIdsFrom keys "own" off the raw surfaceKey — correct for
263
+ // Slack/Discord (whose surfaceKey IS the readable unit) but wrong for Teams, whose surfaceKey is a
264
+ // per-reply-chain CONVERSATION id (bindTeams's `surface`), not the stable CHANNEL grain a Graph read
265
+ // actually operates on. domainKeyOf resolves that stable grain already (teams-identity.ts's
266
+ // `domainSurface`; see memory/resolve.ts's own domainKeyOf comment) — this reuses THAT, not the raw
267
+ // surfaceKey. Kept local rather than importing channel-read.ts's helper: channel-read.ts registers
268
+ // this adapter, so importing back would cycle the module graph (the same DAG discipline
269
+ // discord-backscroll.ts's isDiscordBackscrollVisible note documents).
270
+ //
271
+ // SANITIZATION QUIRK (Teams-only): a Teams channel id looks like `19:abc…@thread.tacv2` — binding.ts's
272
+ // `bindFrom` runs every surface/domainSurface segment through its filesystem-safe `seg()` sanitizer
273
+ // (`[^a-zA-Z0-9._-]+` → "-"), so the value domainKeyOf and a domain's exact `reach` entries actually carry is the SANITIZED
274
+ // form (e.g. `19-abc-thread.tacv2`), not the raw Graph id. Slack/Discord ids are already safe (no
275
+ // sanitization visible), so this is invisible there; Teams is the first surface where it bites. The
276
+ // GRAPH CALL needs the RAW id verbatim (Graph literally requires the colons/`@`), so `readChannel`/
277
+ // `readThread` pass the caller's raw `channel` straight into the URL — but the ALLOWLIST check below
278
+ // sanitizes it first (mirroring binding.ts's `seg()`, kept local — same DAG-avoidance rationale as
279
+ // above) so it compares like-for-like against the already-sanitized grain. Consequence for deployers:
280
+ // a Teams domain's `reach` entries must list the SANITIZED channel id (what domainKeyOf actually
281
+ // produces), not the raw `19:…@thread.tacv2` string — documented in docs/deploy/surfaces/teams.md.
282
+ const UNSAFE_SEGMENT_CHARS = /[^a-zA-Z0-9._-]+/g;
283
+ /** Mirrors binding.ts's private `seg()` sanitizer exactly (same regex, same trim, same 100-char
284
+ * cap) so a raw Teams channel id compares correctly against the already-sanitized domain grain.
285
+ * Pure; exported for the allowlist test. */
286
+ export function sanitizeTeamsChannelId(value) {
287
+ return value.trim().replace(UNSAFE_SEGMENT_CHARS, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 100);
288
+ }
289
+ /** The Teams CHANNEL ids this session may read (SANITIZED — see the header note): the current
290
+ * channel (its channel grain) plus every Teams channel this session's explicit domain lists. Pure
291
+ * but for the injected `domains` — mirrors channel-read.ts's allowedSurfaceIdsFrom shape so it is
292
+ * unit-testable the same way. */
293
+ export function allowedTeamsChannelIds(channelGrainKey, domains) {
294
+ const ids = new Set();
295
+ const own = channelGrainKey.split("/")[2];
296
+ if (own)
297
+ ids.add(own);
298
+ const domain = domainForChannel(channelGrainKey, domains);
299
+ if (domain) {
300
+ for (const key of domainRoster(domain)) {
301
+ const parts = key.split("/");
302
+ if (parts.length === 3 && parts[0] === "teams" && parts[2])
303
+ ids.add(parts[2]);
304
+ }
305
+ }
306
+ return { ids, domain: domain?.name };
307
+ }
308
+ /** Assert a requested (raw) Teams channel id is readable by this session — else AUDIT the refusal
309
+ * and throw (fail closed), the same ChannelScopeError shape slack-read/channel-read raise.
310
+ * Sanitizes `channelId` before comparing (see the header note) — the RAW value is what the Graph
311
+ * call itself uses, and the SANITIZED one is what the row records, so a row compares like-for-like
312
+ * against the domain's `reach`. The decision is unchanged; only the row is new. */
313
+ export async function assertTeamsChannelAllowed(ctx, channelId, deps) {
314
+ const p = resolvePartition(ctx);
315
+ const domains = getConfig().loadDomains();
316
+ const callerKey = domainKeyOf(p);
317
+ const { ids, domain } = allowedTeamsChannelIds(callerKey, domains);
318
+ const wanted = sanitizeTeamsChannelId(channelId);
319
+ if (ids.has(wanted))
320
+ return;
321
+ // The refused read never reached Graph, so no Microsoft boundary governed THIS call — OE's own
322
+ // fence did. The marker still belongs on the row: it says which boundary governs the reads this
323
+ // deployment DOES let through, which is the fact a defender reading a refusal wants next
324
+ // (OPE-428). `"unknown"` when no Graph token has been minted in this process yet — the fence must
325
+ // not make a network call to answer.
326
+ await auditChannelReadRefusal(ctx, { platform: "teams", surfaceId: wanted, callerKey, callerDomain: domain, domains, boundary: teamsReadBoundary() }, deps);
327
+ const scope = domain ? `domain "${domain}"` : "this channel's (no multi-channel domain)";
328
+ throw new ChannelScopeError(`channel "${channelId}" is not in ${scope} teams read scope. The agent may only read channels in its ` +
329
+ `memory domain (memory/config/domains.ts). Add the channel to the domain's \`channels\` to grant it.`);
330
+ }
331
+ /**
332
+ * The current turn's team id IN GRAPH'S SPELLING — the Microsoft 365 group id every `/teams/{id}/…`
333
+ * URL below takes.
334
+ *
335
+ * 🔴 THE BINDING CARRIES THE OTHER ONE. `bindTeams`'s `target.teamId` is the inbound activity's
336
+ * `channelData.team.id`, which is the BOT CONNECTOR's team id (`19:…@thread.tacv2`) — Graph keeps
337
+ * that spelling as a team's `internalId` and addresses nothing by it. So the binding's id is
338
+ * RESOLVED through the Connector before it reaches a Graph URL (channels/teams-graph-team-id.ts,
339
+ * OPE-1808); the resolution is cached per team, so a read pays at most one extra round trip per
340
+ * process. See the file header for why a DIFFERENT channel in the domain is assumed to share this
341
+ * team (Graph needs both team + channel; the domain allowlist tracks only the latter).
342
+ */
343
+ async function currentGraphTeamId(ctx) {
344
+ const t = bindFrom(ctx).target;
345
+ const botTeamId = typeof t?.teamId === "string" && t.teamId.length > 0 ? t.teamId : undefined;
346
+ const serviceUrl = typeof t?.serviceUrl === "string" && t.serviceUrl.length > 0 ? t.serviceUrl : undefined;
347
+ return await teamsGraphTeamId(botTeamId, serviceUrl);
348
+ }
349
+ function noTeamIdError(what) {
350
+ return new Error(`read: couldn't resolve this Teams channel's team id from the live session — Teams ${what} need an engaged team-channel turn, ` +
351
+ `and the team's Microsoft 365 group id must be resolvable from the Bot Connector.`);
352
+ }
353
+ // --- the OPE-474 non-member author leg ---------------------------------------------------------
354
+ //
355
+ // 🔴 THE REGISTER IS THE WHOLE LEG HERE, AND THAT IS A LIMIT WORTH STATING RATHER THAN PAPERING
356
+ // OVER. Teams CAN refute an author in principle — Entra reports `userType: "guest"` — and the WAKE
357
+ // path already reads it (channels/teams.ts `resolveTeamsUserType`). That probe is not reachable from
358
+ // a read, for two reasons that are both structural:
359
+ // • it is keyed by CONVERSATION (`/v3/conversations/{id}/members/{userId}`) and needs the Bot
360
+ // Connector handle off the inbound activity, which a read tool does not hold;
361
+ // • a read may target a DIFFERENT channel from the turn's own conversation, so the current
362
+ // conversation's roster is not the right question to ask about that page's authors anyway.
363
+ // Adding a Graph `/users/{id}` call would be a NEW privileged permission (`User.Read.All`) for one
364
+ // taint bit, so it is deliberately not invented here. A Teams deployment that wants the author leg
365
+ // on reads arms the author-trust register, exactly as Discord and Notion do.
366
+ //
367
+ // The consequence, stated plainly: on an UNARMED Teams tenant a channel read never marks the turn,
368
+ // even over a guest's message. The fence still rides every read, and the wake leg still refutes the
369
+ // same guest when they SPEAK. Under-marking is the direction this whole plane fails in.
370
+ // --- the adapter ------------------------------------------------------------------------------
371
+ export const teamsReadAdapter = {
372
+ platform: "teams",
373
+ /**
374
+ * OPE-929 — Teams' own spelling of "which room and which thread is this turn in", and the one of
375
+ * the three that no core-side guess could have got right.
376
+ *
377
+ * 🔴 THE BINDING'S CONVERSATION IS NOT THE READ CONTAINER. A Teams CHANNEL activity binds the
378
+ * REPLY CHAIN as its `surface` / `anchorChannelId` (the conversation id carries
379
+ * `;messageid=<root>`), while both Graph read URLs below take the CHANNEL id
380
+ * (`/teams/{team}/channels/{channel}/messages[/{root}/replies]`). So the container is
381
+ * `target.channelId` and the thread root is the `;messageid=` half of the conversation id — the
382
+ * two halves of one value, pulled apart here rather than anywhere else.
383
+ *
384
+ * A personal or group chat carries no channel id and no reply-chain suffix, so it reports no
385
+ * address at all: Graph's chat-message read is delegated-only, and this adapter reads channels.
386
+ * The read then refuses loudly, which is the honest answer for a surface it cannot serve.
387
+ */
388
+ wakeAddress(ctx) {
389
+ const b = bindFrom(ctx);
390
+ const container = typeof b.target?.["channelId"] === "string" ? b.target["channelId"] : undefined;
391
+ if (!container)
392
+ return undefined;
393
+ const conversation = typeof b.target?.["conversationId"] === "string" ? b.target["conversationId"] : undefined;
394
+ const root = conversation ? teamsReplyChainRoot(conversation) : undefined;
395
+ return { container, ...(root ? { threadRoot: root } : {}) };
396
+ },
397
+ async readChannel(ctx, channel, limit, req = liveTeamsGraphRequest) {
398
+ await assertTeamsChannelAllowed(ctx, channel);
399
+ const teamId = await currentGraphTeamId(ctx);
400
+ if (!teamId)
401
+ throw noTeamIdError("channel reads");
402
+ const n = limit ?? TEAMS_CHANNEL_DEFAULT_LIMIT;
403
+ const raw = await fetchGraphPages(req, channelMessagesUrl(teamId, channel), n);
404
+ return teamsLinesFromRaw(raw).slice(-n); // most recent n, oldest-first (matches Slack/Discord)
405
+ },
406
+ async readThread(ctx, channel, threadTs, limit, req = liveTeamsGraphRequest) {
407
+ await assertTeamsChannelAllowed(ctx, channel);
408
+ const teamId = await currentGraphTeamId(ctx);
409
+ if (!teamId)
410
+ throw noTeamIdError("thread reads");
411
+ const n = limit ?? TEAMS_THREAD_DEFAULT_LIMIT;
412
+ const raw = await fetchGraphPages(req, threadRepliesUrl(teamId, channel, threadTs), n);
413
+ return teamsLinesFromRaw(raw).slice(-n);
414
+ },
415
+ /** OPE-474 / OPE-534 — the author-trust register only; see the note above for why the Entra probe
416
+ * is not reachable from a read. No `memberships` leg: an armed Teams tenant matches on principal
417
+ * keys (`teams/<entraObjectId>`), which is what a Teams declaration lists. It answers with the
418
+ * refuted AUTHOR IDS, because core reads the one answer twice: the turn mark and the `withhold`
419
+ * body replacement. */
420
+ async refutedAuthorIds(ctx, lines) {
421
+ return await refutedReadAuthorIds(lines, resolvePartition(ctx).workspaceKey);
422
+ },
423
+ };
424
+ //# sourceMappingURL=teams-read.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"teams-read.js","sourceRoot":"","sources":["../../../agent/channels/teams-read.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,54 @@
1
+ import { type TeamsHandle } from "eve/channels/teams";
2
+ /** The subset of eve's `TeamsApiResponse` the probe reads. */
3
+ export interface TeamsRosterResponse {
4
+ readonly ok: boolean;
5
+ readonly body: unknown;
6
+ }
7
+ /** One GET against the Bot Connector, addressed at an already-resolved serviceUrl. Injected so the
8
+ * probe's caching, its fail-open branches and its path are testable with no network. */
9
+ export type TeamsRosterProbe = (path: string) => Promise<TeamsRosterResponse>;
10
+ /** The roster member path for one (conversation, user) — the ONE place it is built. */
11
+ export declare function teamsRosterMemberPath(conversationId: string, userId: string): string;
12
+ /**
13
+ * The probe a NATIVE turn has: `teamsChannel` hands the handler a handle that already holds both the
14
+ * credential and the conversation address, so its `request` escape hatch is the whole transport.
15
+ */
16
+ export declare function teamsHandleRosterProbe(teams: Pick<TeamsHandle, "request">): TeamsRosterProbe;
17
+ /**
18
+ * The probe the FRONT has. The front route is a plain HTTP handler with no channel handle, so it
19
+ * addresses the connector by the activity's own `serviceUrl` and mints the token through eve's
20
+ * `callTeamsConnectorApi` — the SAME client-credentials mint the native handle uses, not a second
21
+ * bespoke credential path.
22
+ *
23
+ * 🔴 THE MINT MUST BE TENANT-SCOPED. eve's `resolveTeamsAccessToken` reads `MICROSOFT_TENANT_ID` /
24
+ * `TEAMS_TENANT_ID` and falls back to the `botframework.com` placeholder tenant only when neither is
25
+ * set. A single-tenant app registration refuses the placeholder with AADSTS700016, so a deployment
26
+ * whose bot app is single-tenant must set the tenant id. The tenant is never hardcoded here.
27
+ *
28
+ * `undefined` when the activity carries no serviceUrl — there is nothing to address, so there is no
29
+ * verdict to resolve.
30
+ */
31
+ export declare function teamsServiceUrlRosterProbe(serviceUrl: string | undefined): TeamsRosterProbe | undefined;
32
+ /**
33
+ * The acting user's tenant membership type, cached and fail-open. `undefined` means NEVER DETERMINED —
34
+ * the caller then leaves `userType` unset, and every downstream verdict is byte-identical to a
35
+ * deployment with no probe at all.
36
+ *
37
+ * 🔴 OPE-572 — THE CACHE KEY AND THE WIRE CALL USE DIFFERENT IDS, ON PURPOSE.
38
+ * - THE KEY strips the reply-chain suffix. On a CHANNEL activity `conversation.id` is the
39
+ * REPLY-CHAIN id, so keying on it verbatim made a busy channel pay one connector call per
40
+ * (THREAD, user) per TTL instead of per (CHANNEL, user). The verdict is a property of the
41
+ * TENANT — a user is a guest of the tenant, never of one reply chain — so the coarser key
42
+ * answers the same question with fewer calls. Two reply chains collapsing onto one channel key
43
+ * is the POINT, not a collision.
44
+ * - THE WIRE CALL keeps the id EXACTLY as the caller passed it. That is the shape the 2026-08-15
45
+ * live-tenant probe measured. Whether the connector ALSO accepts a thread-qualified id on
46
+ * `/v3/conversations/{id}/members/{userId}` is NOT established, and one live GET against a real
47
+ * threaded channel reply is what settles it. Until then, changing the wire shape would trade a
48
+ * measured behaviour for an unmeasured one. A refusal fails open like any other non-2xx.
49
+ * scripts/teams-roster-probe-test.ts pins BOTH halves — the shared key and the unstripped path.
50
+ */
51
+ export declare function resolveTeamsUserType(probe: TeamsRosterProbe | undefined, conversationId: string | undefined, userId: string | undefined): Promise<"guest" | "member" | undefined>;
52
+ /** Test seam: drop the cached roster verdicts (a fresh test must not inherit another's). */
53
+ export declare function _resetTeamsUserTypeCacheForTest(): void;
54
+ //# sourceMappingURL=teams-roster.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"teams-roster.d.ts","sourceRoot":"","sources":["../../../agent/channels/teams-roster.ts"],"names":[],"mappings":"AA8BA,OAAO,EAAyB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAK7E,8DAA8D;AAC9D,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED;yFACyF;AACzF,MAAM,MAAM,gBAAgB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;AAE9E,uFAAuF;AACvF,wBAAgB,qBAAqB,CAAC,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEpF;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,GAAG,gBAAgB,CAM5F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,gBAAgB,GAAG,SAAS,CAGvG;AAcD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,gBAAgB,GAAG,SAAS,EACnC,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,MAAM,EAAE,MAAM,GAAG,SAAS,GACzB,OAAO,CAAC,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC,CAczC;AAED,4FAA4F;AAC5F,wBAAgB,+BAA+B,IAAI,IAAI,CAEtD"}
@@ -0,0 +1,121 @@
1
+ // THE TEAMS ROSTER PROBE (OPE-60 mechanism, OPE-493 reach) — "is the acting user a tenant GUEST?".
2
+ //
3
+ // The inbound Bot Framework activity carries NO guest bit (`from.role` is only "user"/"bot"), so the
4
+ // answer comes from the Bot Connector ROSTER: `GET {serviceUrl}/v3/conversations/{id}/members/{userId}`
5
+ // returns a member record whose `userRole` is "guest" for a B2B guest and "user" for a tenant member.
6
+ // MEASURED against a live tenant on 2026-08-15, so this is a real discriminator, not a documented one.
7
+ // It needs no Microsoft Graph app registration and no new privileged permission — the bot's own
8
+ // client-credentials token already carries it.
9
+ //
10
+ // WHY THIS IS ITS OWN MODULE. Two live paths need the probe and neither can host it:
11
+ // - `teams-identity.ts` imports no eve types on purpose (its header states it), which keeps the
12
+ // security-critical mapping trivially unit-testable. The probe needs eve's connector client, so
13
+ // putting it there would take that property away.
14
+ // - `teams.ts` (the native channel) already imports `teams-front.ts` for `frontTeamsChannel`, so
15
+ // the front cannot import back from `teams.ts` without a cycle.
16
+ // This module imports `teams-identity.ts` and eve, and both call sites import this module. One
17
+ // direction, no cycle, and `teamsUserTypeFromMember` stays the ONE reader of Teams' guest vocabulary.
18
+ //
19
+ // 🔴 THE PATH NEEDS ITS LEADING SLASH. eve composes the connector URL as
20
+ // `trimTrailingSlash(serviceUrl) + path` (node_modules/eve/dist/src/public/channels/teams/api.js), and
21
+ // eve's own callers pass "/v3/…". A path written without the slash composes
22
+ // `https://smba.trafficmanager.net/emeav3/conversations/…` — a malformed URL that answers non-2xx, so
23
+ // the probe fails open and reports NO verdict on every call. `teamsRosterMemberPath` is the ONE place
24
+ // the path is built, and its leading slash is pinned by a test.
25
+ //
26
+ // FAIL-OPEN EVERYWHERE. No serviceUrl, no conversation, no user, an HTTP error, a transport throw or
27
+ // an unrecognised body all yield `undefined` — "never determined". `teamsTrust` then leaves the
28
+ // existing channel/vouch verdict intact, which is the pre-probe behaviour. The probe only ever
29
+ // DEMOTES; it can never raise trust.
30
+ import { callTeamsConnectorApi } from "eve/channels/teams";
31
+ import { dmTrustTtlMs } from "@open-everywhere/core/agent/adapter-api.js";
32
+ import { teamsUserTypeFromMember } from "./teams-identity.js";
33
+ import { teamsChannelGrainConversationId } from "./teams-conversation-ref.js";
34
+ /** The roster member path for one (conversation, user) — the ONE place it is built. */
35
+ export function teamsRosterMemberPath(conversationId, userId) {
36
+ return `/v3/conversations/${encodeURIComponent(conversationId)}/members/${encodeURIComponent(userId)}`;
37
+ }
38
+ /**
39
+ * The probe a NATIVE turn has: `teamsChannel` hands the handler a handle that already holds both the
40
+ * credential and the conversation address, so its `request` escape hatch is the whole transport.
41
+ */
42
+ export function teamsHandleRosterProbe(teams) {
43
+ // The body MUST be absent, not `{}`: eve's request forwards any defined body into the fetch init
44
+ // (`body: "{}"`), and fetch throws `Request with GET/HEAD method cannot have body` — a throw the
45
+ // resolver's fail-open catch then swallows, leaving this probe permanently inert. The handle type
46
+ // requires a body argument, so the absence is spelled as a cast.
47
+ return (path) => teams.request(path, undefined, { method: "GET" });
48
+ }
49
+ /**
50
+ * The probe the FRONT has. The front route is a plain HTTP handler with no channel handle, so it
51
+ * addresses the connector by the activity's own `serviceUrl` and mints the token through eve's
52
+ * `callTeamsConnectorApi` — the SAME client-credentials mint the native handle uses, not a second
53
+ * bespoke credential path.
54
+ *
55
+ * 🔴 THE MINT MUST BE TENANT-SCOPED. eve's `resolveTeamsAccessToken` reads `MICROSOFT_TENANT_ID` /
56
+ * `TEAMS_TENANT_ID` and falls back to the `botframework.com` placeholder tenant only when neither is
57
+ * set. A single-tenant app registration refuses the placeholder with AADSTS700016, so a deployment
58
+ * whose bot app is single-tenant must set the tenant id. The tenant is never hardcoded here.
59
+ *
60
+ * `undefined` when the activity carries no serviceUrl — there is nothing to address, so there is no
61
+ * verdict to resolve.
62
+ */
63
+ export function teamsServiceUrlRosterProbe(serviceUrl) {
64
+ if (!serviceUrl)
65
+ return undefined;
66
+ return (path) => callTeamsConnectorApi({ serviceUrl, path, method: "GET" });
67
+ }
68
+ // Cached per (CHANNEL, user) because tenant membership changes rarely, on the SAME TTL knob the
69
+ // Slack DM-partner cache uses (`identity.dmTrustTtlMs`). Only a POSITIVE verdict is cached: caching
70
+ // "no verdict" would pin a transport blip for the whole TTL.
71
+ const teamsUserTypeCache = new Map();
72
+ // The channel-grain strip lives in channels/teams-conversation-ref.ts, the module whose subject is
73
+ // Teams conversation addressing, and that header states why it is spelled in this repo at all
74
+ // (eve does not re-export its own copy).
75
+ //
76
+ // 🔴 IN THIS FILE IT IS A CACHE KEY AND NOTHING ELSE. It must never reach the connector — see
77
+ // {@link resolveTeamsUserType}.
78
+ /**
79
+ * The acting user's tenant membership type, cached and fail-open. `undefined` means NEVER DETERMINED —
80
+ * the caller then leaves `userType` unset, and every downstream verdict is byte-identical to a
81
+ * deployment with no probe at all.
82
+ *
83
+ * 🔴 OPE-572 — THE CACHE KEY AND THE WIRE CALL USE DIFFERENT IDS, ON PURPOSE.
84
+ * - THE KEY strips the reply-chain suffix. On a CHANNEL activity `conversation.id` is the
85
+ * REPLY-CHAIN id, so keying on it verbatim made a busy channel pay one connector call per
86
+ * (THREAD, user) per TTL instead of per (CHANNEL, user). The verdict is a property of the
87
+ * TENANT — a user is a guest of the tenant, never of one reply chain — so the coarser key
88
+ * answers the same question with fewer calls. Two reply chains collapsing onto one channel key
89
+ * is the POINT, not a collision.
90
+ * - THE WIRE CALL keeps the id EXACTLY as the caller passed it. That is the shape the 2026-08-15
91
+ * live-tenant probe measured. Whether the connector ALSO accepts a thread-qualified id on
92
+ * `/v3/conversations/{id}/members/{userId}` is NOT established, and one live GET against a real
93
+ * threaded channel reply is what settles it. Until then, changing the wire shape would trade a
94
+ * measured behaviour for an unmeasured one. A refusal fails open like any other non-2xx.
95
+ * scripts/teams-roster-probe-test.ts pins BOTH halves — the shared key and the unstripped path.
96
+ */
97
+ export async function resolveTeamsUserType(probe, conversationId, userId) {
98
+ if (!probe || !conversationId || !userId)
99
+ return undefined; // nothing to address / to key on → no verdict
100
+ const key = `${teamsChannelGrainConversationId(conversationId)}:${userId}`;
101
+ const hit = teamsUserTypeCache.get(key);
102
+ if (hit && Date.now() - hit.at < dmTrustTtlMs())
103
+ return hit.userType;
104
+ try {
105
+ const res = await probe(teamsRosterMemberPath(conversationId, userId));
106
+ if (!res.ok)
107
+ return undefined; // 4xx/5xx (e.g. member not resolvable) → no verdict
108
+ const userType = teamsUserTypeFromMember(res.body);
109
+ if (userType)
110
+ teamsUserTypeCache.set(key, { userType, at: Date.now() });
111
+ return userType;
112
+ }
113
+ catch {
114
+ return undefined; // transport error → no verdict (mirrors the Slack users.info catch)
115
+ }
116
+ }
117
+ /** Test seam: drop the cached roster verdicts (a fresh test must not inherit another's). */
118
+ export function _resetTeamsUserTypeCacheForTest() {
119
+ teamsUserTypeCache.clear();
120
+ }
121
+ //# sourceMappingURL=teams-roster.js.map