m365-agent-cli 1.2.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 (92) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +916 -0
  3. package/package.json +50 -0
  4. package/src/cli.ts +100 -0
  5. package/src/commands/auto-reply.ts +182 -0
  6. package/src/commands/calendar.ts +576 -0
  7. package/src/commands/counter.ts +87 -0
  8. package/src/commands/create-event.ts +544 -0
  9. package/src/commands/delegates.ts +286 -0
  10. package/src/commands/delete-event.ts +321 -0
  11. package/src/commands/drafts.ts +502 -0
  12. package/src/commands/files.ts +532 -0
  13. package/src/commands/find.ts +195 -0
  14. package/src/commands/findtime.ts +270 -0
  15. package/src/commands/folders.ts +177 -0
  16. package/src/commands/forward-event.ts +49 -0
  17. package/src/commands/graph-calendar.ts +217 -0
  18. package/src/commands/login.ts +195 -0
  19. package/src/commands/mail.ts +950 -0
  20. package/src/commands/oof.ts +263 -0
  21. package/src/commands/outlook-categories.ts +173 -0
  22. package/src/commands/outlook-graph.ts +880 -0
  23. package/src/commands/planner.ts +1678 -0
  24. package/src/commands/respond.ts +291 -0
  25. package/src/commands/rooms.ts +210 -0
  26. package/src/commands/rules.ts +511 -0
  27. package/src/commands/schedule.ts +109 -0
  28. package/src/commands/send.ts +204 -0
  29. package/src/commands/serve.ts +14 -0
  30. package/src/commands/sharepoint.ts +179 -0
  31. package/src/commands/site-pages.ts +163 -0
  32. package/src/commands/subscribe.ts +103 -0
  33. package/src/commands/subscriptions.ts +29 -0
  34. package/src/commands/suggest.ts +155 -0
  35. package/src/commands/todo.ts +2092 -0
  36. package/src/commands/update-event.ts +608 -0
  37. package/src/commands/update.ts +88 -0
  38. package/src/commands/verify-token.ts +62 -0
  39. package/src/commands/whoami.ts +74 -0
  40. package/src/index.ts +190 -0
  41. package/src/lib/atomic-write.ts +20 -0
  42. package/src/lib/attach-link-spec.test.ts +24 -0
  43. package/src/lib/attach-link-spec.ts +70 -0
  44. package/src/lib/attachments.ts +79 -0
  45. package/src/lib/auth.ts +192 -0
  46. package/src/lib/calendar-range.test.ts +41 -0
  47. package/src/lib/calendar-range.ts +103 -0
  48. package/src/lib/dates.test.ts +74 -0
  49. package/src/lib/dates.ts +137 -0
  50. package/src/lib/delegate-client.test.ts +74 -0
  51. package/src/lib/delegate-client.ts +322 -0
  52. package/src/lib/ews-client.ts +3418 -0
  53. package/src/lib/git-commit.ts +4 -0
  54. package/src/lib/glitchtip-eligibility.ts +220 -0
  55. package/src/lib/glitchtip.ts +253 -0
  56. package/src/lib/global-env.ts +3 -0
  57. package/src/lib/graph-auth.ts +223 -0
  58. package/src/lib/graph-calendar-client.test.ts +118 -0
  59. package/src/lib/graph-calendar-client.ts +112 -0
  60. package/src/lib/graph-client.test.ts +107 -0
  61. package/src/lib/graph-client.ts +1058 -0
  62. package/src/lib/graph-constants.ts +12 -0
  63. package/src/lib/graph-directory.ts +116 -0
  64. package/src/lib/graph-event.ts +134 -0
  65. package/src/lib/graph-schedule.ts +173 -0
  66. package/src/lib/graph-subscriptions.ts +94 -0
  67. package/src/lib/graph-user-path.ts +13 -0
  68. package/src/lib/jwt-utils.ts +34 -0
  69. package/src/lib/markdown.test.ts +21 -0
  70. package/src/lib/markdown.ts +174 -0
  71. package/src/lib/mime-type.ts +106 -0
  72. package/src/lib/oof-client.test.ts +59 -0
  73. package/src/lib/oof-client.ts +122 -0
  74. package/src/lib/outlook-graph-client.test.ts +146 -0
  75. package/src/lib/outlook-graph-client.ts +649 -0
  76. package/src/lib/outlook-master-categories.ts +145 -0
  77. package/src/lib/package-info.ts +59 -0
  78. package/src/lib/places-client.ts +144 -0
  79. package/src/lib/planner-client.ts +1226 -0
  80. package/src/lib/rules-client.ts +178 -0
  81. package/src/lib/sharepoint-client.ts +101 -0
  82. package/src/lib/site-pages-client.ts +73 -0
  83. package/src/lib/todo-client.test.ts +298 -0
  84. package/src/lib/todo-client.ts +1309 -0
  85. package/src/lib/url-validation.ts +40 -0
  86. package/src/lib/utils.ts +45 -0
  87. package/src/lib/webhook-server.ts +51 -0
  88. package/src/test/auth.test.ts +104 -0
  89. package/src/test/cli.integration.test.ts +1083 -0
  90. package/src/test/ews-client.test.ts +268 -0
  91. package/src/test/mocks/index.ts +375 -0
  92. package/src/test/mocks/responses.ts +861 -0
@@ -0,0 +1,1058 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname, resolve } from 'node:path';
6
+ import { Readable } from 'node:stream';
7
+ import { GRAPH_BASE_URL } from './graph-constants.js';
8
+
9
+ export { GRAPH_BASE_URL };
10
+
11
+ const GRAPH_TIMEOUT_MS = Number(process.env.GRAPH_TIMEOUT_MS) || 30_000; // 30s default
12
+
13
+ export interface GraphError {
14
+ message: string;
15
+ code?: string;
16
+ status?: number;
17
+ }
18
+
19
+ export class GraphApiError extends Error {
20
+ constructor(
21
+ message: string,
22
+ public readonly code?: string,
23
+ public readonly status?: number
24
+ ) {
25
+ super(message);
26
+ this.name = 'GraphApiError';
27
+ }
28
+ }
29
+
30
+ export interface GraphResponse<T> {
31
+ ok: boolean;
32
+ data?: T;
33
+ error?: GraphError;
34
+ }
35
+
36
+ export interface DriveItemReference {
37
+ driveId?: string;
38
+ id?: string;
39
+ path?: string;
40
+ }
41
+
42
+ export interface DriveItem {
43
+ id: string;
44
+ name: string;
45
+ webUrl?: string;
46
+ size?: number;
47
+ createdDateTime?: string;
48
+ lastModifiedDateTime?: string;
49
+ file?: { mimeType?: string };
50
+ folder?: { childCount?: number };
51
+ parentReference?: { driveId?: string; id?: string; path?: string };
52
+ '@microsoft.graph.downloadUrl'?: string;
53
+ }
54
+
55
+ export interface DriveItemVersion {
56
+ id: string;
57
+ lastModifiedDateTime?: string;
58
+ size?: number;
59
+ lastModifiedBy?: { user?: { displayName?: string; email?: string } };
60
+ }
61
+
62
+ export interface DriveItemListResponse {
63
+ value: DriveItem[];
64
+ }
65
+
66
+ export interface SharingLinkResult {
67
+ id?: string;
68
+ webUrl?: string;
69
+ type?: string;
70
+ scope?: string;
71
+ }
72
+
73
+ export interface OfficeCollabLinkResult {
74
+ item: DriveItem;
75
+ link: SharingLinkResult;
76
+ collaborationUrl?: string;
77
+ lockAcquired: boolean;
78
+ }
79
+
80
+ export interface CheckinResult {
81
+ item: DriveItem;
82
+ checkedIn: boolean;
83
+ comment?: string;
84
+ }
85
+
86
+ const MAX_DOWNLOAD_STREAM_BYTES = 5 * 1024 * 1024 * 1024;
87
+
88
+ /** Streams HTTPS response body to disk with a hard size cap (mitigates unbounded http-to-file writes). */
89
+ async function streamWebToFile(
90
+ body: ReadableStream<Uint8Array>,
91
+ filePath: string,
92
+ maxBytes: number = MAX_DOWNLOAD_STREAM_BYTES
93
+ ): Promise<number> {
94
+ const stream = createWriteStream(filePath, { flags: 'w', mode: 0o600 });
95
+ let bytesWritten = 0;
96
+
97
+ try {
98
+ for await (const chunk of body) {
99
+ if (bytesWritten + chunk.byteLength > maxBytes) {
100
+ stream.destroy();
101
+ await unlink(filePath).catch(() => {});
102
+ throw new Error(`Download exceeded maximum size (${maxBytes} bytes)`);
103
+ }
104
+ if (!stream.write(chunk)) {
105
+ await new Promise<void>((resolveDrain, rejectDrain) => {
106
+ const onDrain = () => {
107
+ stream.off('error', onError);
108
+ resolveDrain();
109
+ };
110
+ const onError = (err: Error) => {
111
+ stream.off('drain', onDrain);
112
+ rejectDrain(err);
113
+ };
114
+ stream.once('drain', onDrain);
115
+ stream.once('error', onError);
116
+ });
117
+ }
118
+ bytesWritten += chunk.byteLength;
119
+ }
120
+
121
+ await new Promise<void>((resolveClose, rejectClose) => {
122
+ stream.end((err?: Error | null) => {
123
+ if (err) rejectClose(err);
124
+ else resolveClose();
125
+ });
126
+ });
127
+
128
+ return bytesWritten;
129
+ } catch (err) {
130
+ stream.destroy();
131
+ try {
132
+ await unlink(filePath);
133
+ } catch {}
134
+ throw err;
135
+ }
136
+ }
137
+
138
+ export function graphResult<T>(data: T): GraphResponse<T> {
139
+ return { ok: true, data };
140
+ }
141
+
142
+ export function graphError(message: string, code?: string, status?: number): GraphResponse<never> {
143
+ return { ok: false, error: { message, code, status } };
144
+ }
145
+
146
+ function resolveNextPath(nextLink: string, baseUrl: string): string {
147
+ try {
148
+ const normalizedBase = baseUrl.replace(/\/$/, '');
149
+ if (nextLink.startsWith(normalizedBase)) {
150
+ return nextLink.substring(normalizedBase.length);
151
+ }
152
+ const nextUrl = new URL(nextLink);
153
+ const baseUrlUrl = new URL(normalizedBase);
154
+ if (nextUrl.origin === baseUrlUrl.origin) {
155
+ const baseDir = baseUrlUrl.pathname.replace(/\/$/, '');
156
+ if (nextUrl.pathname.startsWith(baseDir)) {
157
+ return nextUrl.pathname.substring(baseDir.length) + nextUrl.search;
158
+ }
159
+ }
160
+ return '';
161
+ } catch {
162
+ return '';
163
+ }
164
+ }
165
+
166
+ export async function fetchAllPages<T>(
167
+ token: string,
168
+ initialPath: string,
169
+ errorMessage: string,
170
+ baseUrl: string = GRAPH_BASE_URL,
171
+ requestInit?: RequestInit
172
+ ): Promise<GraphResponse<T[]>> {
173
+ const items: T[] = [];
174
+ let path = initialPath;
175
+
176
+ while (path) {
177
+ let result: GraphResponse<{ value: T[]; '@odata.nextLink'?: string }>;
178
+ try {
179
+ result = await callGraphAt<{ value: T[]; '@odata.nextLink'?: string }>(baseUrl, token, path, requestInit ?? {});
180
+ } catch (err) {
181
+ if (err instanceof GraphApiError) {
182
+ return graphError(err.message, err.code, err.status) as GraphResponse<T[]>;
183
+ }
184
+ return graphError(err instanceof Error ? err.message : errorMessage) as GraphResponse<T[]>;
185
+ }
186
+ if (!result.ok || !result.data) {
187
+ return graphError(
188
+ result.error?.message || errorMessage,
189
+ result.error?.code,
190
+ result.error?.status
191
+ ) as GraphResponse<T[]>;
192
+ }
193
+ items.push(...(result.data.value || []));
194
+ path = result.data['@odata.nextLink'] ? resolveNextPath(result.data['@odata.nextLink']!, baseUrl) : '';
195
+ }
196
+ return graphResult(items);
197
+ }
198
+
199
+ export async function fetchGraphRaw(token: string, path: string, options: RequestInit = {}): Promise<Response> {
200
+ // codeql[js/file-access-to-http]: Bearer token may come from the local OAuth cache; path is a Graph API path string.
201
+ return fetch(`${GRAPH_BASE_URL}${path}`, {
202
+ ...options,
203
+ headers: {
204
+ Authorization: `Bearer ${token}`,
205
+ ...(options.headers || {})
206
+ }
207
+ });
208
+ }
209
+
210
+ export async function callGraphAt<T>(
211
+ baseUrl: string,
212
+ token: string,
213
+ path: string,
214
+ options: RequestInit = {},
215
+ expectJson: boolean = true
216
+ ): Promise<GraphResponse<T>> {
217
+ const controller = new AbortController();
218
+ const timeout = setTimeout(() => controller.abort(), GRAPH_TIMEOUT_MS);
219
+
220
+ let response: Response;
221
+ try {
222
+ // codeql[js/file-access-to-http]: Bearer token may come from the local OAuth cache; request body is JSON or binary from callers.
223
+ response = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, {
224
+ ...options,
225
+ headers: {
226
+ Authorization: `Bearer ${token}`,
227
+ ...(expectJson ? { Accept: 'application/json' } : {}),
228
+ ...(options.body && !(options.body instanceof Uint8Array) && !(options.body instanceof ArrayBuffer)
229
+ ? { 'Content-Type': 'application/json' }
230
+ : {}),
231
+ ...(options.headers || {})
232
+ },
233
+ signal: controller.signal
234
+ });
235
+ } catch (err) {
236
+ clearTimeout(timeout);
237
+ if (err instanceof Error && err.name === 'AbortError') {
238
+ throw new GraphApiError(`Graph request timed out after ${GRAPH_TIMEOUT_MS / 1000}s`, undefined, 408);
239
+ }
240
+ throw new GraphApiError(err instanceof Error ? err.message : 'Graph request failed');
241
+ }
242
+
243
+ if (!response.ok) {
244
+ let message = `Graph request failed: HTTP ${response.status}`;
245
+ let code: string | undefined;
246
+ try {
247
+ const json = (await response.json()) as { error?: { code?: string; message?: string } };
248
+ message = json.error?.message || message;
249
+ code = json.error?.code;
250
+ } catch {
251
+ clearTimeout(timeout);
252
+ throw new GraphApiError(message, code, response.status);
253
+ }
254
+ clearTimeout(timeout);
255
+ throw new GraphApiError(message, code, response.status);
256
+ }
257
+
258
+ if (!expectJson || response.status === 204) {
259
+ clearTimeout(timeout);
260
+ return graphResult(undefined as T);
261
+ }
262
+
263
+ const result = await response.json();
264
+ clearTimeout(timeout);
265
+ return graphResult(result as T);
266
+ }
267
+
268
+ export async function callGraph<T>(
269
+ token: string,
270
+ path: string,
271
+ options: RequestInit = {},
272
+ expectJson: boolean = true
273
+ ): Promise<GraphResponse<T>> {
274
+ return callGraphAt(GRAPH_BASE_URL, token, path, options, expectJson);
275
+ }
276
+
277
+ /** GET/PATCH a full Graph URL (e.g. `@odata.nextLink` / `@odata.deltaLink`). */
278
+ export async function callGraphAbsolute<T>(
279
+ token: string,
280
+ absoluteUrl: string,
281
+ options: RequestInit = {},
282
+ expectJson: boolean = true
283
+ ): Promise<GraphResponse<T>> {
284
+ const controller = new AbortController();
285
+ const timeout = setTimeout(() => controller.abort(), GRAPH_TIMEOUT_MS);
286
+
287
+ let response: Response;
288
+ try {
289
+ // codeql[js/file-access-to-http]: Bearer token may come from the local OAuth cache; absoluteUrl is Graph `@odata.nextLink` / `@odata.deltaLink`.
290
+ response = await fetch(absoluteUrl, {
291
+ ...options,
292
+ headers: {
293
+ Authorization: `Bearer ${token}`,
294
+ ...(expectJson ? { Accept: 'application/json' } : {}),
295
+ ...(options.body && !(options.body instanceof Uint8Array) && !(options.body instanceof ArrayBuffer)
296
+ ? { 'Content-Type': 'application/json' }
297
+ : {}),
298
+ ...(options.headers || {})
299
+ },
300
+ signal: controller.signal
301
+ });
302
+ } catch (err) {
303
+ clearTimeout(timeout);
304
+ if (err instanceof Error && err.name === 'AbortError') {
305
+ throw new GraphApiError(`Graph request timed out after ${GRAPH_TIMEOUT_MS / 1000}s`, undefined, 408);
306
+ }
307
+ throw new GraphApiError(err instanceof Error ? err.message : 'Graph request failed');
308
+ }
309
+
310
+ if (!response.ok) {
311
+ let message = `Graph request failed: HTTP ${response.status}`;
312
+ let code: string | undefined;
313
+ try {
314
+ const json = (await response.json()) as { error?: { code?: string; message?: string } };
315
+ message = json.error?.message || message;
316
+ code = json.error?.code;
317
+ } catch {
318
+ clearTimeout(timeout);
319
+ throw new GraphApiError(message, code, response.status);
320
+ }
321
+ clearTimeout(timeout);
322
+ throw new GraphApiError(message, code, response.status);
323
+ }
324
+
325
+ if (!expectJson || response.status === 204) {
326
+ clearTimeout(timeout);
327
+ return graphResult(undefined as T);
328
+ }
329
+
330
+ const result = await response.json();
331
+ clearTimeout(timeout);
332
+ return graphResult(result as T);
333
+ }
334
+
335
+ function buildItemPath(reference?: DriveItemReference): string {
336
+ if (!reference?.id) return '/me/drive/root';
337
+
338
+ const drivePrefix = reference.driveId ? `/drives/${encodeURIComponent(reference.driveId)}` : '/me/drive';
339
+ return `${drivePrefix}/items/${encodeURIComponent(reference.id)}`;
340
+ }
341
+
342
+ /**
343
+ * Encode a query string for Graph Drive search.
344
+ *
345
+ * encodeURIComponent encodes most characters, but it leaves certain characters
346
+ * like apostrophes and parentheses unescaped. AQS search uses single-quoted
347
+ * strings in the URL path (for example, search(q='...')), so we also percent-
348
+ * encode [!'()*] to prevent query syntax injection and keep the path safe.
349
+ *
350
+ * @param query - Raw search query string
351
+ * @returns Percent-encoded query safe for use in Graph search URLs
352
+ */
353
+ function encodeGraphSearchQuery(query: string): string {
354
+ return encodeURIComponent(query).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
355
+ }
356
+
357
+ export async function listFiles(token: string, folder?: DriveItemReference): Promise<GraphResponse<DriveItem[]>> {
358
+ const basePath = buildItemPath(folder);
359
+ return fetchAllPages<DriveItem>(token, `${basePath}/children`, 'Failed to list files');
360
+ }
361
+
362
+ export async function searchFiles(token: string, query: string): Promise<GraphResponse<DriveItem[]>> {
363
+ return fetchAllPages<DriveItem>(
364
+ token,
365
+ `/me/drive/root/search(q='${encodeGraphSearchQuery(query)}')`,
366
+ 'Failed to search files'
367
+ );
368
+ }
369
+
370
+ export async function getFileMetadata(token: string, itemId: string): Promise<GraphResponse<DriveItem>> {
371
+ try {
372
+ return await callGraph<DriveItem>(token, `/me/drive/items/${encodeURIComponent(itemId)}`);
373
+ } catch (err) {
374
+ if (err instanceof GraphApiError) {
375
+ return graphError(err.message, err.code, err.status);
376
+ }
377
+ return graphError(err instanceof Error ? err.message : 'Failed to get file metadata');
378
+ }
379
+ }
380
+
381
+ export interface UploadLargeResult {
382
+ uploadUrl: string;
383
+ expirationDateTime?: string;
384
+ driveItem?: DriveItem;
385
+ }
386
+
387
+ export async function uploadFile(
388
+ token: string,
389
+ localPath: string,
390
+ folder?: DriveItemReference
391
+ ): Promise<GraphResponse<DriveItem>> {
392
+ try {
393
+ const absolutePath = resolve(localPath);
394
+ const st0 = await stat(absolutePath).catch(() => null);
395
+ if (!st0?.isFile()) return graphError(`Not a file or not found: ${absolutePath}`);
396
+ let resolvedPath: string;
397
+ try {
398
+ resolvedPath = await realpath(absolutePath);
399
+ } catch {
400
+ resolvedPath = absolutePath;
401
+ }
402
+ const fileStats = await stat(resolvedPath);
403
+ if (!fileStats.isFile()) return graphError(`Not a file: ${resolvedPath}`);
404
+ if (fileStats.size > 250 * 1024 * 1024) {
405
+ return graphError('File exceeds 250MB simple upload limit. Use upload-large instead.');
406
+ }
407
+
408
+ const fileName = basename(resolvedPath);
409
+ const folderPath = folder?.id ? `${buildItemPath(folder)}:/` : '/me/drive/root:/';
410
+ const stream = createReadStream(resolvedPath);
411
+ try {
412
+ // codeql[js/file-access-to-http]: intentional upload of a user-selected local file to Microsoft Graph after resolve+stat+isFile.
413
+ return await callGraph<DriveItem>(token, `${folderPath}${encodeURIComponent(fileName)}:/content`, {
414
+ method: 'PUT',
415
+ body: Readable.toWeb(stream) as unknown as BodyInit,
416
+ headers: {
417
+ 'Content-Type': 'application/octet-stream'
418
+ }
419
+ });
420
+ } catch (err) {
421
+ if (err instanceof GraphApiError) {
422
+ return graphError(err.message, err.code, err.status);
423
+ }
424
+ return graphError(err instanceof Error ? err.message : 'Upload failed');
425
+ } finally {
426
+ stream.destroy();
427
+ }
428
+ } catch (err) {
429
+ return graphError(err instanceof Error ? err.message : 'Upload failed');
430
+ }
431
+ }
432
+
433
+ export async function uploadLargeFile(
434
+ token: string,
435
+ localPath: string,
436
+ folder?: DriveItemReference
437
+ ): Promise<GraphResponse<UploadLargeResult>> {
438
+ try {
439
+ const absolutePath = resolve(localPath);
440
+ const st0 = await stat(absolutePath).catch(() => null);
441
+ if (!st0?.isFile()) return graphError(`Not a file or not found: ${absolutePath}`);
442
+ let resolvedPath: string;
443
+ try {
444
+ resolvedPath = await realpath(absolutePath);
445
+ } catch {
446
+ resolvedPath = absolutePath;
447
+ }
448
+ let fileHandle: any;
449
+ try {
450
+ fileHandle = await open(resolvedPath, 'r');
451
+ } catch (err: any) {
452
+ return graphError(`Failed to open file: ${err.message}`);
453
+ }
454
+
455
+ try {
456
+ const fileStats = await fileHandle.stat();
457
+ if (!fileStats.isFile()) return graphError(`Not a file: ${resolvedPath}`);
458
+ if (fileStats.size > 4 * 1024 * 1024 * 1024) {
459
+ return graphError('File exceeds 4GB large upload limit.');
460
+ }
461
+
462
+ const fileName = basename(resolvedPath);
463
+ const folderPath = folder?.id ? `${buildItemPath(folder)}:/` : '/me/drive/root:/';
464
+
465
+ // Step 1: Create the upload session
466
+ let sessionResult: GraphResponse<UploadLargeResult>;
467
+ try {
468
+ sessionResult = await callGraph<UploadLargeResult>(
469
+ token,
470
+ `${folderPath}${encodeURIComponent(fileName)}:/createUploadSession`,
471
+ {
472
+ method: 'POST',
473
+ body: JSON.stringify({ item: { '@microsoft.graph.conflictBehavior': 'replace', name: fileName } })
474
+ }
475
+ );
476
+ } catch (err) {
477
+ if (err instanceof GraphApiError) {
478
+ return graphError(err.message, err.code, err.status);
479
+ }
480
+ return graphError(err instanceof Error ? err.message : 'Failed to create upload session');
481
+ }
482
+
483
+ if (!sessionResult.ok || !sessionResult.data) {
484
+ return sessionResult;
485
+ }
486
+
487
+ const { uploadUrl, expirationDateTime } = sessionResult.data;
488
+
489
+ // Step 2: Upload the file in chunks
490
+ const fileSize = fileStats.size;
491
+
492
+ if (fileSize === 0) {
493
+ return graphError('Cannot upload zero-byte files using large upload session. Use simple upload instead.');
494
+ }
495
+
496
+ const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
497
+ const chunkBuffer = new Uint8Array(CHUNK_SIZE);
498
+
499
+ let offset = 0;
500
+ let lastSuccessfulResponse: Response | null = null;
501
+
502
+ while (offset < fileSize) {
503
+ const chunkLength = Math.min(CHUNK_SIZE, fileSize - offset);
504
+ const { bytesRead } = await fileHandle.read(chunkBuffer, 0, chunkLength, offset);
505
+
506
+ if (bytesRead === 0) break;
507
+
508
+ const endOffset = offset + bytesRead - 1;
509
+ const contentRange = `bytes ${offset}-${endOffset}/${fileSize}`;
510
+ const chunkData = chunkBuffer.subarray(0, bytesRead);
511
+
512
+ const controller = new AbortController();
513
+ const timeoutId = setTimeout(() => controller.abort(), GRAPH_TIMEOUT_MS);
514
+
515
+ try {
516
+ lastSuccessfulResponse = await fetch(uploadUrl, {
517
+ method: 'PUT',
518
+ headers: {
519
+ 'Content-Length': String(bytesRead),
520
+ 'Content-Range': contentRange
521
+ },
522
+ body: chunkData,
523
+ signal: controller.signal,
524
+ redirect: 'manual'
525
+ });
526
+ } catch (err: any) {
527
+ if (err && err.name === 'AbortError') {
528
+ return graphError(
529
+ `Chunk upload timed out after ${GRAPH_TIMEOUT_MS} ms at offset ${offset}`,
530
+ 'RequestTimeout',
531
+ 408
532
+ );
533
+ }
534
+ throw err;
535
+ } finally {
536
+ clearTimeout(timeoutId);
537
+ }
538
+
539
+ if (!lastSuccessfulResponse.ok) {
540
+ const errorBody = await lastSuccessfulResponse.text().catch(() => '');
541
+ return graphError(
542
+ `Chunk upload failed at offset ${offset} (HTTP ${lastSuccessfulResponse.status}): ${errorBody}`,
543
+ String(lastSuccessfulResponse.status),
544
+ lastSuccessfulResponse.status
545
+ );
546
+ }
547
+
548
+ offset += bytesRead;
549
+
550
+ if (offset < fileSize) {
551
+ await lastSuccessfulResponse.text().catch(() => {});
552
+ }
553
+ }
554
+
555
+ if (offset !== fileSize) {
556
+ return graphError(`Upload stopped early. Expected to upload ${fileSize} bytes but uploaded ${offset}`);
557
+ }
558
+
559
+ // Step 3: Parse the final response
560
+ if (lastSuccessfulResponse) {
561
+ const status = lastSuccessfulResponse.status;
562
+ if (status === 200 || status === 201) {
563
+ let body: unknown;
564
+ try {
565
+ body = await lastSuccessfulResponse.json();
566
+ } catch {
567
+ return graphError('Upload completed but failed to parse final response');
568
+ }
569
+
570
+ const maybeDriveItem = body as Partial<DriveItem> | null;
571
+ if (
572
+ maybeDriveItem &&
573
+ typeof maybeDriveItem === 'object' &&
574
+ typeof maybeDriveItem.id === 'string' &&
575
+ typeof maybeDriveItem.name === 'string'
576
+ ) {
577
+ const driveItem = maybeDriveItem as DriveItem;
578
+ return {
579
+ ok: true,
580
+ data: { uploadUrl, expirationDateTime, driveItem }
581
+ };
582
+ }
583
+
584
+ return graphError('Upload completed but final response did not contain drive item metadata');
585
+ }
586
+ }
587
+
588
+ return graphError('Upload completed but final response was not valid');
589
+ } finally {
590
+ await fileHandle.close();
591
+ }
592
+ } catch (err) {
593
+ return graphError(err instanceof Error ? err.message : 'Upload failed');
594
+ }
595
+ }
596
+
597
+ export async function downloadFile(
598
+ token: string,
599
+ itemId: string,
600
+ outputPath?: string,
601
+ item?: DriveItem
602
+ ): Promise<GraphResponse<{ path: string; item: DriveItem }>> {
603
+ let resolvedItem = item;
604
+ let targetPath: string | undefined;
605
+ let tmpPath: string | undefined;
606
+
607
+ // Step 1: resolve item metadata
608
+ if (!resolvedItem) {
609
+ const metadata = await getFileMetadata(token, itemId);
610
+ if (!metadata.ok || !metadata.data) {
611
+ return graphError(
612
+ metadata.error?.message || 'Failed to fetch file metadata before download',
613
+ metadata.error?.code,
614
+ metadata.error?.status
615
+ );
616
+ }
617
+ resolvedItem = metadata.data;
618
+ }
619
+
620
+ const downloadUrl = resolvedItem['@microsoft.graph.downloadUrl'];
621
+ if (!downloadUrl) {
622
+ return graphError('Download URL missing from Graph metadata response.');
623
+ }
624
+
625
+ // Security: validate downloadUrl before fetching to prevent SSRF and token exfiltration
626
+ let url: URL;
627
+ try {
628
+ url = new URL(downloadUrl);
629
+ } catch {
630
+ return graphError('Download URL is not a valid URL.');
631
+ }
632
+
633
+ if (url.protocol !== 'https:') {
634
+ return graphError('Download URL has unsupported scheme. Only HTTPS is permitted.');
635
+ }
636
+
637
+ // Allowed Microsoft domains for download URLs (supports both exact and suffix matching)
638
+ // Includes sovereign cloud domains: .us (GCC High/DoD), .cn (China/21Vianet)
639
+ const allowedDomains = [
640
+ 'onedrive.live.com',
641
+ 'sharepoint.com',
642
+ 'sharepoint.us',
643
+ 'sharepoint.cn',
644
+ 'graph.microsoft.com',
645
+ 'graph.microsoft.us',
646
+ 'microsoftgraph.chinacloudapi.cn',
647
+ 'files.1drv.com'
648
+ ];
649
+
650
+ const isAllowedHost = allowedDomains.some((domain) => url.hostname === domain || url.hostname.endsWith(`.${domain}`));
651
+
652
+ if (!isAllowedHost) {
653
+ return graphError(`Download URL hostname '${url.hostname}' is not in the allowlist.`);
654
+ }
655
+
656
+ targetPath = resolve(outputPath || defaultDownloadPath(basename(resolvedItem.name || itemId)));
657
+ await mkdir(dirname(targetPath), { recursive: true });
658
+
659
+ // Step 2: retry loop for transient network errors
660
+ const MAX_RETRIES = 2;
661
+ let lastError: unknown;
662
+
663
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
664
+ try {
665
+ const response = await fetch(url.toString(), { redirect: 'manual' });
666
+
667
+ // Reject redirects to prevent SSRF bypass
668
+ if (response.status >= 300 && response.status < 400) {
669
+ return graphError('Download failed: redirects are not permitted for security reasons');
670
+ }
671
+
672
+ if (!response.ok) {
673
+ // Non-transient HTTP errors: don't retry
674
+ return graphError(`Download failed: HTTP ${response.status}`);
675
+ }
676
+ if (!response.body) {
677
+ return graphError('Download failed: response body missing');
678
+ }
679
+
680
+ const contentLength = response.headers.get('content-length');
681
+ const tmpFileName = `.${resolvedItem.name ?? itemId}.${randomBytes(8).toString('hex')}.tmp`;
682
+ tmpPath = resolve(dirname(targetPath), 'tmp', tmpFileName);
683
+ await mkdir(dirname(tmpPath), { recursive: true });
684
+
685
+ const bytesWritten = await streamWebToFile(response.body, tmpPath);
686
+
687
+ // Verify integrity when Content-Length is available
688
+ if (contentLength !== null) {
689
+ const expected = Number(contentLength);
690
+ if (!Number.isFinite(expected)) {
691
+ await unlink(tmpPath).catch(() => {});
692
+ tmpPath = undefined;
693
+ return graphError(`Download failed: invalid Content-Length header`);
694
+ }
695
+ if (bytesWritten !== expected) {
696
+ // Clean up corrupted temp file
697
+ await unlink(tmpPath).catch(() => {});
698
+ tmpPath = undefined;
699
+ return graphError(`Download failed: size mismatch (expected ${expected} bytes, got ${bytesWritten})`);
700
+ }
701
+ }
702
+
703
+ // Atomic rename: temp → final path
704
+ await rename(tmpPath, targetPath);
705
+
706
+ return graphResult({ path: targetPath, item: resolvedItem });
707
+ } catch (err) {
708
+ lastError = err;
709
+
710
+ // Clean up temp file on any error
711
+ if (tmpPath) {
712
+ await unlink(tmpPath).catch(() => {});
713
+ tmpPath = undefined;
714
+ }
715
+
716
+ // Only retry on network/stream errors, not on business-logic errors (size mismatch etc.)
717
+ const isRetryable =
718
+ err instanceof Error &&
719
+ (err.message.includes('fetch failed') ||
720
+ err.message.includes('network') ||
721
+ err.message.includes('ECONNREFUSED') ||
722
+ err.message.includes('ETIMEDOUT') ||
723
+ err.message.includes('ENOTFOUND'));
724
+
725
+ if (isRetryable && attempt < MAX_RETRIES) {
726
+ continue;
727
+ }
728
+ break;
729
+ }
730
+ }
731
+
732
+ return graphError(lastError instanceof Error ? lastError.message : 'Download failed');
733
+ }
734
+
735
+ export async function deleteFile(token: string, itemId: string): Promise<GraphResponse<void>> {
736
+ try {
737
+ return await callGraph<void>(token, `/me/drive/items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }, false);
738
+ } catch (err) {
739
+ if (err instanceof GraphApiError) {
740
+ return graphError(err.message, err.code, err.status);
741
+ }
742
+ return graphError(err instanceof Error ? err.message : 'Failed to delete file');
743
+ }
744
+ }
745
+
746
+ export async function shareFile(
747
+ token: string,
748
+ itemId: string,
749
+ type: 'view' | 'edit' = 'view',
750
+ scope: 'anonymous' | 'organization' = 'organization'
751
+ ): Promise<GraphResponse<SharingLinkResult>> {
752
+ let result: GraphResponse<{ link?: SharingLinkResult }>;
753
+ try {
754
+ result = await callGraph<{ link?: SharingLinkResult }>(
755
+ token,
756
+ `/me/drive/items/${encodeURIComponent(itemId)}/createLink`,
757
+ {
758
+ method: 'POST',
759
+ body: JSON.stringify({ type, scope })
760
+ }
761
+ );
762
+ } catch (err) {
763
+ if (err instanceof GraphApiError) {
764
+ return graphError(err.message, err.code, err.status);
765
+ }
766
+ return graphError(err instanceof Error ? err.message : 'Failed to share file');
767
+ }
768
+
769
+ if (!result.ok || !result.data) return result as GraphResponse<SharingLinkResult>;
770
+ return graphResult(result.data.link || {});
771
+ }
772
+
773
+ export async function checkoutFile(token: string, itemId: string): Promise<GraphResponse<void>> {
774
+ try {
775
+ return await callGraph<void>(
776
+ token,
777
+ `/me/drive/items/${encodeURIComponent(itemId)}/checkout`,
778
+ { method: 'POST' },
779
+ false
780
+ );
781
+ } catch (err) {
782
+ if (err instanceof GraphApiError) {
783
+ return graphError(err.message, err.code, err.status);
784
+ }
785
+ return graphError(err instanceof Error ? err.message : 'Failed to checkout file');
786
+ }
787
+ }
788
+
789
+ export async function checkinFile(
790
+ token: string,
791
+ itemId: string,
792
+ comment?: string
793
+ ): Promise<GraphResponse<CheckinResult>> {
794
+ let result: GraphResponse<void>;
795
+ try {
796
+ result = await callGraph<void>(
797
+ token,
798
+ `/me/drive/items/${encodeURIComponent(itemId)}/checkin`,
799
+ {
800
+ method: 'POST',
801
+ body: JSON.stringify({ comment: comment || '' })
802
+ },
803
+ false
804
+ );
805
+ } catch (err) {
806
+ if (err instanceof GraphApiError) {
807
+ return graphError(err.message, err.code, err.status);
808
+ }
809
+ return graphError(err instanceof Error ? err.message : 'Check-in failed');
810
+ }
811
+
812
+ if (!result.ok) {
813
+ return graphError(result.error?.message || 'Check-in failed', result.error?.code, result.error?.status);
814
+ }
815
+
816
+ const item = await getFileMetadata(token, itemId);
817
+ if (!item.ok || !item.data) {
818
+ return graphError(
819
+ item.error?.message || 'Checked in, but failed to refresh file metadata',
820
+ item.error?.code,
821
+ item.error?.status
822
+ );
823
+ }
824
+
825
+ return graphResult({ item: item.data, checkedIn: true, comment });
826
+ }
827
+
828
+ export async function createOfficeCollaborationLink(
829
+ token: string,
830
+ itemId: string,
831
+ options: { lock?: boolean } = {}
832
+ ): Promise<GraphResponse<OfficeCollabLinkResult>> {
833
+ const item = await getFileMetadata(token, itemId);
834
+ if (!item.ok || !item.data) {
835
+ return graphError(item.error?.message || 'Failed to fetch file metadata', item.error?.code, item.error?.status);
836
+ }
837
+
838
+ const extension = item.data.name.includes('.') ? item.data.name.split('.').pop()?.toLowerCase() : undefined;
839
+ const supported = new Set(['docx', 'xlsx', 'pptx']);
840
+ if (!extension || !supported.has(extension)) {
841
+ return graphError(
842
+ 'Office Online collaboration is only supported for .docx, .xlsx, and .pptx files. Convert legacy Office formats first.'
843
+ );
844
+ }
845
+
846
+ if (options.lock) {
847
+ const lock = await checkoutFile(token, itemId);
848
+ if (!lock.ok) {
849
+ return graphError(
850
+ lock.error?.message || 'Failed to checkout file before sharing',
851
+ lock.error?.code,
852
+ lock.error?.status
853
+ );
854
+ }
855
+ }
856
+
857
+ const link = await shareFile(token, itemId, 'edit', 'organization');
858
+ if (!link.ok || !link.data) {
859
+ if (options.lock) {
860
+ await checkinFile(token, itemId);
861
+ }
862
+ return graphError(
863
+ link.error?.message || 'Failed to create collaboration link',
864
+ link.error?.code,
865
+ link.error?.status
866
+ );
867
+ }
868
+
869
+ return graphResult({
870
+ item: item.data,
871
+ link: link.data,
872
+ collaborationUrl: item.data.webUrl || link.data.webUrl,
873
+ lockAcquired: !!options.lock
874
+ });
875
+ }
876
+
877
+ export function defaultDownloadPath(fileName: string): string {
878
+ return resolve(homedir(), 'Downloads', basename(fileName));
879
+ }
880
+
881
+ export async function listFileVersions(token: string, itemId: string): Promise<GraphResponse<DriveItemVersion[]>> {
882
+ return fetchAllPages<DriveItemVersion>(
883
+ token,
884
+ `/me/drive/items/${encodeURIComponent(itemId)}/versions`,
885
+ 'Failed to list versions'
886
+ );
887
+ }
888
+
889
+ export async function restoreFileVersion(
890
+ token: string,
891
+ itemId: string,
892
+ versionId: string
893
+ ): Promise<GraphResponse<void>> {
894
+ try {
895
+ return await callGraph<void>(
896
+ token,
897
+ `/me/drive/items/${encodeURIComponent(itemId)}/versions/${encodeURIComponent(versionId)}/restoreVersion`,
898
+ { method: 'POST' },
899
+ false
900
+ );
901
+ } catch (err) {
902
+ if (err instanceof GraphApiError) {
903
+ return graphError(err.message, err.code, err.status);
904
+ }
905
+ return graphError(err instanceof Error ? err.message : 'Failed to restore version');
906
+ }
907
+ }
908
+
909
+ export async function cleanupDownloadedFile(path: string): Promise<void> {
910
+ try {
911
+ await unlink(path);
912
+ } catch {
913
+ // Ignore cleanup failures
914
+ }
915
+ }
916
+
917
+ export interface FileAnalytics {
918
+ allTime?: {
919
+ access?: { actionCount?: number; actorCount?: number };
920
+ };
921
+ lastSevenDays?: {
922
+ access?: { actionCount?: number; actorCount?: number };
923
+ };
924
+ }
925
+
926
+ export async function getFileAnalytics(token: string, itemId: string): Promise<GraphResponse<FileAnalytics>> {
927
+ const [allTimeResult, lastSevenDaysResult] = await Promise.allSettled([
928
+ callGraph<FileAnalytics['allTime']>(token, `/me/drive/items/${encodeURIComponent(itemId)}/analytics/allTime`),
929
+ callGraph<FileAnalytics['lastSevenDays']>(
930
+ token,
931
+ `/me/drive/items/${encodeURIComponent(itemId)}/analytics/lastSevenDays`
932
+ )
933
+ ]);
934
+
935
+ const analytics: FileAnalytics = {};
936
+
937
+ if (allTimeResult.status === 'fulfilled' && allTimeResult.value.ok && allTimeResult.value.data) {
938
+ analytics.allTime = allTimeResult.value.data;
939
+ }
940
+
941
+ if (lastSevenDaysResult.status === 'fulfilled' && lastSevenDaysResult.value.ok && lastSevenDaysResult.value.data) {
942
+ analytics.lastSevenDays = lastSevenDaysResult.value.data;
943
+ }
944
+
945
+ if (allTimeResult.status === 'rejected' && lastSevenDaysResult.status === 'rejected') {
946
+ const error = allTimeResult.reason;
947
+ if (error instanceof GraphApiError) {
948
+ return graphError(error.message, error.code, error.status);
949
+ }
950
+ return graphError(error instanceof Error ? error.message : 'Failed to get file analytics');
951
+ }
952
+
953
+ return graphResult(analytics);
954
+ }
955
+
956
+ export async function downloadConvertedFile(
957
+ token: string,
958
+ itemId: string,
959
+ format: string = 'pdf',
960
+ outputPath?: string
961
+ ): Promise<GraphResponse<{ path: string }>> {
962
+ let tmpPath: string | undefined;
963
+ try {
964
+ const metadata = await getFileMetadata(token, itemId);
965
+ if (!metadata.ok || !metadata.data) {
966
+ return graphError(
967
+ metadata.error?.message || 'Failed to fetch file metadata',
968
+ metadata.error?.code,
969
+ metadata.error?.status
970
+ );
971
+ }
972
+
973
+ const item = metadata.data;
974
+ const originalName = item.name || itemId;
975
+ const newName = originalName.includes('.')
976
+ ? `${originalName.substring(0, originalName.lastIndexOf('.'))}.${format}`
977
+ : `${originalName}.${format}`;
978
+
979
+ const targetPath = resolve(outputPath || defaultDownloadPath(newName));
980
+ await mkdir(dirname(targetPath), { recursive: true });
981
+
982
+ const path = `/me/drive/items/${encodeURIComponent(itemId)}/content?format=${encodeURIComponent(format)}`;
983
+
984
+ const redirectResponse = await fetchGraphRaw(token, path, { redirect: 'manual' });
985
+
986
+ if (redirectResponse.status < 300 || redirectResponse.status >= 400) {
987
+ if (!redirectResponse.ok) {
988
+ return graphError(`Failed to convert file: HTTP ${redirectResponse.status}`);
989
+ }
990
+ return graphError('Expected a redirect for file conversion, but got a direct response.');
991
+ }
992
+
993
+ const location = redirectResponse.headers.get('location');
994
+ if (!location) {
995
+ return graphError('Missing redirect location for converted file');
996
+ }
997
+
998
+ let url: URL;
999
+ try {
1000
+ url = new URL(location);
1001
+ } catch {
1002
+ return graphError('Redirect location is not a valid URL.');
1003
+ }
1004
+
1005
+ if (url.protocol !== 'https:') {
1006
+ return graphError('Redirect URL has unsupported scheme. Only HTTPS is permitted.');
1007
+ }
1008
+
1009
+ const allowedDomains = [
1010
+ 'onedrive.live.com',
1011
+ 'sharepoint.com',
1012
+ 'sharepoint.us',
1013
+ 'sharepoint.cn',
1014
+ 'graph.microsoft.com',
1015
+ 'graph.microsoft.us',
1016
+ 'microsoftgraph.chinacloudapi.cn',
1017
+ 'files.1drv.com'
1018
+ ];
1019
+
1020
+ const isAllowedHost = allowedDomains.some(
1021
+ (domain) => url.hostname === domain || url.hostname.endsWith(`.${domain}`)
1022
+ );
1023
+
1024
+ if (!isAllowedHost) {
1025
+ return graphError(`Redirect URL hostname '${url.hostname}' is not in the allowlist.`);
1026
+ }
1027
+
1028
+ const response = await fetch(url.toString(), { redirect: 'manual' });
1029
+
1030
+ if (response.status >= 300 && response.status < 400) {
1031
+ return graphError('Download failed: further redirects are not permitted for security reasons');
1032
+ }
1033
+
1034
+ if (!response.ok) {
1035
+ return graphError(`Failed to download converted file: HTTP ${response.status}`);
1036
+ }
1037
+ if (!response.body) {
1038
+ return graphError('Response body is empty');
1039
+ }
1040
+
1041
+ const tmpFileName = `.${newName}.${randomBytes(8).toString('hex')}.tmp`;
1042
+ tmpPath = resolve(dirname(targetPath), 'tmp', tmpFileName);
1043
+ await mkdir(dirname(tmpPath), { recursive: true });
1044
+
1045
+ await streamWebToFile(response.body, tmpPath);
1046
+ await rename(tmpPath, targetPath);
1047
+
1048
+ return graphResult({ path: targetPath });
1049
+ } catch (err) {
1050
+ if (tmpPath) {
1051
+ await unlink(tmpPath).catch(() => {});
1052
+ }
1053
+ if (err instanceof GraphApiError) {
1054
+ return graphError(err.message, err.code, err.status);
1055
+ }
1056
+ return graphError(err instanceof Error ? err.message : 'Failed to download converted file');
1057
+ }
1058
+ }