@bradygaster/squad-sdk 0.9.3-insider.1 → 0.9.4

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 (53) hide show
  1. package/dist/config/init.d.ts +5 -0
  2. package/dist/config/init.d.ts.map +1 -1
  3. package/dist/config/init.js +59 -26
  4. package/dist/config/init.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/platform/azure-devops.d.ts.map +1 -1
  10. package/dist/platform/azure-devops.js +7 -5
  11. package/dist/platform/azure-devops.js.map +1 -1
  12. package/dist/platform/comms-teams.d.ts +80 -1
  13. package/dist/platform/comms-teams.d.ts.map +1 -1
  14. package/dist/platform/comms-teams.js +306 -84
  15. package/dist/platform/comms-teams.js.map +1 -1
  16. package/dist/platform/comms.d.ts +1 -1
  17. package/dist/platform/comms.d.ts.map +1 -1
  18. package/dist/platform/comms.js +13 -5
  19. package/dist/platform/comms.js.map +1 -1
  20. package/dist/platform/types.d.ts +9 -10
  21. package/dist/platform/types.d.ts.map +1 -1
  22. package/dist/resolution.d.ts +44 -0
  23. package/dist/resolution.d.ts.map +1 -1
  24. package/dist/resolution.js +69 -18
  25. package/dist/resolution.js.map +1 -1
  26. package/dist/roles/catalog-engineering.d.ts +17 -0
  27. package/dist/roles/catalog-engineering.d.ts.map +1 -1
  28. package/dist/roles/catalog-engineering.js +45 -0
  29. package/dist/roles/catalog-engineering.js.map +1 -1
  30. package/dist/roles/catalog.d.ts +1 -1
  31. package/dist/roles/catalog.d.ts.map +1 -1
  32. package/dist/runtime/scheduler.d.ts +6 -0
  33. package/dist/runtime/scheduler.d.ts.map +1 -1
  34. package/dist/runtime/scheduler.js +25 -2
  35. package/dist/runtime/scheduler.js.map +1 -1
  36. package/dist/state-backend.d.ts +5 -0
  37. package/dist/state-backend.d.ts.map +1 -1
  38. package/dist/state-backend.js +66 -39
  39. package/dist/state-backend.js.map +1 -1
  40. package/package.json +1 -1
  41. package/templates/casting-reference.md +104 -104
  42. package/templates/ceremonies.md +28 -28
  43. package/templates/mcp-config.md +0 -2
  44. package/templates/orchestration-log.md +27 -27
  45. package/templates/scribe-charter.md +1 -1
  46. package/templates/skills/external-comms/SKILL.md +329 -329
  47. package/templates/skills/gh-auth-isolation/SKILL.md +183 -183
  48. package/templates/skills/humanizer/SKILL.md +105 -105
  49. package/templates/skills/pr-review-response/SKILL.md +268 -0
  50. package/templates/skills/pr-screenshots/SKILL.md +149 -149
  51. package/templates/skills/versioning-policy/SKILL.md +119 -0
  52. package/templates/squad.agent.md.template +13 -6
  53. package/templates/workflows/squad-heartbeat.yml +167 -167
@@ -4,61 +4,167 @@
4
4
  * Auth priority: cached token → refresh → browser PKCE → device code fallback.
5
5
  * Uses the Microsoft Graph PowerShell first-party client ID by default (works
6
6
  * in every Microsoft tenant with no custom Entra registration required).
7
- * Tokens stored in ~/.squad/teams-tokens.json.
7
+ * Tokens stored per-identity in ~/.squad/teams-tokens-{hash(tenantId:clientId)}.json.
8
8
  *
9
9
  * @module platform/comms-teams
10
10
  */
11
11
  import { join } from 'node:path';
12
12
  import { homedir, platform } from 'node:os';
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
14
14
  import { createServer } from 'node:http';
15
15
  import { randomBytes, createHash } from 'node:crypto';
16
- import { exec } from 'node:child_process';
16
+ import { execFile } from 'node:child_process';
17
17
  // ─── Defaults ────────────────────────────────────────────────────────
18
18
  /** Microsoft Graph PowerShell — first-party, present in every tenant */
19
19
  const DEFAULT_CLIENT_ID = '14d82eec-204b-4c2f-b7e8-296a70dab67e';
20
20
  /** Multi-tenant "organizations" endpoint — works for any Entra org */
21
21
  const DEFAULT_TENANT_ID = 'organizations';
22
22
  const SQUAD_DIR = join(homedir(), '.squad');
23
- const TOKEN_PATH = join(SQUAD_DIR, 'teams-tokens.json');
24
- function loadTokens() {
23
+ /** Legacy single-file path (pre-tenant-scoped) — migrated away on first use */
24
+ const LEGACY_TOKEN_PATH = join(SQUAD_DIR, 'teams-tokens.json');
25
+ /**
26
+ * Derive a safe, collision-resistant filename for a token cache entry.
27
+ * Uses SHA-256 hash of `tenantId + clientId` to avoid path traversal,
28
+ * special character issues, and to provide collision-resistant separation for different app registrations. Uses 16 hex chars (~64 bits) of SHA-256 — sufficient for practical uniqueness across tenant/app combinations.
29
+ */
30
+ function getTokenPath(tenantId, clientId) {
31
+ const hash = createHash('sha256').update(`${tenantId}:${clientId}`).digest('hex').slice(0, 16);
32
+ return join(SQUAD_DIR, `teams-tokens-${hash}.json`);
33
+ }
34
+ function loadTokens(tenantId, clientId) {
35
+ const tokenPath = getTokenPath(tenantId, clientId);
25
36
  try {
26
- if (!existsSync(TOKEN_PATH))
37
+ if (!existsSync(tokenPath))
38
+ return null;
39
+ const raw = readFileSync(tokenPath, 'utf-8');
40
+ const parsed = JSON.parse(raw);
41
+ // Validate minimum required shape
42
+ if (typeof parsed.accessToken !== 'string' || typeof parsed.expiresAt !== 'number' || typeof parsed.refreshToken !== 'string')
43
+ return null;
44
+ // Reject tokens from a different config (stale/corrupted cache)
45
+ if (parsed.configTenantId && parsed.configTenantId !== tenantId)
46
+ return null;
47
+ if (parsed.clientId && parsed.clientId !== clientId)
27
48
  return null;
28
- const raw = readFileSync(TOKEN_PATH, 'utf-8');
29
- return JSON.parse(raw);
49
+ return parsed;
30
50
  }
31
51
  catch {
32
52
  return null;
33
53
  }
34
54
  }
35
- function saveTokens(tokens) {
55
+ // Security: tokens stored with 0o600 permissions (owner-only read/write).
56
+ function saveTokens(tenantId, clientId, tokens) {
57
+ const tokenPath = getTokenPath(tenantId, clientId);
36
58
  if (!existsSync(SQUAD_DIR)) {
37
- mkdirSync(SQUAD_DIR, { recursive: true });
59
+ mkdirSync(SQUAD_DIR, { recursive: true, mode: 0o700 });
60
+ }
61
+ const jwtClaims = extractJwtClaims(tokens.accessToken);
62
+ const withMeta = {
63
+ ...tokens,
64
+ configTenantId: tenantId,
65
+ clientId,
66
+ authenticatedTenantId: jwtClaims.tid,
67
+ authenticatedUserId: jwtClaims.oid,
68
+ };
69
+ writeFileSync(tokenPath, JSON.stringify(withMeta, null, 2), { encoding: 'utf-8', mode: 0o600 });
70
+ // Ensure permissions are correct even if file already existed
71
+ if (platform() === 'win32') {
72
+ execFile('icacls', [tokenPath, '/inheritance:r', '/grant:r', `${process.env.USERNAME ?? 'CURRENT_USER'}:(R,W)`], (err) => {
73
+ if (err)
74
+ console.warn('⚠️ Could not restrict token file permissions:', err.message);
75
+ });
76
+ }
77
+ else {
78
+ chmodSync(SQUAD_DIR, 0o700);
79
+ chmodSync(tokenPath, 0o600);
80
+ }
81
+ }
82
+ /**
83
+ * Remove cached tokens from disk for a specific config.
84
+ * Used on permanent auth errors and explicit logout.
85
+ */
86
+ function clearTokens(tenantId, clientId) {
87
+ const tokenPath = getTokenPath(tenantId, clientId);
88
+ try {
89
+ if (existsSync(tokenPath))
90
+ unlinkSync(tokenPath);
91
+ }
92
+ catch { /* best-effort cleanup */ }
93
+ }
94
+ /**
95
+ * Migrate legacy single-file token cache to identity-scoped storage.
96
+ * Moves tokens from `teams-tokens.json` → `teams-tokens-{hash}.json`,
97
+ * then deletes the legacy file.
98
+ */
99
+ function migrateLegacyTokens(tenantId, clientId) {
100
+ try {
101
+ if (!existsSync(LEGACY_TOKEN_PATH))
102
+ return;
103
+ const raw = readFileSync(LEGACY_TOKEN_PATH, 'utf-8');
104
+ const tokens = JSON.parse(raw);
105
+ if (tokens.accessToken) {
106
+ saveTokens(tenantId, clientId, tokens);
107
+ }
108
+ unlinkSync(LEGACY_TOKEN_PATH);
38
109
  }
39
- writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2), 'utf-8');
110
+ catch { /* best-effort migration */ }
40
111
  }
112
+ /**
113
+ * Extract `tid` (tenant GUID) and `oid` (user object ID) from a JWT access token.
114
+ * Best-effort: returns empty object if the token can't be decoded.
115
+ * Does NOT verify the signature — the token was just received over TLS from Microsoft.
116
+ */
117
+ function extractJwtClaims(accessToken) {
118
+ try {
119
+ const parts = accessToken.split('.');
120
+ if (parts.length !== 3 || !parts[1])
121
+ return {};
122
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
123
+ const decoded = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
124
+ return {
125
+ tid: typeof decoded.tid === 'string' ? decoded.tid : undefined,
126
+ oid: typeof decoded.oid === 'string' ? decoded.oid : undefined,
127
+ };
128
+ }
129
+ catch {
130
+ return {};
131
+ }
132
+ }
133
+ /** Errors that indicate a permanently invalid refresh token (do not retry) */
134
+ const PERMANENT_AUTH_ERRORS = ['invalid_grant', 'interaction_required', 'consent_required', 'invalid_client'];
41
135
  // ─── Graph Helpers ───────────────────────────────────────────────────
42
136
  const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
43
137
  const SCOPES = 'Chat.ReadWrite ChatMessage.Send ChatMessage.Read User.Read offline_access';
44
138
  async function graphFetch(url, accessToken, options = {}) {
45
- const res = await fetch(url, {
46
- method: options.method ?? 'GET',
47
- headers: {
48
- Authorization: `Bearer ${accessToken}`,
49
- 'Content-Type': 'application/json',
50
- },
51
- body: options.body ? JSON.stringify(options.body) : undefined,
52
- });
53
- if (!res.ok) {
54
- const text = await res.text().catch(() => '');
55
- throw new Error(`Graph API ${res.status}: ${res.statusText} ${text}`);
56
- }
57
- const contentType = res.headers.get('content-type') ?? '';
58
- if (contentType.includes('application/json')) {
59
- return res.json();
139
+ const maxRetries = 3;
140
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
141
+ const res = await fetch(url, {
142
+ method: options.method ?? 'GET',
143
+ headers: {
144
+ Authorization: `Bearer ${accessToken}`,
145
+ 'Content-Type': 'application/json',
146
+ },
147
+ body: options.body ? JSON.stringify(options.body) : undefined,
148
+ });
149
+ if (res.status === 429 || res.status === 503 || res.status === 504) {
150
+ if (attempt < maxRetries) {
151
+ const retryAfter = Math.min(Number(res.headers.get('Retry-After') || '5'), 30);
152
+ await new Promise((r) => setTimeout(r, retryAfter * 1000));
153
+ continue;
154
+ }
155
+ }
156
+ if (!res.ok) {
157
+ const text = await res.text().catch(() => '');
158
+ throw new Error(`Graph API ${res.status}: ${res.statusText} — ${text}`);
159
+ }
160
+ const contentType = res.headers.get('content-type') ?? '';
161
+ if (contentType.includes('application/json')) {
162
+ return res.json();
163
+ }
164
+ return undefined;
60
165
  }
61
- return undefined;
166
+ // Unreachable, but satisfies TypeScript
167
+ throw new Error('Graph API request failed after retries');
62
168
  }
63
169
  function parseTokens(data) {
64
170
  return {
@@ -76,10 +182,17 @@ h1{margin:0 0 .5rem;font-size:1.4rem}p{color:#555;margin:0}</style></head>
76
182
  <body><div class="card"><h1>✅ Authentication successful!</h1><p>You can close this tab and return to the terminal.</p></div></body></html>`;
77
183
  /** Open a URL in the user's default browser. */
78
184
  function openBrowser(url) {
79
- const cmd = platform() === 'win32' ? `start "" "${url}"`
80
- : platform() === 'darwin' ? `open "${url}"`
81
- : `xdg-open "${url}"`;
82
- exec(cmd, () => { });
185
+ const p = platform();
186
+ if (p === 'win32') {
187
+ // Use PowerShell Start-Process to avoid cmd.exe & metacharacter injection
188
+ execFile('powershell.exe', ['-NoProfile', '-Command', `Start-Process '${url.replace(/'/g, "''")}'`], () => { });
189
+ }
190
+ else if (p === 'darwin') {
191
+ execFile('open', [url], () => { });
192
+ }
193
+ else {
194
+ execFile('xdg-open', [url], () => { });
195
+ }
83
196
  }
84
197
  /** Base64-URL encode (no padding). */
85
198
  function base64url(buf) {
@@ -88,14 +201,17 @@ function base64url(buf) {
88
201
  async function startBrowserAuthFlow(tenantId, clientId) {
89
202
  const codeVerifier = base64url(randomBytes(32));
90
203
  const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest());
204
+ const oauthState = base64url(randomBytes(16));
91
205
  return new Promise((resolve, reject) => {
92
206
  const server = createServer((req, res) => {
93
207
  const reqUrl = new URL(req.url ?? '/', `http://localhost`);
94
208
  const code = reqUrl.searchParams.get('code');
95
209
  const error = reqUrl.searchParams.get('error');
210
+ const returnedState = reqUrl.searchParams.get('state');
96
211
  if (error) {
212
+ const errorDesc = reqUrl.searchParams.get('error_description') ?? '';
97
213
  res.writeHead(200, { 'Content-Type': 'text/html' });
98
- res.end(`<html><body><h1>Authentication failed</h1><p>${error}</p></body></html>`);
214
+ res.end(`<html><body><h1>Authentication failed</h1><p>${escapeHtml(error)}</p><p>${escapeHtml(errorDesc)}</p></body></html>`);
99
215
  cleanup();
100
216
  reject(new Error(`Browser auth denied: ${error}`));
101
217
  return;
@@ -105,6 +221,14 @@ async function startBrowserAuthFlow(tenantId, clientId) {
105
221
  res.end('Missing authorization code');
106
222
  return;
107
223
  }
224
+ // Validate state to prevent CSRF
225
+ if (returnedState !== oauthState) {
226
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
227
+ res.end('Invalid state parameter — possible CSRF attack');
228
+ cleanup();
229
+ reject(new Error('OAuth state mismatch — possible CSRF'));
230
+ return;
231
+ }
108
232
  // Exchange code for tokens
109
233
  const redirectUri = `http://localhost:${server.address().port}`;
110
234
  const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
@@ -126,7 +250,7 @@ async function startBrowserAuthFlow(tenantId, clientId) {
126
250
  throw new Error(`Token exchange failed: ${data.error} — ${data.error_description}`);
127
251
  }
128
252
  const tokens = parseTokens(data);
129
- saveTokens(tokens);
253
+ saveTokens(tenantId, clientId, tokens);
130
254
  res.writeHead(200, { 'Content-Type': 'text/html' });
131
255
  res.end(SUCCESS_HTML);
132
256
  cleanup();
@@ -148,6 +272,11 @@ async function startBrowserAuthFlow(tenantId, clientId) {
148
272
  clearTimeout(timer);
149
273
  server.close();
150
274
  }
275
+ // Handle server startup errors (port exhaustion, permission denied, etc.)
276
+ server.on('error', (err) => {
277
+ cleanup();
278
+ reject(new Error(`OAuth callback server failed: ${err.message}`));
279
+ });
151
280
  // Bind to a random available port
152
281
  server.listen(0, '127.0.0.1', () => {
153
282
  const port = server.address().port;
@@ -159,12 +288,19 @@ async function startBrowserAuthFlow(tenantId, clientId) {
159
288
  `&scope=${encodeURIComponent(SCOPES)}` +
160
289
  `&code_challenge=${encodeURIComponent(codeChallenge)}` +
161
290
  `&code_challenge_method=S256` +
291
+ `&state=${encodeURIComponent(oauthState)}` +
162
292
  `&prompt=select_account`;
163
293
  console.log('🌐 Opening browser for Teams authentication...');
164
294
  openBrowser(authorizeUrl);
165
295
  });
166
296
  });
167
297
  }
298
+ /** Maximum time allowed for device-code auth flow (15 minutes) */
299
+ const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000;
300
+ /** Minimum poll interval for device-code flow (2 seconds) */
301
+ const DEVICE_CODE_MIN_POLL_MS = 2_000;
302
+ /** Maximum poll interval for device-code flow (30 seconds) */
303
+ const DEVICE_CODE_MAX_POLL_MS = 30_000;
168
304
  async function startDeviceCodeFlow(tenantId, clientId) {
169
305
  const deviceCodeUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/devicecode`;
170
306
  const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
@@ -182,8 +318,11 @@ async function startDeviceCodeFlow(tenantId, clientId) {
182
318
  const dcData = (await dcRes.json());
183
319
  console.log(`\n🔐 Teams authentication required`);
184
320
  console.log(` ${dcData.message}\n`);
185
- const pollInterval = (dcData.interval || 5) * 1000;
186
- const deadline = Date.now() + dcData.expires_in * 1000;
321
+ // Clamp poll interval and timeout to sane bounds
322
+ const rawInterval = (dcData.interval || 5) * 1000;
323
+ const pollInterval = Math.max(DEVICE_CODE_MIN_POLL_MS, Math.min(rawInterval, DEVICE_CODE_MAX_POLL_MS));
324
+ const serverTimeout = dcData.expires_in > 0 ? dcData.expires_in * 1000 : DEVICE_CODE_TIMEOUT_MS;
325
+ const deadline = Date.now() + Math.min(serverTimeout, DEVICE_CODE_TIMEOUT_MS);
187
326
  while (Date.now() < deadline) {
188
327
  await new Promise((r) => setTimeout(r, pollInterval));
189
328
  const tokenRes = await fetch(tokenUrl, {
@@ -198,7 +337,7 @@ async function startDeviceCodeFlow(tenantId, clientId) {
198
337
  const tokenData = (await tokenRes.json());
199
338
  if (tokenData.access_token) {
200
339
  const tokens = parseTokens(tokenData);
201
- saveTokens(tokens);
340
+ saveTokens(tenantId, clientId, tokens);
202
341
  console.log(`✅ Teams authentication successful — tokens saved\n`);
203
342
  return tokens;
204
343
  }
@@ -227,29 +366,41 @@ async function refreshAccessToken(tenantId, clientId, refreshToken) {
227
366
  });
228
367
  const data = (await res.json());
229
368
  if (!data.access_token) {
230
- throw new Error(`Token refresh failed: ${data.error} — ${data.error_description}`);
369
+ const err = new Error(`Token refresh failed: ${data.error} — ${data.error_description}`);
370
+ // Attach error code for callers to distinguish permanent vs transient failures
371
+ err.authError = data.error ?? 'unknown';
372
+ throw err;
231
373
  }
232
374
  const tokens = {
233
375
  accessToken: data.access_token,
234
376
  refreshToken: data.refresh_token ?? refreshToken,
235
377
  expiresAt: Date.now() + data.expires_in * 1000,
236
378
  };
237
- saveTokens(tokens);
379
+ saveTokens(tenantId, clientId, tokens);
238
380
  return tokens;
239
381
  }
240
382
  // ─── Adapter ─────────────────────────────────────────────────────────
241
383
  export class TeamsCommunicationAdapter {
242
384
  config;
243
- channel = 'teams-webhook';
385
+ channel = 'teams-graph';
244
386
  tokens = null;
245
387
  resolvedChatId;
246
388
  clientId;
247
389
  tenantId;
390
+ /** Per-instance user ID cache — cleared on every token change to prevent cross-account leaks */
391
+ cachedUserId = null;
248
392
  constructor(config) {
249
393
  this.config = config;
250
394
  this.resolvedChatId = config.chatId ?? null;
251
395
  this.clientId = config.clientId ?? DEFAULT_CLIENT_ID;
252
396
  this.tenantId = config.tenantId ?? DEFAULT_TENANT_ID;
397
+ // One-time migration from legacy single-file token storage
398
+ migrateLegacyTokens(this.tenantId, this.clientId);
399
+ }
400
+ /** Reset all identity-sensitive caches. Called on every token change. */
401
+ resetIdentityCaches() {
402
+ this.cachedUserId = null;
403
+ this.resolvedChatId = this.config.chatId ?? null;
253
404
  }
254
405
  /**
255
406
  * Ensure we have a valid access token.
@@ -257,7 +408,12 @@ export class TeamsCommunicationAdapter {
257
408
  */
258
409
  async ensureAuthenticated() {
259
410
  if (!this.tokens) {
260
- this.tokens = loadTokens();
411
+ this.tokens = loadTokens(this.tenantId, this.clientId);
412
+ if (this.tokens) {
413
+ // Loaded from disk — reset identity caches since this may be
414
+ // a different session or the identity may have changed
415
+ this.resetIdentityCaches();
416
+ }
261
417
  }
262
418
  // Valid token — return it
263
419
  if (this.tokens && Date.now() < this.tokens.expiresAt - 60_000) {
@@ -267,15 +423,27 @@ export class TeamsCommunicationAdapter {
267
423
  if (this.tokens?.refreshToken) {
268
424
  try {
269
425
  this.tokens = await refreshAccessToken(this.tenantId, this.clientId, this.tokens.refreshToken);
426
+ this.resetIdentityCaches();
270
427
  return this.tokens.accessToken;
271
428
  }
272
- catch {
273
- console.warn('⚠️ Token refresh failed re-authenticating...');
429
+ catch (err) {
430
+ const authError = err.authError ?? '';
431
+ if (PERMANENT_AUTH_ERRORS.includes(authError)) {
432
+ // Permanent failure — clear stale tokens so they're not reloaded
433
+ clearTokens(this.tenantId, this.clientId);
434
+ this.tokens = null;
435
+ this.resetIdentityCaches();
436
+ console.warn(`⚠️ Token refresh permanently failed (${authError}) — re-authenticating...`);
437
+ }
438
+ else {
439
+ console.warn('⚠️ Token refresh failed (transient) — re-authenticating...');
440
+ }
274
441
  }
275
442
  }
276
443
  // Try browser auth code flow with PKCE first
277
444
  try {
278
445
  this.tokens = await startBrowserAuthFlow(this.tenantId, this.clientId);
446
+ this.resetIdentityCaches();
279
447
  console.log('✅ Teams authentication successful — tokens saved');
280
448
  return this.tokens.accessToken;
281
449
  }
@@ -284,8 +452,33 @@ export class TeamsCommunicationAdapter {
284
452
  }
285
453
  // Fallback — device code flow (works in headless/SSH environments)
286
454
  this.tokens = await startDeviceCodeFlow(this.tenantId, this.clientId);
455
+ this.resetIdentityCaches();
287
456
  return this.tokens.accessToken;
288
457
  }
458
+ /**
459
+ * Logout: clear cached credentials (memory + disk) for this adapter's config.
460
+ * This is a local credential purge — does not call Microsoft's revocation endpoint
461
+ * (public-client refresh tokens cannot be reliably revoked server-side).
462
+ */
463
+ async logout() {
464
+ clearTokens(this.tenantId, this.clientId);
465
+ this.tokens = null;
466
+ this.resetIdentityCaches();
467
+ }
468
+ /** Resolve the current user's Graph ID, cached per auth session. */
469
+ async getMyUserId(accessToken) {
470
+ if (this.cachedUserId)
471
+ return this.cachedUserId;
472
+ try {
473
+ const me = (await graphFetch(`${GRAPH_BASE}/me`, accessToken));
474
+ this.cachedUserId = me.id;
475
+ return this.cachedUserId;
476
+ }
477
+ catch (err) {
478
+ console.warn(`⚠️ Teams /me fetch failed: ${err.message}`);
479
+ return null;
480
+ }
481
+ }
289
482
  /**
290
483
  * Find or create a 1:1 chat with the recipient.
291
484
  */
@@ -293,31 +486,18 @@ export class TeamsCommunicationAdapter {
293
486
  if (this.resolvedChatId)
294
487
  return this.resolvedChatId;
295
488
  const upn = this.config.recipientUpn;
296
- // "me" mode find or create a self-chat
489
+ // "me" mode requires an explicit chat ID to avoid selecting an arbitrary 1:1 chat.
297
490
  if (!upn || upn === 'me') {
298
- const chatsRes = (await graphFetch(`${GRAPH_BASE}/me/chats?$filter=chatType eq 'oneOnOne'&$top=10`, accessToken));
299
- if (chatsRes.value.length > 0) {
300
- this.resolvedChatId = chatsRes.value[0].id;
301
- return this.resolvedChatId;
491
+ if (!this.config.chatId) {
492
+ throw new Error('Teams "me" mode requires an explicit chatId to avoid routing messages to the wrong one-on-one chat. Provide config.chatId or set recipientUpn to a specific user.');
302
493
  }
303
- const meRes = (await graphFetch(`${GRAPH_BASE}/me`, accessToken));
304
- const chatRes = (await graphFetch(`${GRAPH_BASE}/chats`, accessToken, {
305
- method: 'POST',
306
- body: {
307
- chatType: 'oneOnOne',
308
- members: [
309
- {
310
- '@odata.type': '#microsoft.graph.aadUserConversationMember',
311
- roles: ['owner'],
312
- 'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${meRes.id}')`,
313
- },
314
- ],
315
- },
316
- }));
317
- this.resolvedChatId = chatRes.id;
494
+ validateGraphId(this.config.chatId, 'chatId');
495
+ this.resolvedChatId = this.config.chatId;
318
496
  return this.resolvedChatId;
319
497
  }
320
- // Create 1:1 chat with recipient UPN
498
+ // Create 1:1 chat with recipient UPN — Graph requires both participants
499
+ const safeUpn = validateGraphId(upn, 'recipientUpn');
500
+ const meRes = (await graphFetch(`${GRAPH_BASE}/me`, accessToken));
321
501
  const chatRes = (await graphFetch(`${GRAPH_BASE}/chats`, accessToken, {
322
502
  method: 'POST',
323
503
  body: {
@@ -326,7 +506,12 @@ export class TeamsCommunicationAdapter {
326
506
  {
327
507
  '@odata.type': '#microsoft.graph.aadUserConversationMember',
328
508
  roles: ['owner'],
329
- 'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${upn}')`,
509
+ 'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${meRes.id}')`,
510
+ },
511
+ {
512
+ '@odata.type': '#microsoft.graph.aadUserConversationMember',
513
+ roles: ['owner'],
514
+ 'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${safeUpn}')`,
330
515
  },
331
516
  ],
332
517
  },
@@ -338,8 +523,10 @@ export class TeamsCommunicationAdapter {
338
523
  const accessToken = await this.ensureAuthenticated();
339
524
  // Use channel message if teamId + channelId are configured
340
525
  if (this.config.teamId && this.config.channelId) {
341
- const url = `${GRAPH_BASE}/teams/${this.config.teamId}/channels/${this.config.channelId}/messages`;
342
- const msg = (await graphFetch(url, accessToken, {
526
+ const safeTeamId = validateGraphId(this.config.teamId, 'teamId');
527
+ const safeChannelId = validateGraphId(this.config.channelId, 'channelId');
528
+ const url = `${GRAPH_BASE}/teams/${safeTeamId}/channels/${safeChannelId}/messages`;
529
+ await graphFetch(url, accessToken, {
343
530
  method: 'POST',
344
531
  body: {
345
532
  body: {
@@ -347,16 +534,18 @@ export class TeamsCommunicationAdapter {
347
534
  content: formatTeamsMessage(options.title, options.body, options.author),
348
535
  },
349
536
  },
350
- }));
537
+ });
538
+ // Return stable composite ID so pollForReplies can locate the channel
351
539
  return {
352
- id: msg.id,
353
- url: `https://teams.microsoft.com/l/channel/${this.config.channelId}`,
540
+ id: `${this.config.teamId}|${this.config.channelId}`,
541
+ url: `https://teams.microsoft.com/l/channel/${encodeURIComponent(this.config.channelId)}`,
354
542
  };
355
543
  }
356
544
  // 1:1 chat mode
357
545
  const chatId = await this.ensureChat(accessToken);
358
- const url = `${GRAPH_BASE}/chats/${chatId}/messages`;
359
- const msg = (await graphFetch(url, accessToken, {
546
+ const safeChatId = validateGraphId(chatId, 'chatId');
547
+ const url = `${GRAPH_BASE}/chats/${safeChatId}/messages`;
548
+ await graphFetch(url, accessToken, {
360
549
  method: 'POST',
361
550
  body: {
362
551
  body: {
@@ -364,36 +553,60 @@ export class TeamsCommunicationAdapter {
364
553
  content: formatTeamsMessage(options.title, options.body, options.author),
365
554
  },
366
555
  },
367
- }));
556
+ });
557
+ // Return chatId so pollForReplies can locate the right chat
368
558
  return {
369
- id: msg.id,
559
+ id: chatId,
370
560
  url: this.getNotificationUrl(chatId),
371
561
  };
372
562
  }
373
563
  async pollForReplies(options) {
374
564
  const accessToken = await this.ensureAuthenticated();
375
- const chatId = this.resolvedChatId ?? options.threadId;
376
565
  const sinceIso = options.since.toISOString();
377
- const url = `${GRAPH_BASE}/chats/${chatId}/messages?$filter=createdDateTime gt ${sinceIso}&$top=50&$orderby=createdDateTime asc`;
566
+ // Channel mode: threadId is "teamId|channelId" composite from postUpdate
567
+ let messagesUrl;
568
+ if (options.threadId.includes('|')) {
569
+ const [teamId, channelId] = options.threadId.split('|', 2);
570
+ if (teamId && channelId) {
571
+ const safeTeamId = validateGraphId(teamId, 'teamId');
572
+ const safeChannelId = validateGraphId(channelId, 'channelId');
573
+ const url = new URL(`${GRAPH_BASE}/teams/${safeTeamId}/channels/${safeChannelId}/messages`);
574
+ url.searchParams.set('$filter', `createdDateTime gt ${sinceIso}`);
575
+ url.searchParams.set('$top', '50');
576
+ url.searchParams.set('$orderby', 'createdDateTime asc');
577
+ messagesUrl = url.toString();
578
+ }
579
+ else {
580
+ return [];
581
+ }
582
+ }
583
+ else {
584
+ // 1:1 chat mode: threadId is the chatId
585
+ const chatId = this.resolvedChatId ?? options.threadId;
586
+ const safeChatId = validateGraphId(chatId, 'chatId');
587
+ const url = new URL(`${GRAPH_BASE}/chats/${safeChatId}/messages`);
588
+ url.searchParams.set('$filter', `createdDateTime gt ${sinceIso}`);
589
+ url.searchParams.set('$top', '50');
590
+ url.searchParams.set('$orderby', 'createdDateTime asc');
591
+ messagesUrl = url.toString();
592
+ }
378
593
  let data;
379
594
  try {
380
- data = (await graphFetch(url, accessToken));
595
+ data = (await graphFetch(messagesUrl, accessToken));
381
596
  }
382
- catch {
597
+ catch (err) {
598
+ console.warn(`⚠️ Teams pollForReplies failed: ${err.message}`);
383
599
  return [];
384
600
  }
385
- let myId = null;
386
- try {
387
- const me = (await graphFetch(`${GRAPH_BASE}/me`, accessToken));
388
- myId = me.id;
389
- }
390
- catch { /* ignore */ }
601
+ const myId = await this.getMyUserId(accessToken);
391
602
  return data.value
392
603
  .filter((m) => {
393
604
  if (!m.from?.user)
394
605
  return false;
395
606
  if (myId && m.from.user.id === myId)
396
607
  return false;
608
+ if (new Date(m.createdDateTime) <= options.since)
609
+ return false;
397
610
  return true;
398
611
  })
399
612
  .map((m) => ({
@@ -408,15 +621,24 @@ export class TeamsCommunicationAdapter {
408
621
  return `https://teams.microsoft.com/l/chat/${encodeURIComponent(chatId)}`;
409
622
  }
410
623
  }
624
+ // ─── Graph ID Validation ─────────────────────────────────────────────
625
+ /** Validate and encode a Graph API path segment. */
626
+ function validateGraphId(id, label) {
627
+ if (!/^[\w:@.\-]+$/.test(id)) {
628
+ throw new Error(`Invalid ${label}: contains unsafe characters`);
629
+ }
630
+ return encodeURIComponent(id);
631
+ }
411
632
  // ─── Formatting ──────────────────────────────────────────────────────
412
633
  function formatTeamsMessage(title, body, author) {
413
634
  const authorLine = author ? `<em>Posted by ${escapeHtml(author)}</em><br/>` : '';
414
635
  return `<b>${escapeHtml(title)}</b><br/>${authorLine}<br/>${escapeHtml(body).replace(/\n/g, '<br/>')}`;
415
636
  }
416
637
  function escapeHtml(s) {
417
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
638
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
418
639
  }
419
640
  function stripHtml(html) {
420
641
  return html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').trim();
421
642
  }
643
+ export { escapeHtml, stripHtml, formatTeamsMessage, parseTokens, base64url, validateGraphId, getTokenPath, clearTokens, loadTokens, saveTokens, migrateLegacyTokens, extractJwtClaims, DEVICE_CODE_TIMEOUT_MS, DEVICE_CODE_MIN_POLL_MS, DEVICE_CODE_MAX_POLL_MS, LEGACY_TOKEN_PATH, PERMANENT_AUTH_ERRORS, };
422
644
  //# sourceMappingURL=comms-teams.js.map