@patimweb/pi-teams 1.0.0 → 1.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.
@@ -37,6 +37,7 @@ import {
37
37
  type PermissionBlock,
38
38
  type ResolvedPermissions,
39
39
  } from "./scope.ts";
40
+ import { canOpenBrowser } from "../utils/environment.ts";
40
41
 
41
42
  // ---------------------------------------------------------------------------
42
43
  // Types
@@ -45,15 +46,23 @@ import {
45
46
  /**
46
47
  * How pi obtains a token.
47
48
  *
48
- * - `device-code`: delegated. pi acts **as the signed-in user** — messages are
49
- * sent from their account and everything they can see, pi can see.
49
+ * - `interactive`: delegated. The browser opens at the Microsoft sign-in page
50
+ * and MSAL catches the redirect on a loopback port. pi then acts **as the
51
+ * signed-in user** — messages come from their account, and everything they
52
+ * can see, pi can see.
53
+ * - `device-code`: delegated as well, but the user types a short code on
54
+ * another device. For SSH sessions, containers, and anything headless.
50
55
  * - `client-credentials`: app-only. No user identity; Microsoft Graph does not
51
56
  * allow posting chat or channel messages this way (outside of migration
52
57
  * scenarios), so writes are refused with an explanation.
53
- * - `auto`: use `client-credentials` when a secret is configured, otherwise
54
- * `device-code`.
58
+ * - `auto`: `client-credentials` when a secret is configured, otherwise
59
+ * `interactive` where a browser is reachable and `device-code` where it is
60
+ * not.
55
61
  */
56
- export type AuthMode = "device-code" | "client-credentials" | "auto";
62
+ export type AuthMode = "interactive" | "device-code" | "client-credentials" | "auto";
63
+
64
+ /** An auth mode after "auto" has been resolved away. */
65
+ export type ResolvedAuthMode = "interactive" | "device-code" | "client-credentials";
57
66
 
58
67
  /** Safety level for mutation operations. */
59
68
  export type SafetyLevel = "open" | "confirm" | "readonly";
@@ -72,6 +81,8 @@ export interface TenantConfig {
72
81
  authMode?: AuthMode;
73
82
  /** Requested Graph scopes override */
74
83
  scopes?: string[];
84
+ /** Fixed loopback port for the browser sign-in redirect */
85
+ loopbackPort?: number;
75
86
  /** Safety level override — most specific wins */
76
87
  safetyLevel?: SafetyLevel;
77
88
  /** Scope rules layered on top of the account's */
@@ -94,6 +105,14 @@ export interface AccountConfig {
94
105
  authMode?: AuthMode;
95
106
  /** Requested Graph scopes (default: DEFAULT_SCOPES) */
96
107
  scopes?: string[];
108
+ /**
109
+ * Fixed loopback port for the browser sign-in redirect.
110
+ *
111
+ * Only needed where the app registration lists an exact redirect URI such
112
+ * as `http://localhost:3000`; by default MSAL picks a free port and Entra
113
+ * accepts any port on localhost for a public client.
114
+ */
115
+ loopbackPort?: number;
97
116
  /** Safety level override */
98
117
  safetyLevel?: SafetyLevel;
99
118
  /** Scope rules layered on top of the global ones */
@@ -136,8 +155,10 @@ export interface TeamsConnection {
136
155
  clientId: string;
137
156
  clientSecret?: string;
138
157
  /** Effective auth mode — never "auto" after resolution */
139
- authMode: "device-code" | "client-credentials";
158
+ authMode: ResolvedAuthMode;
140
159
  scopes: string[];
160
+ /** Fixed loopback port for the browser sign-in, when one is configured */
161
+ loopbackPort?: number;
141
162
  safetyLevel: SafetyLevel;
142
163
  permissions: ResolvedPermissions;
143
164
  maxMessages: number;
@@ -165,13 +186,11 @@ export class ConfigError extends Error {
165
186
  /**
166
187
  * Delegated scopes requested by default.
167
188
  *
168
- * `offline_access` is what makes the sign-in last: without it pi would have to
169
- * ask for a device code again every hour.
189
+ * `openid`, `profile` and `offline_access` are absent on purpose: MSAL always
190
+ * requests them and rejects them in an explicit scope list. The lasting
191
+ * sign-in they buy is therefore automatic.
170
192
  */
171
193
  export const DEFAULT_SCOPES: string[] = [
172
- "offline_access",
173
- "openid",
174
- "profile",
175
194
  "User.Read",
176
195
  "User.ReadBasic.All",
177
196
  "Team.ReadBasic.All",
@@ -201,7 +220,12 @@ const DEFAULTS = {
201
220
  authorityHost: "https://login.microsoftonline.com",
202
221
  };
203
222
 
204
- const VALID_AUTH_MODES = new Set<string>(["device-code", "client-credentials", "auto"]);
223
+ const VALID_AUTH_MODES = new Set<string>([
224
+ "interactive",
225
+ "device-code",
226
+ "client-credentials",
227
+ "auto",
228
+ ]);
205
229
  const VALID_SAFETY_LEVELS = new Set<string>(["open", "confirm", "readonly"]);
206
230
 
207
231
  // ---------------------------------------------------------------------------
@@ -299,18 +323,27 @@ export function resolveEffectiveSafetyLevel(
299
323
  );
300
324
  }
301
325
 
302
- /** Effective auth mode, resolving "auto" against the available credentials. */
326
+ /**
327
+ * Effective auth mode, resolving "auto" against the available credentials and
328
+ * the environment.
329
+ *
330
+ * `browserAvailable` is injected rather than detected here so the cascade stays
331
+ * a pure function — the detection itself lives in ../auth/msal.ts.
332
+ */
303
333
  export function resolveAuthMode(
304
334
  account: AccountConfig | undefined,
305
335
  tenant: TenantConfig | undefined,
306
336
  hasSecret: boolean,
307
- ): "device-code" | "client-credentials" {
337
+ browserAvailable = true,
338
+ ): ResolvedAuthMode {
308
339
  const configured =
309
340
  validateAuthMode(tenant?.authMode) ??
310
341
  validateAuthMode(account?.authMode) ??
311
342
  DEFAULTS.authMode;
312
- if (configured === "auto") return hasSecret ? "client-credentials" : "device-code";
313
- return configured;
343
+
344
+ if (configured !== "auto") return configured;
345
+ if (hasSecret) return "client-credentials";
346
+ return browserAvailable ? "interactive" : "device-code";
314
347
  }
315
348
 
316
349
  // ---------------------------------------------------------------------------
@@ -395,7 +428,7 @@ export function resolveConnection(
395
428
  if (missing.length > 0) throw new ConfigError(missing);
396
429
 
397
430
  const clientSecret = tenant?.clientSecret ?? account.clientSecret ?? undefined;
398
- const authMode = resolveAuthMode(account, tenant, !!clientSecret);
431
+ const authMode = resolveAuthMode(account, tenant, !!clientSecret, canOpenBrowser());
399
432
 
400
433
  if (authMode === "client-credentials" && !clientSecret) {
401
434
  throw new ConfigError(
@@ -417,6 +450,7 @@ export function resolveConnection(
417
450
  clientId,
418
451
  clientSecret,
419
452
  authMode,
453
+ loopbackPort: tenant?.loopbackPort ?? account.loopbackPort,
420
454
  scopes,
421
455
  safetyLevel: resolveEffectiveSafetyLevel(config.safetyLevel, account, tenant),
422
456
  permissions: resolvePermissions([config.permissions, account.permissions, tenant?.permissions]),
@@ -644,7 +678,7 @@ const TEMPLATE_JSON = `{
644
678
  "displayName": "Work account",
645
679
  "tenantId": "contoso.onmicrosoft.com",
646
680
  "clientId": "00000000-0000-0000-0000-000000000000",
647
- "authMode": "device-code",
681
+ "authMode": "interactive",
648
682
  "safetyLevel": "confirm",
649
683
  "permissions": {
650
684
  "read": {
@@ -18,7 +18,7 @@
18
18
  * which channel a message is actually headed for.
19
19
  */
20
20
 
21
- import type { SafetyLevel, TeamsConnection } from "../config/index.ts";
21
+ import type { ResolvedAuthMode, SafetyLevel, TeamsConnection } from "../config/index.ts";
22
22
  import { checkScope, type ScopeCategory, type ScopeMode } from "../config/scope.ts";
23
23
  import { ScopeDeniedError } from "../utils/errors.ts";
24
24
  import { MUTATION_TOOLS } from "../tools/tool-names.ts";
@@ -47,7 +47,7 @@ export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout"
47
47
  */
48
48
  export function blockReason(
49
49
  safetyLevel: SafetyLevel,
50
- authMode: "device-code" | "client-credentials",
50
+ authMode: ResolvedAuthMode,
51
51
  toolName: string,
52
52
  ): string | undefined {
53
53
  if (!isMutationTool(toolName)) return undefined;
@@ -64,7 +64,7 @@ export function blockReason(
64
64
  return (
65
65
  `"${toolName}" cannot run on an app-only token. Microsoft Graph only permits app-only writes to ` +
66
66
  `chats and channels for migration scenarios, so pi cannot post as a person this way. ` +
67
- `Set authMode to "device-code" for this account and run teams_login.`
67
+ `Set authMode to "interactive" for this account and run teams_login.`
68
68
  );
69
69
  }
70
70
 
package/src/status.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { TeamsConnection } from "./config/index.ts";
9
- import { getToken, isFresh } from "./auth/token-store.ts";
9
+ import { readCacheSummary } from "./auth/cache-plugin.ts";
10
10
  import { formatPermissions } from "./config/scope.ts";
11
11
 
12
12
  /** Immutable snapshot of the active Teams connection. */
@@ -34,7 +34,7 @@ export function buildConnectionCard(
34
34
  ): ConnectionCard | undefined {
35
35
  if (!conn) return undefined;
36
36
 
37
- const token = getToken(conn.account, conn.tenantId);
37
+ const cache = readCacheSummary(conn.account, conn.tenantId);
38
38
 
39
39
  return {
40
40
  account: conn.account,
@@ -43,9 +43,11 @@ export function buildConnectionCard(
43
43
  tenantId: conn.tenantId,
44
44
  authMode: conn.authMode,
45
45
  safetyLevel: conn.safetyLevel,
46
- user: token?.user?.upn ?? token?.user?.displayName,
47
- signedIn: isFresh(token) || !!token?.refreshToken,
48
- expiresAt: token ? new Date(token.expiresAt).toISOString() : undefined,
46
+ user: cache.username ?? cache.name,
47
+ // A cached session counts as signed in even when the access token has
48
+ // expired: MSAL renews it silently on the next call.
49
+ signedIn: cache.present,
50
+ expiresAt: cache.expiresAt ? new Date(cache.expiresAt).toISOString() : undefined,
49
51
  otherAccounts: conn.allAccounts.map((a) => a.name).filter((name) => name !== conn.account),
50
52
  permissionSummary: formatPermissions(conn.permissions),
51
53
  };
@@ -78,9 +80,21 @@ export function formatStatusText(card: ConnectionCard | undefined): string {
78
80
  if (card.tenant !== card.account) lines.push(`- **Tenant:** ${card.tenant}`);
79
81
  lines.push(`- **Tenant ID:** ${card.tenantId}`);
80
82
  lines.push(`- **Acting as:** ${card.signedIn ? (card.user ?? "(signed in)") : "not signed in — run teams_login"}`);
81
- lines.push(`- **Auth mode:** ${card.authMode}${card.authMode === "client-credentials" ? " (app-only — cannot post as a person)" : ""}`);
83
+ lines.push(
84
+ `- **Auth mode:** ${card.authMode}${
85
+ card.authMode === "client-credentials"
86
+ ? " (app-only — cannot post as a person)"
87
+ : card.authMode === "interactive"
88
+ ? " (browser sign-in)"
89
+ : " (device code)"
90
+ }`,
91
+ );
82
92
  lines.push(`- **Safety level:** ${card.safetyLevel}`);
83
- if (card.expiresAt) lines.push(`- **Token expires:** ${new Date(card.expiresAt).toLocaleString()}`);
93
+ if (card.expiresAt) {
94
+ lines.push(
95
+ `- **Access token expires:** ${new Date(card.expiresAt).toLocaleString()} (renewed silently)`,
96
+ );
97
+ }
84
98
  if (card.otherAccounts.length > 0) {
85
99
  lines.push(`- **Other accounts:** ${card.otherAccounts.join(", ")}`);
86
100
  }
@@ -12,7 +12,8 @@ import {
12
12
  setSafetyLevel,
13
13
  validateSafetyLevel,
14
14
  } from "../config/index.ts";
15
- import { deleteToken, getToken, isFresh } from "../auth/token-store.ts";
15
+ import { readCacheSummary, removeCache } from "../auth/cache-plugin.ts";
16
+ import { resetApps } from "../auth/index.ts";
16
17
  import { clearUserCache, errorResult, run, textResult, type ToolResult } from "./shared.ts";
17
18
 
18
19
  interface AccountsParams {
@@ -69,12 +70,12 @@ export const teamsAccountsTool = {
69
70
  const lines = ["## Configured Teams accounts", ""];
70
71
  for (const account of config.accounts) {
71
72
  const isDefault = config.defaultAccount === account.name;
72
- const token = getToken(account.name, account.tenantId);
73
- const state = isFresh(token)
74
- ? "signed in"
75
- : token?.refreshToken
76
- ? "session saved"
77
- : "not signed in";
73
+ const cache = readCacheSummary(account.name, account.tenantId);
74
+ const state = !cache.present
75
+ ? "not signed in"
76
+ : cache.fresh
77
+ ? "signed in"
78
+ : "session saved";
78
79
 
79
80
  lines.push(
80
81
  `### ${account.displayName ?? account.name}${isDefault ? " (default)" : ""}`,
@@ -83,16 +84,16 @@ export const teamsAccountsTool = {
83
84
  `- tenant: ${account.tenantId}`,
84
85
  `- auth mode: ${account.authMode ?? "device-code"}`,
85
86
  `- safety: ${account.safetyLevel ?? config.safetyLevel ?? "confirm"}`,
86
- `- status: ${state}${token?.user?.upn ? ` as ${token.user.upn}` : ""}`,
87
+ `- status: ${state}${cache.username ? ` as ${cache.username}` : ""}`,
87
88
  );
88
89
 
89
90
  for (const tenant of account.tenants ?? []) {
90
- const tenantToken = getToken(account.name, tenant.tenantId);
91
- const tenantState = isFresh(tenantToken)
92
- ? "signed in"
93
- : tenantToken?.refreshToken
94
- ? "session saved"
95
- : "not signed in";
91
+ const tenantCache = readCacheSummary(account.name, tenant.tenantId);
92
+ const tenantState = !tenantCache.present
93
+ ? "not signed in"
94
+ : tenantCache.fresh
95
+ ? "signed in"
96
+ : "session saved";
96
97
  lines.push(
97
98
  ` - tenant \`${tenant.name}\` (${tenant.tenantId}) — ${tenantState}` +
98
99
  `${tenant.safetyLevel ? `, safety: ${tenant.safetyLevel}` : ""}`,
@@ -125,7 +126,10 @@ export const teamsAccountsTool = {
125
126
  .find((a) => a.name === params.account)
126
127
  ?.tenants?.find((t) => t.name === params.tenant);
127
128
  const removed = removeTenant(params.account, params.tenant);
128
- if (removed && tenantConfig) deleteToken(params.account, tenantConfig.tenantId);
129
+ if (removed && tenantConfig) {
130
+ removeCache(params.account, tenantConfig.tenantId);
131
+ resetApps();
132
+ }
129
133
  return removed
130
134
  ? textResult(`✅ Removed tenant "${params.tenant}" from account "${params.account}".`, {})
131
135
  : errorResult(`Tenant "${params.tenant}" not found in account "${params.account}".`);
@@ -134,10 +138,11 @@ export const teamsAccountsTool = {
134
138
  const accountConfig = config.accounts.find((a) => a.name === params.account);
135
139
  const removed = removeAccount(params.account);
136
140
  if (removed && accountConfig) {
137
- deleteToken(params.account, accountConfig.tenantId);
141
+ removeCache(params.account, accountConfig.tenantId);
138
142
  for (const tenant of accountConfig.tenants ?? []) {
139
- deleteToken(params.account, tenant.tenantId);
143
+ removeCache(params.account, tenant.tenantId);
140
144
  }
145
+ resetApps();
141
146
  }
142
147
  clearUserCache();
143
148
  return removed
@@ -13,9 +13,9 @@ import {
13
13
  DEFAULT_SCOPES,
14
14
  type TeamsConnection,
15
15
  } from "../config/index.ts";
16
- import { getToken, getTokenPath, isFresh } from "../auth/token-store.ts";
16
+ import { getCacheDir, readCacheSummary } from "../auth/cache-plugin.ts";
17
17
  import { tokenScopes } from "../auth/jwt.ts";
18
- import { getAccessToken } from "../auth/index.ts";
18
+ import { browserUnavailableReason, canOpenBrowser, getAccessToken } from "../auth/index.ts";
19
19
  import { getMe } from "../graph/me.ts";
20
20
  import { listJoinedTeams } from "../graph/teams.ts";
21
21
  import { formatGraphError, describeScope } from "../utils/errors.ts";
@@ -47,28 +47,46 @@ async function checkConnection(
47
47
  "- ⚠️ app-only: pi cannot post messages as a person with this mode " +
48
48
  "(Microsoft Graph restricts app-only message writes to migration scenarios)",
49
49
  );
50
+ } else if (conn.authMode === "interactive" && !canOpenBrowser()) {
51
+ lines.push(
52
+ `- ⚠️ browser sign-in is configured but unavailable here (${browserUnavailableReason()}); ` +
53
+ "`teams_login` will use the device code flow",
54
+ );
50
55
  }
51
56
 
52
- const token = getToken(conn.account, conn.tenantId);
53
- if (!token) {
57
+ const cache = readCacheSummary(conn.account, conn.tenantId);
58
+ if (!cache.present) {
54
59
  lines.push("- ❌ not signed in — run `teams_login`");
55
60
  return lines;
56
61
  }
57
62
 
58
63
  lines.push(
59
- isFresh(token)
60
- ? `- ✅ token valid until ${new Date(token.expiresAt).toLocaleString()}`
61
- : token.refreshToken
62
- ? "- ⚠️ access token expired, refresh token present (will renew on the next call)"
63
- : "- ❌ token expired and no refresh token — run `teams_login`",
64
+ cache.fresh
65
+ ? `- ✅ access token valid until ${new Date(cache.expiresAt!).toLocaleString()}`
66
+ : "- ⚠️ access token expired; MSAL renews it silently on the next call",
64
67
  );
68
+ if (cache.username) lines.push(`- cached identity: ${cache.username}`);
69
+
70
+ if (offline) {
71
+ lines.push("- _offline check: scopes and connectivity not verified_");
72
+ return lines;
73
+ }
74
+
75
+ // Everything below needs a live token. Acquiring it also proves that silent
76
+ // renewal works, which is the failure users actually hit.
77
+ let accessToken: string;
78
+ try {
79
+ const token = await getAccessToken(conn, signal);
80
+ accessToken = token.accessToken;
81
+ } catch (err) {
82
+ lines.push(`- ❌ could not obtain a token: ${formatGraphError(err)}`);
83
+ return lines;
84
+ }
65
85
 
66
- // Consent check works offline: the scopes are inside the token.
67
- const granted = tokenScopes(token.accessToken);
86
+ // The granted scopes are inside the token, so this needs no extra call.
87
+ const granted = tokenScopes(accessToken);
68
88
  if (granted.length > 0) {
69
- const wanted = (conn.scopes.length > 0 ? conn.scopes : DEFAULT_SCOPES).filter(
70
- (scope) => !["openid", "profile", "offline_access"].includes(scope),
71
- );
89
+ const wanted = conn.scopes.length > 0 ? conn.scopes : DEFAULT_SCOPES;
72
90
  const missing = wanted.filter(
73
91
  (scope) => !granted.some((g) => g.toLowerCase() === scope.toLowerCase()),
74
92
  );
@@ -86,15 +104,6 @@ async function checkConnection(
86
104
  }
87
105
  }
88
106
 
89
- if (offline) return lines;
90
-
91
- try {
92
- await getAccessToken(conn, signal);
93
- } catch (err) {
94
- lines.push(`- ❌ could not obtain a token: ${formatGraphError(err)}`);
95
- return lines;
96
- }
97
-
98
107
  try {
99
108
  const me = await getMe(conn, signal);
100
109
  lines.push(`- ✅ acting as **${me.displayName}** <${me.upn}>`);
@@ -146,8 +155,11 @@ export const teamsDoctorTool = {
146
155
 
147
156
  const lines = ["## Teams doctor", ""];
148
157
  lines.push(`- config: \`${getConfigPath()}\``);
149
- lines.push(`- tokens: \`${getTokenPath()}\``);
158
+ lines.push(`- tokens: \`${getCacheDir()}/\``);
150
159
  lines.push(`- audit log: \`${getAuditPath()}\``);
160
+ lines.push(
161
+ `- browser sign-in: ${canOpenBrowser() ? "available" : `unavailable — ${browserUnavailableReason()}`}`,
162
+ );
151
163
  lines.push("");
152
164
 
153
165
  if (errors.length > 0) {