@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.
- package/dist/config/init.d.ts +5 -0
- package/dist/config/init.d.ts.map +1 -1
- package/dist/config/init.js +59 -26
- package/dist/config/init.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/platform/azure-devops.d.ts.map +1 -1
- package/dist/platform/azure-devops.js +7 -5
- package/dist/platform/azure-devops.js.map +1 -1
- package/dist/platform/comms-teams.d.ts +80 -1
- package/dist/platform/comms-teams.d.ts.map +1 -1
- package/dist/platform/comms-teams.js +306 -84
- package/dist/platform/comms-teams.js.map +1 -1
- package/dist/platform/comms.d.ts +1 -1
- package/dist/platform/comms.d.ts.map +1 -1
- package/dist/platform/comms.js +13 -5
- package/dist/platform/comms.js.map +1 -1
- package/dist/platform/types.d.ts +9 -10
- package/dist/platform/types.d.ts.map +1 -1
- package/dist/resolution.d.ts +44 -0
- package/dist/resolution.d.ts.map +1 -1
- package/dist/resolution.js +69 -18
- package/dist/resolution.js.map +1 -1
- package/dist/roles/catalog-engineering.d.ts +17 -0
- package/dist/roles/catalog-engineering.d.ts.map +1 -1
- package/dist/roles/catalog-engineering.js +45 -0
- package/dist/roles/catalog-engineering.js.map +1 -1
- package/dist/roles/catalog.d.ts +1 -1
- package/dist/roles/catalog.d.ts.map +1 -1
- package/dist/runtime/scheduler.d.ts +6 -0
- package/dist/runtime/scheduler.d.ts.map +1 -1
- package/dist/runtime/scheduler.js +25 -2
- package/dist/runtime/scheduler.js.map +1 -1
- package/dist/state-backend.d.ts +5 -0
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +66 -39
- package/dist/state-backend.js.map +1 -1
- package/package.json +1 -1
- package/templates/casting-reference.md +104 -104
- package/templates/ceremonies.md +28 -28
- package/templates/mcp-config.md +0 -2
- package/templates/orchestration-log.md +27 -27
- package/templates/scribe-charter.md +1 -1
- package/templates/skills/external-comms/SKILL.md +329 -329
- package/templates/skills/gh-auth-isolation/SKILL.md +183 -183
- package/templates/skills/humanizer/SKILL.md +105 -105
- package/templates/skills/pr-review-response/SKILL.md +268 -0
- package/templates/skills/pr-screenshots/SKILL.md +149 -149
- package/templates/skills/versioning-policy/SKILL.md +119 -0
- package/templates/squad.agent.md.template +13 -6
- 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 {
|
|
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
|
-
|
|
24
|
-
|
|
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(
|
|
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
|
-
|
|
29
|
-
return JSON.parse(raw);
|
|
49
|
+
return parsed;
|
|
30
50
|
}
|
|
31
51
|
catch {
|
|
32
52
|
return null;
|
|
33
53
|
}
|
|
34
54
|
}
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
186
|
-
const
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
489
|
+
// "me" mode requires an explicit chat ID to avoid selecting an arbitrary 1:1 chat.
|
|
297
490
|
if (!upn || upn === 'me') {
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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('${
|
|
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
|
|
342
|
-
const
|
|
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:
|
|
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
|
|
359
|
-
const
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
638
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
418
639
|
}
|
|
419
640
|
function stripHtml(html) {
|
|
420
641
|
return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/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
|