@patimweb/pi-teams 1.0.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -0
  3. package/index.ts +1 -0
  4. package/package.json +61 -0
  5. package/prompts/teams-catch-up.md +24 -0
  6. package/prompts/teams-channel-digest.md +23 -0
  7. package/prompts/teams-doctor.md +21 -0
  8. package/prompts/teams-meeting-prep.md +22 -0
  9. package/prompts/teams-standup.md +21 -0
  10. package/prompts/teams-triage.md +22 -0
  11. package/skills/teams-collaboration/SKILL.md +143 -0
  12. package/src/auth/client-credentials.ts +72 -0
  13. package/src/auth/device-code.ts +276 -0
  14. package/src/auth/index.ts +105 -0
  15. package/src/auth/jwt.ts +50 -0
  16. package/src/auth/token-store.ts +156 -0
  17. package/src/config/index.ts +685 -0
  18. package/src/config/scope.ts +272 -0
  19. package/src/extension/index.ts +336 -0
  20. package/src/graph/calendar.ts +165 -0
  21. package/src/graph/chats.ts +159 -0
  22. package/src/graph/client.ts +234 -0
  23. package/src/graph/mappers.ts +268 -0
  24. package/src/graph/me.ts +80 -0
  25. package/src/graph/messages.ts +383 -0
  26. package/src/graph/presence.ts +160 -0
  27. package/src/graph/search.ts +111 -0
  28. package/src/graph/teams.ts +271 -0
  29. package/src/safety/audit.ts +57 -0
  30. package/src/safety/index.ts +186 -0
  31. package/src/status.ts +90 -0
  32. package/src/tools/resolve.ts +138 -0
  33. package/src/tools/shared.ts +124 -0
  34. package/src/tools/teams-accounts.ts +169 -0
  35. package/src/tools/teams-create-channel.ts +89 -0
  36. package/src/tools/teams-create-chat.ts +112 -0
  37. package/src/tools/teams-delete-message.ts +114 -0
  38. package/src/tools/teams-doctor.ts +182 -0
  39. package/src/tools/teams-find-user.ts +64 -0
  40. package/src/tools/teams-inbox.ts +142 -0
  41. package/src/tools/teams-list-channels.ts +62 -0
  42. package/src/tools/teams-list-chats.ts +74 -0
  43. package/src/tools/teams-list-files.ts +54 -0
  44. package/src/tools/teams-list-members.ts +72 -0
  45. package/src/tools/teams-list-teams.ts +65 -0
  46. package/src/tools/teams-login.ts +199 -0
  47. package/src/tools/teams-meetings.ts +342 -0
  48. package/src/tools/teams-permissions.ts +169 -0
  49. package/src/tools/teams-presence.ts +216 -0
  50. package/src/tools/teams-react.ts +109 -0
  51. package/src/tools/teams-read-channel.ts +75 -0
  52. package/src/tools/teams-read-chat.ts +73 -0
  53. package/src/tools/teams-read-thread.ts +88 -0
  54. package/src/tools/teams-search-messages.ts +71 -0
  55. package/src/tools/teams-send-channel-message.ts +250 -0
  56. package/src/tools/teams-send-chat-message.ts +152 -0
  57. package/src/tools/teams-setup.ts +245 -0
  58. package/src/tools/teams-status.ts +73 -0
  59. package/src/tools/tool-names.ts +30 -0
  60. package/src/types.ts +192 -0
  61. package/src/utils/errors.ts +133 -0
  62. package/src/utils/formatting.ts +356 -0
  63. package/tsconfig.json +16 -0
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: teams-collaboration
3
+ description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to start a chat; to react to a message; to check or set their Teams status; to see who is available; or to schedule, change or cancel a Teams meeting. Also covers multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
4
+ ---
5
+
6
+ # Microsoft Teams
7
+
8
+ pi acts **as the signed-in user**. A message it sends is indistinguishable from
9
+ one the user typed: same name, same avatar, same thread. Treat every write as
10
+ something the user is personally saying.
11
+
12
+ ## Before anything else
13
+
14
+ Run `teams_status` if you are unsure which account is active or whether pi is
15
+ signed in. If it reports "not signed in", run `teams_login` — it prints a short
16
+ code and a URL that the user must open themselves.
17
+
18
+ If a tool fails with an authentication, permission or configuration error, run
19
+ `teams_doctor` and report what it says. Do not retry the failing call.
20
+
21
+ ## Multiple accounts and companies
22
+
23
+ Every tool accepts `account` and `tenant`:
24
+
25
+ - `account` — a configured Teams identity (e.g. `work`, `private`)
26
+ - `tenant` — a tenant beneath that account: a customer, a guest tenant, a
27
+ subsidiary
28
+
29
+ Omit both to use the default. Use `teams_accounts` to list what is configured.
30
+ When the user says "in the customer's Teams" or names a company, pass the
31
+ matching `tenant` rather than assuming the default is right.
32
+
33
+ ## What pi may do
34
+
35
+ Three independent gates apply:
36
+
37
+ 1. **Auth mode** — an app-only account cannot post as a person at all.
38
+ 2. **Safety level** — `readonly` blocks writes, `confirm` asks the user first,
39
+ `open` proceeds.
40
+ 3. **Scope rules** — allow/deny lists per category (teams, channels, chats,
41
+ people) and per mode (read, write).
42
+
43
+ When a call is refused with "Blocked by configuration", that is the user's own
44
+ rule set. Show them the rule, do not work around it, and do not try a different
45
+ tool to achieve the same thing. `teams_permissions` with `action: "test"`
46
+ checks a target without touching it.
47
+
48
+ ## Tools
49
+
50
+ ### Session
51
+
52
+ | Tool | Use when |
53
+ |------|----------|
54
+ | `teams_status` | Which account is active, who pi acts as, what it may do |
55
+ | `teams_login` | Not signed in, or adding an account/tenant |
56
+ | `teams_logout` | Removing a saved session |
57
+ | `teams_accounts` | Listing accounts, switching the default, changing a safety level |
58
+ | `teams_permissions` | Showing, testing or changing the allow/deny rules |
59
+ | `teams_doctor` | Anything failed and you need to know why |
60
+ | `teams_setup` | Adding a new account or tenant to the configuration |
61
+
62
+ ### Reading
63
+
64
+ | Tool | Use when |
65
+ |------|----------|
66
+ | `teams_inbox` | "What did I miss?", "anything new in Teams?" |
67
+ | `teams_list_chats` | Finding a chat, seeing recent conversations |
68
+ | `teams_read_chat` | Reading one conversation |
69
+ | `teams_list_teams` / `teams_list_channels` | Discovering team and channel names |
70
+ | `teams_read_channel` | The posts in a channel (openers only) |
71
+ | `teams_read_thread` | One thread, opener plus every reply |
72
+ | `teams_search_messages` | Finding something said, when the location is unknown |
73
+ | `teams_list_members` | Who is in a team or channel |
74
+ | `teams_find_user` | Resolving a first name to a real person |
75
+ | `teams_list_files` | Files shared in a channel |
76
+ | `teams_get_presence` | Is someone available right now |
77
+ | `teams_list_meetings` / `teams_get_meeting` | Calendar and meeting details |
78
+
79
+ ### Writing
80
+
81
+ | Tool | Use when |
82
+ |------|----------|
83
+ | `teams_send_chat_message` | Sending a chat message |
84
+ | `teams_create_chat` | Starting a new conversation |
85
+ | `teams_send_channel_message` | Posting a new channel message |
86
+ | `teams_reply_channel_message` | Answering inside an existing thread |
87
+ | `teams_react` | Adding or removing an emoji reaction |
88
+ | `teams_delete_message` | Taking back something the user sent |
89
+ | `teams_create_channel` | Adding a channel to a team |
90
+ | `teams_set_presence` / `teams_set_status_message` | Changing the user's status |
91
+ | `teams_create_meeting` / `teams_update_meeting` / `teams_cancel_meeting` | Scheduling work |
92
+
93
+ ## How to write as the user
94
+
95
+ - Write in **their** voice. No "As an AI…", no signature, no note that a model
96
+ composed it, unless they ask for one.
97
+ - Match the register of the conversation you just read. A channel post and a
98
+ quick reply to a colleague are not the same thing.
99
+ - Show the exact text and the destination before sending whenever the request
100
+ leaves any room for interpretation. Once it is sent, it is sent.
101
+ - Never invent facts, commitments, dates or agreement on the user's behalf. If
102
+ a detail is missing, ask.
103
+ - Prefer `teams_reply_channel_message` over a new post when answering something
104
+ — starting a second thread fragments the conversation.
105
+
106
+ ## Channel addressing
107
+
108
+ Channels are addressed as `Team/Channel` or via separate `team` and `channel`
109
+ parameters. Names are resolved case-insensitively; an ambiguous name is
110
+ reported rather than guessed, and the answer is to pass the ID.
111
+
112
+ ```yaml
113
+ teams_send_channel_message:
114
+ channel: "Engineering/General"
115
+ body: "Deployment finished, release notes are in the wiki."
116
+ ```
117
+
118
+ ## Mentions
119
+
120
+ Pass `mentions` with names, UPNs or e-mail addresses, and write `@Display Name`
121
+ in the body where the mention belongs:
122
+
123
+ ```yaml
124
+ teams_send_chat_message:
125
+ chat: "Project Alpha"
126
+ body: "@Anna Schmidt could you take a look before the standup?"
127
+ mentions: ["anna@contoso.com"]
128
+ ```
129
+
130
+ Resolve names with `teams_find_user` first when only a first name is known.
131
+
132
+ ## Threads
133
+
134
+ Channel conversations are threaded. `teams_read_channel` returns the openers
135
+ with their IDs; `teams_read_thread` returns one thread in full. To summarize a
136
+ channel properly, read the openers, then the threads that matter — do not
137
+ summarize from openers alone.
138
+
139
+ ## Reading before writing
140
+
141
+ Before replying anywhere, read the recent messages first. It prevents
142
+ answering a question that was already answered, and it is what tells you the
143
+ tone to match.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Client credentials grant — app-only access, no user involved.
3
+ *
4
+ * Useful for unattended jobs (a nightly digest, a scheduled report) against
5
+ * directory and presence data. It is deliberately *not* a way to post as
6
+ * somebody: Microsoft Graph only accepts app-only writes to chats and channels
7
+ * for migration scenarios (`Teamwork.Migrate.All`), so this extension refuses
8
+ * message sends on an app-only token instead of letting them fail deep inside
9
+ * a Graph call. See ../safety/index.ts.
10
+ */
11
+
12
+ import type { TeamsConnection } from "../config/index.ts";
13
+ import { AuthError, tokenUrl } from "./device-code.ts";
14
+ import type { CachedToken } from "./token-store.ts";
15
+
16
+ interface TokenResponse {
17
+ access_token: string;
18
+ expires_in: number;
19
+ scope?: string;
20
+ token_type: string;
21
+ error?: string;
22
+ error_description?: string;
23
+ }
24
+
25
+ /**
26
+ * Acquire an app-only token.
27
+ *
28
+ * @throws {AuthError} when the secret is missing, expired, or the app has no
29
+ * admin-consented application permissions.
30
+ */
31
+ export async function acquireAppToken(
32
+ conn: TeamsConnection,
33
+ signal?: AbortSignal,
34
+ ): Promise<CachedToken> {
35
+ if (!conn.clientSecret) {
36
+ throw new AuthError(
37
+ "missing_client_secret",
38
+ `Account "${conn.account}" uses client-credentials but no clientSecret is configured.`,
39
+ );
40
+ }
41
+
42
+ const body = new URLSearchParams({
43
+ grant_type: "client_credentials",
44
+ client_id: conn.clientId,
45
+ client_secret: conn.clientSecret,
46
+ scope: conn.scopes.length === 1 ? conn.scopes[0]! : "https://graph.microsoft.com/.default",
47
+ });
48
+
49
+ const response = await fetch(tokenUrl(conn.authorityHost, conn.tenantId), {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
52
+ body: body.toString(),
53
+ signal,
54
+ });
55
+
56
+ const json = (await response.json()) as TokenResponse;
57
+
58
+ if (!response.ok || !json.access_token) {
59
+ throw new AuthError(
60
+ json.error ?? `http_${response.status}`,
61
+ json.error_description ?? `Client credentials request failed with HTTP ${response.status}`,
62
+ );
63
+ }
64
+
65
+ return {
66
+ accessToken: json.access_token,
67
+ expiresAt: Date.now() + Math.max(0, json.expires_in - 60) * 1000,
68
+ scopes: json.scope ? json.scope.split(" ") : [],
69
+ authMode: "client-credentials",
70
+ updatedAt: new Date().toISOString(),
71
+ };
72
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * OAuth 2.0 device authorization grant — the flow that makes pi *you*.
3
+ *
4
+ * The user opens a URL, types a short code, and signs in with their own
5
+ * credentials and MFA. Everything pi does afterwards is attributed to that
6
+ * person in Teams: messages come from them, and pi sees exactly what they see —
7
+ * no more.
8
+ *
9
+ * Implemented against the token endpoint directly rather than through MSAL:
10
+ * the extension ships with no build step, and one fetch-based module is easier
11
+ * to audit than a dependency tree.
12
+ */
13
+
14
+ import type { TeamsConnection } from "../config/index.ts";
15
+ import { decodeIdToken } from "./jwt.ts";
16
+ import type { CachedToken } from "./token-store.ts";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** What the user needs in order to complete the sign-in. */
23
+ export interface DeviceCodeChallenge {
24
+ deviceCode: string;
25
+ userCode: string;
26
+ verificationUri: string;
27
+ /** Message Microsoft suggests showing to the user */
28
+ message: string;
29
+ /** Epoch ms after which the code is dead */
30
+ expiresAt: number;
31
+ /** Seconds between polls, as requested by the server */
32
+ intervalSeconds: number;
33
+ }
34
+
35
+ interface DeviceCodeResponse {
36
+ device_code: string;
37
+ user_code: string;
38
+ verification_uri: string;
39
+ expires_in: number;
40
+ interval: number;
41
+ message: string;
42
+ }
43
+
44
+ interface TokenResponse {
45
+ access_token: string;
46
+ refresh_token?: string;
47
+ id_token?: string;
48
+ expires_in: number;
49
+ scope?: string;
50
+ token_type: string;
51
+ }
52
+
53
+ interface ErrorResponse {
54
+ error: string;
55
+ error_description?: string;
56
+ }
57
+
58
+ /** A token endpoint error that callers may want to branch on. */
59
+ export class AuthError extends Error {
60
+ readonly code: string;
61
+ constructor(code: string, message: string) {
62
+ super(message);
63
+ this.name = "AuthError";
64
+ this.code = code;
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Endpoints
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function deviceCodeUrl(conn: TeamsConnection): string {
73
+ return `${conn.authorityHost}/${encodeURIComponent(conn.tenantId)}/oauth2/v2.0/devicecode`;
74
+ }
75
+
76
+ export function tokenUrl(authorityHost: string, tenantId: string): string {
77
+ return `${authorityHost}/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Step 1 — request a code
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * Ask Microsoft for a device code.
86
+ *
87
+ * @throws {AuthError} when the app registration rejects the request — most
88
+ * often because "Allow public client flows" is still off.
89
+ */
90
+ export async function requestDeviceCode(
91
+ conn: TeamsConnection,
92
+ signal?: AbortSignal,
93
+ ): Promise<DeviceCodeChallenge> {
94
+ const body = new URLSearchParams({
95
+ client_id: conn.clientId,
96
+ scope: conn.scopes.join(" "),
97
+ });
98
+
99
+ const response = await fetch(deviceCodeUrl(conn), {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
102
+ body: body.toString(),
103
+ signal,
104
+ });
105
+
106
+ const json = (await response.json()) as DeviceCodeResponse & Partial<ErrorResponse>;
107
+
108
+ if (!response.ok || json.error) {
109
+ throw new AuthError(
110
+ json.error ?? `http_${response.status}`,
111
+ json.error_description ?? `Device code request failed with HTTP ${response.status}`,
112
+ );
113
+ }
114
+
115
+ return {
116
+ deviceCode: json.device_code,
117
+ userCode: json.user_code,
118
+ verificationUri: json.verification_uri,
119
+ message: json.message,
120
+ expiresAt: Date.now() + json.expires_in * 1000,
121
+ intervalSeconds: json.interval || 5,
122
+ };
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Step 2 — poll until the user is done
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
130
+ return new Promise((resolve, reject) => {
131
+ const timer = setTimeout(resolve, ms);
132
+ signal?.addEventListener(
133
+ "abort",
134
+ () => {
135
+ clearTimeout(timer);
136
+ reject(new AuthError("aborted", "Sign-in cancelled."));
137
+ },
138
+ { once: true },
139
+ );
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Poll the token endpoint until the user finishes signing in.
145
+ *
146
+ * `onPending` is called on every poll so a tool can keep the UI alive during
147
+ * what may be a minute or two of waiting.
148
+ */
149
+ export async function pollForToken(
150
+ conn: TeamsConnection,
151
+ challenge: DeviceCodeChallenge,
152
+ options: { signal?: AbortSignal; onPending?: (secondsLeft: number) => void } = {},
153
+ ): Promise<CachedToken> {
154
+ const { signal, onPending } = options;
155
+ let intervalMs = challenge.intervalSeconds * 1000;
156
+
157
+ for (;;) {
158
+ if (Date.now() > challenge.expiresAt) {
159
+ throw new AuthError("expired_token", "The device code expired before sign-in completed.");
160
+ }
161
+
162
+ await sleep(intervalMs, signal);
163
+ onPending?.(Math.max(0, Math.round((challenge.expiresAt - Date.now()) / 1000)));
164
+
165
+ const body = new URLSearchParams({
166
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
167
+ client_id: conn.clientId,
168
+ device_code: challenge.deviceCode,
169
+ });
170
+
171
+ const response = await fetch(tokenUrl(conn.authorityHost, conn.tenantId), {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
174
+ body: body.toString(),
175
+ signal,
176
+ });
177
+
178
+ const json = (await response.json()) as TokenResponse & Partial<ErrorResponse>;
179
+
180
+ if (response.ok && json.access_token) {
181
+ return toCachedToken(json, "device-code");
182
+ }
183
+
184
+ switch (json.error) {
185
+ case "authorization_pending":
186
+ continue;
187
+ case "slow_down":
188
+ // The server asks for more breathing room; honour it permanently.
189
+ intervalMs += 5000;
190
+ continue;
191
+ case "authorization_declined":
192
+ throw new AuthError("authorization_declined", "Sign-in was declined in the browser.");
193
+ case "expired_token":
194
+ throw new AuthError("expired_token", "The device code expired before sign-in completed.");
195
+ case "bad_verification_code":
196
+ throw new AuthError("bad_verification_code", "The device code was rejected. Start over.");
197
+ default:
198
+ throw new AuthError(
199
+ json.error ?? `http_${response.status}`,
200
+ json.error_description ?? `Token request failed with HTTP ${response.status}`,
201
+ );
202
+ }
203
+ }
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Step 3 — refresh
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Exchange a refresh token for a fresh access token.
212
+ *
213
+ * @throws {AuthError} with code `invalid_grant` when the refresh token is dead
214
+ * (password change, revoked session, conditional access) — the caller should
215
+ * then ask the user to run `teams_login` again.
216
+ */
217
+ export async function refreshAccessToken(
218
+ conn: TeamsConnection,
219
+ refreshToken: string,
220
+ signal?: AbortSignal,
221
+ ): Promise<CachedToken> {
222
+ const body = new URLSearchParams({
223
+ grant_type: "refresh_token",
224
+ client_id: conn.clientId,
225
+ refresh_token: refreshToken,
226
+ scope: conn.scopes.join(" "),
227
+ });
228
+
229
+ const response = await fetch(tokenUrl(conn.authorityHost, conn.tenantId), {
230
+ method: "POST",
231
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
232
+ body: body.toString(),
233
+ signal,
234
+ });
235
+
236
+ const json = (await response.json()) as TokenResponse & Partial<ErrorResponse>;
237
+
238
+ if (!response.ok || !json.access_token) {
239
+ throw new AuthError(
240
+ json.error ?? `http_${response.status}`,
241
+ json.error_description ?? `Token refresh failed with HTTP ${response.status}`,
242
+ );
243
+ }
244
+
245
+ // Entra ID rotates refresh tokens; keep the old one only if none came back.
246
+ return { ...toCachedToken(json, "device-code"), refreshToken: json.refresh_token ?? refreshToken };
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Shaping
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function toCachedToken(
254
+ json: TokenResponse,
255
+ authMode: "device-code" | "client-credentials",
256
+ ): CachedToken {
257
+ const claims = json.id_token ? decodeIdToken(json.id_token) : undefined;
258
+ return {
259
+ accessToken: json.access_token,
260
+ refreshToken: json.refresh_token,
261
+ // Shave 60s off the stated lifetime so clock skew never hands Graph a
262
+ // token that expired a second ago.
263
+ expiresAt: Date.now() + Math.max(0, json.expires_in - 60) * 1000,
264
+ scopes: json.scope ? json.scope.split(" ") : [],
265
+ authMode,
266
+ user: claims
267
+ ? {
268
+ id: claims.oid,
269
+ displayName: claims.name,
270
+ upn: claims.preferred_username ?? claims.upn,
271
+ tenantId: claims.tid,
272
+ }
273
+ : undefined,
274
+ updatedAt: new Date().toISOString(),
275
+ };
276
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Auth resolver — hands the Graph layer a valid bearer token.
3
+ *
4
+ * Order of preference for a delegated connection:
5
+ * 1. a cached access token that is still fresh
6
+ * 2. a refresh of the cached refresh token
7
+ * 3. nothing — the caller must run `teams_login`
8
+ *
9
+ * App-only connections skip the cache-refresh dance; a new token is cheap and
10
+ * there is no user to interrupt.
11
+ */
12
+
13
+ import type { TeamsConnection } from "../config/index.ts";
14
+ import { AuthError, refreshAccessToken } from "./device-code.ts";
15
+ import { acquireAppToken } from "./client-credentials.ts";
16
+ import { getToken, saveToken, isFresh, type CachedToken } from "./token-store.ts";
17
+
18
+ /** Raised when there is no usable token and only the user can fix it. */
19
+ export class NotSignedInError extends Error {
20
+ readonly account: string;
21
+ readonly tenant: string;
22
+ constructor(account: string, tenant: string, detail?: string) {
23
+ super(
24
+ `Not signed in to Teams account "${account}"${tenant !== account ? ` (tenant ${tenant})` : ""}. ` +
25
+ `Run the teams_login tool or the /teams-login command.${detail ? ` (${detail})` : ""}`,
26
+ );
27
+ this.name = "NotSignedInError";
28
+ this.account = account;
29
+ this.tenant = tenant;
30
+ }
31
+ }
32
+
33
+ /** In-process cache so a burst of Graph calls does not re-read the token file. */
34
+ const memoryCache = new Map<string, CachedToken>();
35
+
36
+ function memKey(conn: TeamsConnection): string {
37
+ return `${conn.account.toLowerCase()}::${conn.tenantId.toLowerCase()}`;
38
+ }
39
+
40
+ /**
41
+ * Get a bearer token for this connection, refreshing it when needed.
42
+ *
43
+ * @throws {NotSignedInError} when a delegated sign-in is required
44
+ * @throws {AuthError} when the identity provider refuses
45
+ */
46
+ export async function getAccessToken(
47
+ conn: TeamsConnection,
48
+ signal?: AbortSignal,
49
+ ): Promise<CachedToken> {
50
+ const key = memKey(conn);
51
+
52
+ const inMemory = memoryCache.get(key);
53
+ if (isFresh(inMemory)) return inMemory!;
54
+
55
+ if (conn.authMode === "client-credentials") {
56
+ const token = await acquireAppToken(conn, signal);
57
+ memoryCache.set(key, token);
58
+ saveToken(conn.account, conn.tenantId, token);
59
+ return token;
60
+ }
61
+
62
+ const cached = getToken(conn.account, conn.tenantId);
63
+
64
+ if (isFresh(cached)) {
65
+ memoryCache.set(key, cached!);
66
+ return cached!;
67
+ }
68
+
69
+ if (cached?.refreshToken) {
70
+ try {
71
+ const refreshed = await refreshAccessToken(conn, cached.refreshToken, signal);
72
+ // Keep the identity we already know; a refresh response carries no id_token
73
+ // unless openid was requested again.
74
+ const merged: CachedToken = { ...refreshed, user: refreshed.user ?? cached.user };
75
+ memoryCache.set(key, merged);
76
+ saveToken(conn.account, conn.tenantId, merged);
77
+ return merged;
78
+ } catch (err) {
79
+ if (err instanceof AuthError && err.code === "invalid_grant") {
80
+ throw new NotSignedInError(conn.account, conn.tenant, "the saved sign-in expired or was revoked");
81
+ }
82
+ throw err;
83
+ }
84
+ }
85
+
86
+ throw new NotSignedInError(conn.account, conn.tenant);
87
+ }
88
+
89
+ /** Store a freshly acquired token (used by `teams_login`). */
90
+ export function storeToken(conn: TeamsConnection, token: CachedToken): void {
91
+ memoryCache.set(memKey(conn), token);
92
+ saveToken(conn.account, conn.tenantId, token);
93
+ }
94
+
95
+ /** Forget the in-process token — the file cache is handled by token-store. */
96
+ export function forgetToken(conn: TeamsConnection): void {
97
+ memoryCache.delete(memKey(conn));
98
+ }
99
+
100
+ /** Drop every in-process token. */
101
+ export function clearMemoryCache(): void {
102
+ memoryCache.clear();
103
+ }
104
+
105
+ export { AuthError };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Minimal JWT claim reader.
3
+ *
4
+ * Used only to show *who* pi is signed in as. The token is never validated
5
+ * here — it came straight from the token endpoint over TLS, and Graph is the
6
+ * authority on whether it is accepted. Nothing security-relevant depends on
7
+ * these claims.
8
+ */
9
+
10
+ export interface IdTokenClaims {
11
+ oid?: string;
12
+ tid?: string;
13
+ name?: string;
14
+ preferred_username?: string;
15
+ upn?: string;
16
+ scp?: string;
17
+ roles?: string[];
18
+ exp?: number;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ /** Decode the payload of a JWT, or undefined when it is not decodable. */
23
+ export function decodeJwtPayload(token: string): IdTokenClaims | undefined {
24
+ try {
25
+ const parts = token.split(".");
26
+ if (parts.length < 2) return undefined;
27
+ const payload = parts[1]!.replace(/-/g, "+").replace(/_/g, "/");
28
+ const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
29
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf-8")) as IdTokenClaims;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ export function decodeIdToken(idToken: string): IdTokenClaims | undefined {
36
+ return decodeJwtPayload(idToken);
37
+ }
38
+
39
+ /**
40
+ * Scopes actually present in an access token (`scp`), or app roles (`roles`)
41
+ * for app-only tokens. `teams_doctor` uses this to tell the user which consent
42
+ * is still missing instead of letting them guess from a 403.
43
+ */
44
+ export function tokenScopes(accessToken: string): string[] {
45
+ const claims = decodeJwtPayload(accessToken);
46
+ if (!claims) return [];
47
+ if (typeof claims.scp === "string" && claims.scp.length > 0) return claims.scp.split(" ");
48
+ if (Array.isArray(claims.roles)) return claims.roles;
49
+ return [];
50
+ }