diff --git a/.gitignore b/.gitignore index 04a5d8b..391dc5f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ dist/ coverage/ *.tsbuildinfo +/.pnpm-store/ # Local environment and OS files .env diff --git a/README.md b/README.md index 7968913..02097fc 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,110 @@ hostile symlinks. Transport failures throw `TransportError` with a stable `code`, actionable `guidance`, and the original `cause` when filesystem or mapper operations fail. +### SharePoint Transport + +`SharePointTransport` publishes DOCX bytes to a SharePoint document library via +Microsoft Graph app-only auth. It implements the same create-or-update +`Transport.upload(canonicalId, docx)` contract as `LocalFileTransport`. + +```ts +import { + SharePointTransport, + convertMarkdownToDocx, +} from "@agentic-tooling/polydoc-core"; + +const docx = await convertMarkdownToDocx({ + markdown, + referenceDocxPath: "./reference.docx", +}); + +const transport = new SharePointTransport({ + siteId: "contoso.sharepoint.com,site-collection-id,site-id", + driveId: "document-library-drive-id", + baseFolder: "TeamWiki", + tenantId, + clientId, + clientSecret, + mapCanonicalId: (canonicalId) => `published/${canonicalId}`, +}); + +const destination = await transport.upload("handbook/intro", docx); + +console.log(destination.destinationId); // Microsoft Graph driveItem id +console.log(destination.path); // TeamWiki/published/handbook/intro.docx +console.log(destination.webUrl); // optional Graph driveItem webUrl +``` + +Auth uses `@azure/msal-node` with the Microsoft Graph scope +`https://graph.microsoft.com/.default`. `Sites.Selected` is the required Entra +application permission to admin-consent on the app registration, and a separate +site-specific `write` grant must be provisioned out of band. The library does +not request `Sites.Selected` as an OAuth scope, does not require +tenant-wide `Sites.ReadWrite.All`, does not provision site grants, and does not +verify token roles. + +Typical setup is: + +- Create or reuse an Entra app registration and client credential. +- Add the Microsoft Graph application permission `Sites.Selected` and grant + tenant admin consent. +- Grant that app `write` permission to the target SharePoint site using the + Microsoft Graph site permissions API or an equivalent admin process. +- Pass `tenantId`, `clientId`, and `clientSecret` from the consuming + application. This package never reads environment variables directly and does + not log or expose secrets. + +Consumers that use managed identity, certificates, or their own token cache can +pass `accessTokenProvider` instead of client-secret fields. The constructor +rejects configurations that provide both auth shapes. + +Uploads use Microsoft Graph simple upload: + +```txt +PUT /v1.0/sites/{site-id}/drives/{drive-id}/root:/{relative-path}:/content +``` + +The body is the DOCX bytes with DOCX content type. Every 2xx response is treated +as success, but the response body must include a non-empty Graph driveItem `id`. +The returned `destinationId` and `driveItemId` are that stable Graph ID, which +callers should persist if they need a durable handle. Re-uploading the same +canonical ID maps to the same relative path and lets SharePoint/Graph update the +existing item/version history by path; the library does not keep local upload +state. + +Path mapping is deterministic. By default, a path-safe canonical ID becomes the +relative SharePoint destination. `mapCanonicalId` can map opaque IDs such as +`urn:teamwiki:note:123` to a SharePoint-safe relative path. `.docx` is appended +once, `baseFolder` is prepended when provided, and the final decoded destination +is validated before auth or network work. Path segments are encoded +individually, so spaces, `#`, and `%` are percent-encoded instead of corrupting +the URL. + +Destination validation rejects traversal, empty segments, control characters, +segments with leading or trailing whitespace, decoded segments over 255 +characters, decoded relative paths over 400 characters, double quote, `*`, `:`, +`<`, `>`, `?`, `\`, `|`, structural slash misuse, names beginning with `~` or +`~$`, segments ending with `.`, Windows device names including `COM0`-`COM9` and +`LPT0`-`LPT9`, `.lock`, `desktop.ini`, names containing `_vti_`, and root-level +`Forms`. The 400-character check applies to the library-relative destination; +SharePoint's actual decoded full-path limit also includes the site and document +library prefix, so Microsoft Graph can still reject a shorter relative path in a +deep site or library. + +Microsoft Graph simple upload supports files up to 250 MB. `SharePointTransport` +checks this limit before token acquisition or upload. This package only supports +forward Markdown-to-DOCX publishing; it does not import, diff, or reverse-convert +SharePoint documents. + +Microsoft references: + +- [Client credentials and `.default`](https://learn.microsoft.com/en-us/graph/auth-v2-service) +- [MSAL Node client credential requests](https://learn.microsoft.com/en-us/entra/msal/javascript/node/acquire-token-requests) +- [Sites.Selected permission](https://learn.microsoft.com/en-us/graph/permissions-reference#sitesselected) +- [Site-specific permission grants](https://learn.microsoft.com/en-us/graph/api/site-post-permissions?view=graph-rest-1.0) +- [DriveItem simple upload](https://learn.microsoft.com/en-us/graph/api/driveitem-put-content?view=graph-rest-1.0) +- [OneDrive/SharePoint path addressing](https://learn.microsoft.com/en-us/graph/onedrive-addressing-driveitems) + ## Pandoc Contract This package shells out to the system `pandoc` binary through `execa` with an diff --git a/package.json b/package.json index cafc47d..723702e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "vitest": "^4.1.10" }, "dependencies": { + "@azure/msal-node": "5.4.2", "execa": "^8.0.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d29a99d..2d8bdf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@azure/msal-node': + specifier: 5.4.2 + version: 5.4.2 execa: specifier: ^8.0.1 version: 8.0.1 @@ -33,6 +36,14 @@ importers: packages: + '@azure/msal-common@16.11.2': + resolution: {integrity: sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.4.2': + resolution: {integrity: sha512-fnqOpGYAV+i0RH4W5HB6Oy1IhqGZoCdnp7Y2Sa9k18FlT8aBkCA7L8Hv19hUHLDUK6kVjUO29AfnGX6wgAHyNg==} + engines: {node: '>=20'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -420,6 +431,9 @@ packages: ast-v8-to-istanbul@1.0.5: resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -435,6 +449,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} @@ -503,6 +520,16 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -577,6 +604,27 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -594,6 +642,9 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -638,6 +689,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -798,6 +852,13 @@ packages: snapshots: + '@azure/msal-common@16.11.2': {} + + '@azure/msal-node@5.4.2': + dependencies: + '@azure/msal-common': 16.11.2 + jsonwebtoken: 9.0.3 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -1076,6 +1137,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + buffer-equal-constant-time@1.0.1: {} + chai@6.2.2: {} convert-source-map@2.0.0: {} @@ -1088,6 +1151,10 @@ snapshots: detect-libc@2.1.2: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + es-module-lexer@2.3.1: {} estree-walker@3.0.3: @@ -1144,6 +1211,30 @@ snapshots: js-tokens@10.0.0: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + lightningcss-android-arm64@1.33.0: optional: true @@ -1193,6 +1284,20 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1211,6 +1316,8 @@ snapshots: mimic-fn@4.0.0: {} + ms@2.1.3: {} + nanoid@3.3.16: {} npm-run-path@5.3.0: @@ -1260,6 +1367,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 + safe-buffer@5.2.1: {} + semver@7.8.5: {} shebang-command@2.0.0: diff --git a/src/index.ts b/src/index.ts index 0180476..1f62662 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,31 @@ export { PandocError, SUPPORTED_PANDOC_MAJOR, } from "./pandoc.js"; +export type { + SharePointAccessTokenProvider, + SharePointAccessTokenRequest, + SharePointClientSecretAccessTokenProviderOptions, + SharePointClientSecretCredentials, + SharePointConfidentialClient, + SharePointDestinationMapper, + SharePointTransportDestination, + SharePointTransportErrorCode, + SharePointTransportErrorContext, + SharePointTransportOptions, + SharePointTransportOptionsBase, +} from "./sharepoint.js"; +export { + createSharePointClientSecretAccessTokenProvider, + DOCX_MIME_TYPE, + encodeSharePointRelativePath, + MICROSOFT_GRAPH_DEFAULT_SCOPE, + MICROSOFT_GRAPH_V1_BASE_URL, + SHAREPOINT_REQUIRED_APPLICATION_PERMISSION, + SHAREPOINT_SIMPLE_UPLOAD_MAX_BYTES, + SharePointTransport, + SharePointTransportError, + validateSharePointDocxSize, +} from "./sharepoint.js"; export type { LocalFileDestinationMapper, LocalFileTransportDestination, diff --git a/src/sharepoint.ts b/src/sharepoint.ts new file mode 100644 index 0000000..ad58359 --- /dev/null +++ b/src/sharepoint.ts @@ -0,0 +1,749 @@ +import { ConfidentialClientApplication } from "@azure/msal-node"; + +import type { Transport, TransportUploadResult } from "./transport.js"; +import { TransportError, type TransportErrorCode } from "./transport.js"; + +export const MICROSOFT_GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default"; +export const SHAREPOINT_REQUIRED_APPLICATION_PERMISSION = "Sites.Selected"; +export const DOCX_MIME_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; +export const SHAREPOINT_SIMPLE_UPLOAD_MAX_BYTES = 250 * 1024 * 1024; +export const MICROSOFT_GRAPH_V1_BASE_URL = "https://graph.microsoft.com/v1.0"; + +export type SharePointTransportErrorCode = Extract< + TransportErrorCode, + | "SHAREPOINT_CONFIG_INVALID" + | "SHAREPOINT_AUTH_FAILED" + | "SHAREPOINT_PATH_INVALID" + | "SHAREPOINT_DOCX_TOO_LARGE" + | "SHAREPOINT_NETWORK_FAILED" + | "SHAREPOINT_HTTP_FAILED" + | "SHAREPOINT_RESPONSE_INVALID" +>; + +export interface SharePointTransportErrorContext { + readonly status?: number; + readonly requestId?: string; + readonly graphErrorCode?: string; +} + +export class SharePointTransportError extends TransportError { + readonly context: SharePointTransportErrorContext | undefined; + + constructor( + code: SharePointTransportErrorCode, + message: string, + guidance: string, + options: { + readonly cause?: unknown; + readonly context?: SharePointTransportErrorContext; + } = {}, + ) { + super(code, message, guidance, "cause" in options ? { cause: options.cause } : {}); + this.name = "SharePointTransportError"; + this.context = options.context; + } +} + +export interface SharePointAccessTokenRequest { + readonly scopes: readonly [typeof MICROSOFT_GRAPH_DEFAULT_SCOPE]; +} + +export type SharePointAccessTokenProvider = ( + request: SharePointAccessTokenRequest, +) => Promise; + +export interface SharePointClientSecretCredentials { + readonly tenantId: string; + readonly clientId: string; + readonly clientSecret: string; +} + +export interface SharePointConfidentialClient { + acquireTokenByClientCredential(request: { + readonly scopes: readonly string[]; + }): Promise<{ readonly accessToken?: string | null } | null>; +} + +export interface SharePointClientSecretAccessTokenProviderOptions + extends SharePointClientSecretCredentials { + readonly clientApplication?: SharePointConfidentialClient; +} + +export type SharePointDestinationMapper = (canonicalId: string) => string; + +export interface SharePointTransportDestination extends TransportUploadResult { + readonly kind: "sharepoint"; + readonly driveItemId: string; + readonly path: string; + readonly webUrl?: string; + readonly eTag?: string; +} + +export interface SharePointTransportOptionsBase { + readonly siteId: string; + readonly driveId: string; + readonly baseFolder?: string; + readonly mapCanonicalId?: SharePointDestinationMapper; + readonly fetch?: typeof fetch; +} + +export type SharePointTransportOptions = SharePointTransportOptionsBase & + ( + | { + readonly tenantId: string; + readonly clientId: string; + readonly clientSecret: string; + readonly accessTokenProvider?: never; + } + | { + readonly accessTokenProvider: SharePointAccessTokenProvider; + readonly tenantId?: never; + readonly clientId?: never; + readonly clientSecret?: never; + } + ); + +interface ResolvedSharePointDestination { + readonly path: string; + readonly url: string; +} + +const INVALID_SHAREPOINT_NAME_CHARS = /["*<>?:|\\]/; +const RESERVED_WINDOWS_DEVICE_BASENAME = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$/i; +const MAX_DECODED_SHAREPOINT_PATH_LENGTH = 400; +const MAX_DECODED_SHAREPOINT_SEGMENT_LENGTH = 255; +const MAX_GRAPH_CONTEXT_VALUE_LENGTH = 128; + +export function createSharePointClientSecretAccessTokenProvider( + options: SharePointClientSecretAccessTokenProviderOptions, +): SharePointAccessTokenProvider { + validateClientSecretCredentials(options); + + const clientApplication = + options.clientApplication ?? + new ConfidentialClientApplication({ + auth: { + authority: `https://login.microsoftonline.com/${encodeGraphPathSegment(options.tenantId)}`, + clientId: options.clientId, + clientSecret: options.clientSecret, + }, + }); + + return async (request) => { + let response: { readonly accessToken?: string | null } | null; + + try { + response = await clientApplication.acquireTokenByClientCredential({ + scopes: [...request.scopes], + }); + } catch { + throw new SharePointTransportError( + "SHAREPOINT_AUTH_FAILED", + "Failed to acquire a Microsoft Graph app-only access token.", + "Check the Entra app registration, client credential, admin consent, and tenant ID.", + ); + } + + const accessToken = response?.accessToken; + + if (typeof accessToken !== "string" || accessToken.trim() === "") { + throw new SharePointTransportError( + "SHAREPOINT_AUTH_FAILED", + "Microsoft Graph token acquisition did not return an access token.", + "Check the Entra app registration, client credential, admin consent, and tenant ID.", + ); + } + + return accessToken; + }; +} + +export class SharePointTransport implements Transport { + readonly siteId: string; + readonly driveId: string; + readonly baseFolder: string | undefined; + + readonly #accessTokenProvider: SharePointAccessTokenProvider; + readonly #fetch: typeof fetch; + readonly #graphBaseUrl: string; + readonly #mapCanonicalId: SharePointDestinationMapper; + + constructor(options: SharePointTransportOptions) { + validateAuthShape(options); + validateGraphPathParameter(options.siteId, "siteId"); + validateGraphPathParameter(options.driveId, "driveId"); + + const baseFolder = options.baseFolder; + + this.siteId = options.siteId; + this.driveId = options.driveId; + this.baseFolder = baseFolder; + this.#mapCanonicalId = options.mapCanonicalId ?? ((canonicalId) => canonicalId); + this.#graphBaseUrl = MICROSOFT_GRAPH_V1_BASE_URL; + this.#fetch = options.fetch ?? globalThis.fetch; + + if (typeof this.#fetch !== "function") { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + "A fetch implementation is required for SharePoint transport.", + "Run on Node.js 20 or newer, or pass a compatible fetch implementation.", + ); + } + + this.#accessTokenProvider = + "accessTokenProvider" in options && options.accessTokenProvider !== undefined + ? options.accessTokenProvider + : createSharePointClientSecretAccessTokenProvider({ + tenantId: options.tenantId, + clientId: options.clientId, + clientSecret: options.clientSecret, + }); + } + + async upload(canonicalId: string, docx: Uint8Array): Promise { + validateSharePointDocxSize(docx.byteLength); + + const destination = this.#resolveUploadDestination(canonicalId); + const bytes = Buffer.from(docx); + const accessToken = await this.#getAccessToken(); + const response = await this.#putDocx(destination.url, accessToken, bytes, canonicalId); + + return parseDriveItemResponse(response, destination.path, canonicalId); + } + + #resolveUploadDestination(canonicalId: string): ResolvedSharePointDestination { + validateCanonicalId(canonicalId); + + const mappedDestination = mapCanonicalId(canonicalId, this.#mapCanonicalId); + const relativeDestination = ensureDocxExtension( + normalizeSharePointRelativePath(mappedDestination, "mapped SharePoint destination"), + ); + const destinationPath = + this.baseFolder === undefined + ? relativeDestination + : `${this.baseFolder}/${relativeDestination}`; + const normalizedDestinationPath = normalizeSharePointRelativePath( + destinationPath, + "SharePoint destination", + ); + const encodedSiteId = encodeGraphPathSegment(this.siteId); + const encodedDriveId = encodeGraphPathSegment(this.driveId); + const encodedPath = encodeSharePointRelativePath(normalizedDestinationPath); + const url = `${this.#graphBaseUrl}/sites/${encodedSiteId}/drives/${encodedDriveId}/root:/${encodedPath}:/content`; + + return { + path: normalizedDestinationPath, + url, + }; + } + + async #getAccessToken(): Promise { + try { + const accessToken = await this.#accessTokenProvider({ + scopes: [MICROSOFT_GRAPH_DEFAULT_SCOPE], + }); + + if (typeof accessToken !== "string" || accessToken.trim() === "") { + throw new TypeError("SharePoint access token provider returned an empty token."); + } + + return accessToken; + } catch (error) { + if (error instanceof SharePointTransportError) { + throw error; + } + + throw new SharePointTransportError( + "SHAREPOINT_AUTH_FAILED", + "Failed to acquire a Microsoft Graph app-only access token.", + "Check the Entra app registration, client credential, admin consent, and tenant ID.", + ); + } + } + + async #putDocx( + url: string, + accessToken: string, + bytes: Uint8Array, + canonicalId: string, + ): Promise { + let response: Response; + + try { + response = await this.#fetch(url, { + body: bytes, + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": DOCX_MIME_TYPE, + }, + method: "PUT", + }); + } catch { + throw new SharePointTransportError( + "SHAREPOINT_NETWORK_FAILED", + `Failed to upload DOCX for canonical ID ${JSON.stringify(canonicalId)} to SharePoint.`, + "Check network connectivity to Microsoft Graph and retry the upload.", + ); + } + + if (!response.ok) { + throw await createHttpError(response, canonicalId); + } + + return response; + } +} + +export function validateSharePointDocxSize(byteLength: number): void { + if (!Number.isSafeInteger(byteLength) || byteLength < 0) { + throw new SharePointTransportError( + "SHAREPOINT_DOCX_TOO_LARGE", + "DOCX byte length must be a non-negative safe integer.", + "Pass a real Uint8Array produced by convertMarkdownToDocx().", + ); + } + + if (byteLength > SHAREPOINT_SIMPLE_UPLOAD_MAX_BYTES) { + throw new SharePointTransportError( + "SHAREPOINT_DOCX_TOO_LARGE", + "SharePoint simple upload only supports DOCX payloads up to 250 MB.", + "Use a smaller DOCX or implement an upload-session transport for larger files.", + ); + } +} + +export function encodeSharePointRelativePath(path: string): string { + return normalizeSharePointRelativePath(path, "SharePoint relative path") + .split("/") + .map(encodeGraphPathSegment) + .join("/"); +} + +function validateAuthShape(options: SharePointTransportOptions): void { + const candidate = options as { + readonly accessTokenProvider?: unknown; + readonly tenantId?: unknown; + readonly clientId?: unknown; + readonly clientSecret?: unknown; + }; + const hasAccessTokenProvider = candidate.accessTokenProvider !== undefined; + const hasAnyCredential = + candidate.tenantId !== undefined || + candidate.clientId !== undefined || + candidate.clientSecret !== undefined; + + if (hasAccessTokenProvider && hasAnyCredential) { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + "SharePoint transport auth configuration is ambiguous.", + "Pass either accessTokenProvider or tenantId/clientId/clientSecret, not both.", + ); + } + + if (!hasAccessTokenProvider && !hasAnyCredential) { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + "SharePoint transport auth configuration is required.", + "Pass accessTokenProvider, or tenantId/clientId/clientSecret for MSAL client-secret auth.", + ); + } +} + +function validateClientSecretCredentials(options: SharePointClientSecretCredentials): void { + validateTenantOrClientId(options.tenantId, "tenantId"); + validateTenantOrClientId(options.clientId, "clientId"); + validateClientSecret(options.clientSecret); +} + +function validateTenantOrClientId(value: string, label: "tenantId" | "clientId"): void { + if (typeof value !== "string" || value.trim() === "") { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + `SharePoint ${label} is required for MSAL client-secret auth.`, + "Pass non-empty tenantId, clientId, and clientSecret values from the consuming application.", + ); + } + + if (value !== value.trim() || containsControlCharacter(value) || value.includes("/")) { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + `SharePoint ${label} is not a valid credential field.`, + "Pass trimmed credential fields without control characters or URL path separators.", + ); + } +} + +function validateClientSecret(value: string): void { + if (typeof value !== "string" || value.length === 0) { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + "SharePoint clientSecret is required for MSAL client-secret auth.", + "Pass the opaque client secret value from the consuming application.", + ); + } + + if (containsControlCharacter(value)) { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + "SharePoint clientSecret must not contain control characters.", + "Pass the opaque client secret value from the consuming application.", + ); + } +} + +function validateGraphPathParameter(value: string, label: "siteId" | "driveId"): void { + if (typeof value !== "string" || value.trim() === "") { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + `SharePoint ${label} is required.`, + "Pass the stable Microsoft Graph site ID and document-library drive ID.", + ); + } + + if (value !== value.trim() || containsControlCharacter(value) || /[/\\]/.test(value)) { + throw new SharePointTransportError( + "SHAREPOINT_CONFIG_INVALID", + `SharePoint ${label} is not valid for a Microsoft Graph path parameter.`, + "Pass a trimmed Graph ID without path separators or control characters.", + ); + } +} + +function validateCanonicalId(canonicalId: string): void { + if (typeof canonicalId !== "string" || canonicalId.trim() === "") { + throw new SharePointTransportError( + "SHAREPOINT_PATH_INVALID", + "A non-empty canonical ID is required for SharePoint upload.", + "Pass the stable document ID used by the source system.", + ); + } + + if (canonicalId !== canonicalId.trim() || canonicalId.includes("\0")) { + throw new SharePointTransportError( + "SHAREPOINT_PATH_INVALID", + "SharePoint canonical IDs must be trimmed and must not contain NUL bytes.", + "Normalize the source document ID before uploading.", + ); + } +} + +function mapCanonicalId(canonicalId: string, mapper: SharePointDestinationMapper): string { + try { + const mappedDestination = mapper(canonicalId); + + if (typeof mappedDestination !== "string") { + throw new TypeError("SharePoint destination mapper must return a string."); + } + + return mappedDestination; + } catch (cause) { + if (cause instanceof SharePointTransportError) { + throw cause; + } + + throw new SharePointTransportError( + "SHAREPOINT_PATH_INVALID", + `SharePoint destination mapping failed for canonical ID ${JSON.stringify(canonicalId)}.`, + "Fix mapCanonicalId so it returns a safe relative DOCX destination.", + { cause }, + ); + } +} + +function normalizeSharePointRelativePath(path: string, label: string): string { + if (typeof path !== "string" || path.trim() === "") { + throw invalidSharePointPathError(label, "must be non-empty"); + } + + if (path.includes("\0") || containsControlCharacter(path)) { + throw invalidSharePointPathError(label, "must not contain NUL bytes or control characters"); + } + + if (path.startsWith("/") || path.startsWith("//")) { + throw invalidSharePointPathError(label, "must be relative to the document library root"); + } + + if (path.length > MAX_DECODED_SHAREPOINT_PATH_LENGTH) { + throw invalidSharePointPathError( + label, + `must not exceed ${MAX_DECODED_SHAREPOINT_PATH_LENGTH} decoded characters`, + ); + } + + const segments = path.split("/"); + + for (const [index, segment] of segments.entries()) { + validateSharePointPathSegment(segment, label, index); + } + + return path; +} + +function validateSharePointPathSegment(segment: string, label: string, index: number): void { + if (segment === "" || segment === "." || segment === "..") { + throw invalidSharePointPathError( + label, + "must not contain empty, current-directory, or parent-directory segments", + ); + } + + if (segment !== segment.trim()) { + throw invalidSharePointPathError( + label, + "must not contain path segments with leading or trailing whitespace", + ); + } + + if (segment.length > MAX_DECODED_SHAREPOINT_SEGMENT_LENGTH) { + throw invalidSharePointPathError( + label, + `must not contain path segments longer than ${MAX_DECODED_SHAREPOINT_SEGMENT_LENGTH} decoded characters`, + ); + } + + if (segment.startsWith("~$")) { + throw invalidSharePointPathError(label, "must not contain names beginning with ~$"); + } + + if (segment.startsWith("~")) { + throw invalidSharePointPathError(label, "must not contain segments beginning with tilde"); + } + + if (segment.endsWith(".")) { + throw invalidSharePointPathError(label, "must not contain segments ending with a period"); + } + + if (INVALID_SHAREPOINT_NAME_CHARS.test(segment)) { + throw invalidSharePointPathError(label, 'must not contain " * < > ? : | or \\ characters'); + } + + if (RESERVED_WINDOWS_DEVICE_BASENAME.test(segment)) { + throw invalidSharePointPathError(label, "must not use Windows-reserved device names"); + } + + validateBlockedSharePointName(segment, label, index); +} + +function validateBlockedSharePointName(segment: string, label: string, index: number): void { + const lowerSegment = segment.toLowerCase(); + + if (lowerSegment === ".lock" || lowerSegment === ".lock.docx") { + throw invalidSharePointPathError(label, "must not use the blocked name .lock"); + } + + if (lowerSegment === "desktop.ini" || lowerSegment === "desktop.ini.docx") { + throw invalidSharePointPathError(label, "must not use the blocked name desktop.ini"); + } + + if (lowerSegment.includes("_vti_")) { + throw invalidSharePointPathError(label, "must not contain _vti_ in path segment names"); + } + + if (index === 0 && lowerSegment === "forms") { + throw invalidSharePointPathError( + label, + "must not use Forms as a root-level document-library item name", + ); + } +} + +function invalidSharePointPathError(label: string, reason: string): SharePointTransportError { + return new SharePointTransportError( + "SHAREPOINT_PATH_INVALID", + `The ${label} ${reason}.`, + "Return a relative SharePoint path without traversal, backslashes, or reserved SharePoint name characters.", + ); +} + +function ensureDocxExtension(path: string): string { + return path.toLowerCase().endsWith(".docx") ? path : `${path}.docx`; +} + +function encodeGraphPathSegment(segment: string): string { + return encodeURIComponent(segment).replace( + /[!'()*]/g, + (character) => `%${character.codePointAt(0)?.toString(16).toUpperCase()}`, + ); +} + +function containsControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + + if (codePoint !== undefined && codePoint <= 31) { + return true; + } + } + + return false; +} + +async function createHttpError( + response: Response, + canonicalId: string, +): Promise { + const context = await readGraphErrorContext(response); + const details = [ + `status ${response.status}`, + context.requestId === undefined ? undefined : `request-id ${context.requestId}`, + context.graphErrorCode === undefined ? undefined : `Graph error ${context.graphErrorCode}`, + ].filter((detail): detail is string => detail !== undefined); + + return new SharePointTransportError( + "SHAREPOINT_HTTP_FAILED", + `Microsoft Graph rejected the SharePoint upload for canonical ID ${JSON.stringify( + canonicalId, + )} (${details.join(", ")}).`, + "Check the site-specific write grant, document-library drive ID, mapped path, and Graph service response.", + { context }, + ); +} + +async function readGraphErrorContext(response: Response): Promise { + const requestId = + sanitizeGraphContextValue(response.headers.get("request-id")) ?? + sanitizeGraphContextValue(response.headers.get("client-request-id")); + const bodyText = await readBoundedResponseText(response); + const graphErrorCode = extractGraphErrorCode(bodyText); + const context: { + status?: number; + requestId?: string; + graphErrorCode?: string; + } = { + status: response.status, + }; + + if (requestId !== undefined) { + context.requestId = requestId; + } + + if (graphErrorCode !== undefined) { + context.graphErrorCode = graphErrorCode; + } + + return context; +} + +async function readBoundedResponseText(response: Response): Promise { + try { + return (await response.text()).slice(0, 4096); + } catch { + return ""; + } +} + +function extractGraphErrorCode(bodyText: string): string | undefined { + if (bodyText.trim() === "") { + return undefined; + } + + try { + const body = JSON.parse(bodyText) as unknown; + const code = getStringProperty(getObjectProperty(body, "error"), "code"); + + return sanitizeGraphContextValue(code); + } catch { + return undefined; + } +} + +function sanitizeGraphContextValue(value: string | null | undefined): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + + let sanitized = ""; + + for (const character of value) { + const codePoint = character.codePointAt(0); + + if (codePoint !== undefined && codePoint > 31 && codePoint !== 127) { + sanitized += character; + } + } + + const trimmed = sanitized.trim(); + + return trimmed === "" ? undefined : trimmed.slice(0, MAX_GRAPH_CONTEXT_VALUE_LENGTH); +} + +async function parseDriveItemResponse( + response: Response, + path: string, + canonicalId: string, +): Promise { + let body: unknown; + + try { + body = (await response.json()) as unknown; + } catch { + throw new SharePointTransportError( + "SHAREPOINT_RESPONSE_INVALID", + `Microsoft Graph returned invalid JSON for SharePoint upload of canonical ID ${JSON.stringify( + canonicalId, + )}.`, + "Retry the upload or inspect the Graph response outside this library.", + ); + } + + const driveItemId = getStringProperty(body, "id"); + + if (driveItemId === undefined || driveItemId.trim() === "") { + throw new SharePointTransportError( + "SHAREPOINT_RESPONSE_INVALID", + `Microsoft Graph did not return a driveItem id for SharePoint upload of canonical ID ${JSON.stringify( + canonicalId, + )}.`, + "Check that the upload endpoint returns a driveItem response body.", + ); + } + + const destination: { + kind: "sharepoint"; + destinationId: string; + driveItemId: string; + path: string; + webUrl?: string; + eTag?: string; + } = { + kind: "sharepoint", + destinationId: driveItemId, + driveItemId, + path, + }; + const webUrl = getStringProperty(body, "webUrl"); + const eTag = getStringProperty(body, "eTag"); + + if (webUrl !== undefined && webUrl.trim() !== "") { + destination.webUrl = webUrl; + } + + if (eTag !== undefined && eTag.trim() !== "") { + destination.eTag = eTag; + } + + return destination; +} + +function getObjectProperty(value: unknown, key: string): Record | undefined { + if (typeof value !== "object" || value === null || !Object.hasOwn(value, key)) { + return undefined; + } + + const property = (value as Record)[key]; + + return typeof property === "object" && property !== null + ? (property as Record) + : undefined; +} + +function getStringProperty(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null || !Object.hasOwn(value, key)) { + return undefined; + } + + const property = (value as Record)[key]; + + return typeof property === "string" ? property : undefined; +} diff --git a/src/transport.ts b/src/transport.ts index 18c32e1..ecaf169 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -6,7 +6,14 @@ export type TransportErrorCode = | "TRANSPORT_ID_INVALID" | "TRANSPORT_DESTINATION_INVALID" | "TRANSPORT_DESTINATION_OUTSIDE_ROOT" - | "TRANSPORT_WRITE_FAILED"; + | "TRANSPORT_WRITE_FAILED" + | "SHAREPOINT_CONFIG_INVALID" + | "SHAREPOINT_AUTH_FAILED" + | "SHAREPOINT_PATH_INVALID" + | "SHAREPOINT_DOCX_TOO_LARGE" + | "SHAREPOINT_NETWORK_FAILED" + | "SHAREPOINT_HTTP_FAILED" + | "SHAREPOINT_RESPONSE_INVALID"; export interface TransportUploadResult { readonly destinationId: string; diff --git a/tests/index.test.ts b/tests/index.test.ts index 0c9a030..02bd942 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -9,12 +9,24 @@ import { applyMarkdownPreprocessors, type ConvertMarkdownToDocxOptions, convertMarkdownToDocx, + createSharePointClientSecretAccessTokenProvider, DEFAULT_SOURCE_DATE_EPOCH, + DOCX_MIME_TYPE, doctor, + encodeSharePointRelativePath, LocalFileTransport, + MICROSOFT_GRAPH_DEFAULT_SCOPE, PandocError, type PandocRunner, + SHAREPOINT_REQUIRED_APPLICATION_PERMISSION, + SHAREPOINT_SIMPLE_UPLOAD_MAX_BYTES, + type SharePointAccessTokenProvider, + type SharePointConfidentialClient, + SharePointTransport, + SharePointTransportError, + type SharePointTransportOptions, SUPPORTED_PANDOC_MAJOR, + validateSharePointDocxSize, } from "../src/index.js"; const fixtureInputPath = "tests/fixtures/golden/publish/basic-note/input.md"; @@ -743,6 +755,487 @@ describe("local file transport", () => { }); }); +describe("SharePoint transport", () => { + it("uses MSAL client-credential token requests with exactly the Microsoft Graph .default scope", async () => { + const clientApplication: SharePointConfidentialClient = { + acquireTokenByClientCredential: vi.fn(async () => ({ accessToken: "token" })), + }; + const provider = createSharePointClientSecretAccessTokenProvider({ + tenantId: "tenant-id", + clientId: "client-id", + clientSecret: "client-secret", + clientApplication, + }); + + await expect(provider({ scopes: [MICROSOFT_GRAPH_DEFAULT_SCOPE] })).resolves.toBe("token"); + + expect(clientApplication.acquireTokenByClientCredential).toHaveBeenCalledWith({ + scopes: [MICROSOFT_GRAPH_DEFAULT_SCOPE], + }); + expect(MICROSOFT_GRAPH_DEFAULT_SCOPE).toBe("https://graph.microsoft.com/.default"); + expect(SHAREPOINT_REQUIRED_APPLICATION_PERMISSION).toBe("Sites.Selected"); + }); + + it("treats client secrets as opaque non-empty strings with normal punctuation", () => { + const clientApplication: SharePointConfidentialClient = { + acquireTokenByClientCredential: vi.fn(async () => ({ accessToken: "token" })), + }; + + expect(() => + createSharePointClientSecretAccessTokenProvider({ + tenantId: "tenant-id", + clientId: "client-id", + clientSecret: "opaque/secret:with?punctuation|#% and spaces", + clientApplication, + }), + ).not.toThrow(); + expect( + () => + new SharePointTransport({ + siteId: "site-id", + driveId: "drive-id", + tenantId: "tenant-id", + clientId: "client-id", + clientSecret: "opaque/secret:with?punctuation|#% and spaces", + }), + ).not.toThrow(); + }); + + it("does not attach secret-bearing MSAL failures as auth error causes", async () => { + const secretBearingCause = new Error("client-secret-value"); + const clientApplication: SharePointConfidentialClient = { + acquireTokenByClientCredential: vi.fn(async () => { + throw secretBearingCause; + }), + }; + const provider = createSharePointClientSecretAccessTokenProvider({ + tenantId: "tenant-id", + clientId: "client-id", + clientSecret: "client-secret-value", + clientApplication, + }); + + await expect(provider({ scopes: [MICROSOFT_GRAPH_DEFAULT_SCOPE] })).rejects.toSatisfy( + (error: unknown) => + error instanceof SharePointTransportError && + error.code === "SHAREPOINT_AUTH_FAILED" && + error.cause === undefined && + !String(error).includes("client-secret-value"), + ); + }); + + it("PUTs a DOCX body to the encoded Graph path with bearer auth and content type", async () => { + const calls: FetchCall[] = []; + const transport = createSharePointTransport({ + fetchImpl: createSuccessfulSharePointFetch(calls, { id: "drive-item-1" }), + }); + const docx = new Uint8Array([1, 2, 3]); + + const result = await transport.upload("Team Docs/#plan 100%", docx); + + expect(result).toEqual({ + kind: "sharepoint", + destinationId: "drive-item-1", + driveItemId: "drive-item-1", + path: "Team Docs/#plan 100%.docx", + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + url: "https://graph.microsoft.com/v1.0/sites/site%20id%23100%25/drives/drive%20id%23100%25/root:/Team%20Docs/%23plan%20100%25.docx:/content", + method: "PUT", + authorization: "Bearer test-token", + contentType: DOCX_MIME_TYPE, + body: [1, 2, 3], + }); + }); + + it("snapshots DOCX bytes before asynchronous token and network work", async () => { + const calls: FetchCall[] = []; + let releaseToken: ((token: string) => void) | undefined; + const accessTokenProvider: SharePointAccessTokenProvider = vi.fn( + () => + new Promise((resolve) => { + releaseToken = resolve; + }), + ); + const transport = createSharePointTransport({ + accessTokenProvider, + fetchImpl: createSuccessfulSharePointFetch(calls, { id: "snapshot-id" }), + }); + const docx = new Uint8Array([1, 2, 3]); + + const upload = transport.upload("snapshot", docx); + docx.fill(9); + releaseToken?.("test-token"); + await upload; + + expect(calls[0]?.body).toEqual([1, 2, 3]); + }); + + it("repeated uploads to the same canonical ID use the same URL and stable driveItem ID", async () => { + const calls: FetchCall[] = []; + const transport = createSharePointTransport({ + fetchImpl: createSuccessfulSharePointFetch(calls, { id: "stable-drive-item-id" }), + }); + + const first = await transport.upload("stable/path", new Uint8Array([1])); + const second = await transport.upload("stable/path", new Uint8Array([2])); + + expect(first.driveItemId).toBe("stable-drive-item-id"); + expect(second.driveItemId).toBe("stable-drive-item-id"); + expect(calls).toHaveLength(2); + expect(calls[1]?.url).toBe(calls[0]?.url); + expect(calls[1]?.method).toBe("PUT"); + }); + + it("supports custom opaque ID mapping, base folders, and .docx extension handling", async () => { + const calls: FetchCall[] = []; + const seenIds: string[] = []; + const transport = createSharePointTransport({ + baseFolder: "Published Docs", + fetchImpl: createSuccessfulSharePointFetch(calls, { + id: "opaque-id", + webUrl: "https://tenant.sharepoint.com/sites/wiki/Shared%20Documents/opaque.docx", + eTag: '"etag"', + }), + mapCanonicalId: (canonicalId) => { + seenIds.push(canonicalId); + return "Opaque IDs/note-123.DOCX"; + }, + }); + + const result = await transport.upload("urn:teamwiki:note:123", new Uint8Array([1])); + + expect(seenIds).toEqual(["urn:teamwiki:note:123"]); + expect(result).toEqual({ + kind: "sharepoint", + destinationId: "opaque-id", + driveItemId: "opaque-id", + path: "Published Docs/Opaque IDs/note-123.DOCX", + webUrl: "https://tenant.sharepoint.com/sites/wiki/Shared%20Documents/opaque.docx", + eTag: '"etag"', + }); + expect(calls[0]?.url).toContain("/root:/Published%20Docs/Opaque%20IDs/note-123.DOCX:/content"); + }); + + it("encodes supported spaces, hash, and percent characters per path segment", () => { + expect(encodeSharePointRelativePath("Team Docs/#plan 100%.docx")).toBe( + "Team%20Docs/%23plan%20100%25.docx", + ); + }); + + it.each([ + [ + "empty site ID", + () => + ({ + siteId: "", + driveId: "drive-id", + accessTokenProvider: async () => "token", + }) as SharePointTransportOptions, + ], + [ + "empty drive ID", + () => + ({ + siteId: "site-id", + driveId: " ", + accessTokenProvider: async () => "token", + }) as SharePointTransportOptions, + ], + [ + "mixed provider and credentials", + () => + ({ + siteId: "site-id", + driveId: "drive-id", + accessTokenProvider: async () => "token", + tenantId: "tenant", + clientId: "client", + clientSecret: "secret", + }) as unknown as SharePointTransportOptions, + ], + [ + "missing auth", + () => + ({ + siteId: "site-id", + driveId: "drive-id", + }) as unknown as SharePointTransportOptions, + ], + ])("rejects invalid config: %s", (_label, buildOptions) => { + expect(() => new SharePointTransport(buildOptions())).toThrow( + expect.objectContaining({ + name: "SharePointTransportError", + code: "SHAREPOINT_CONFIG_INVALID", + }), + ); + }); + + it.each(["", " ", " leading", "trailing ", "bad\0id"])( + "rejects invalid canonical ID %j", + async (canonicalId) => { + const transport = createSharePointTransport(); + + await expect(transport.upload(canonicalId, new Uint8Array([1]))).rejects.toMatchObject({ + name: "SharePointTransportError", + code: "SHAREPOINT_PATH_INVALID", + }); + }, + ); + + it.each([ + "../secret", + "team/../secret", + "team//secret", + "./secret", + "/absolute", + String.raw`team\secret`, + "name?", + "name*", + "name<", + "name>", + 'name"', + "name:", + "name|", + "~temporary", + "~$temporary", + "folder/trailing.", + "CON", + "COM0", + "LPT1.docx", + "LPT0.docx", + ".lock", + "desktop.ini", + "Team/_vti_/note", + ])("rejects unsafe SharePoint destination %j", async (mappedDestination) => { + const transport = createSharePointTransport({ + mapCanonicalId: () => mappedDestination, + }); + + await expect(transport.upload("safe", new Uint8Array([1]))).rejects.toMatchObject({ + name: "SharePointTransportError", + code: "SHAREPOINT_PATH_INVALID", + }); + }); + + it.each([ + ["base folder trailing whitespace segment", { baseFolder: "TeamWiki /Published" }], + ["mapped nested leading whitespace segment", { mapCanonicalId: (): string => "Team/ note" }], + ["mapped nested trailing whitespace segment", { mapCanonicalId: (): string => "Team/note " }], + [ + "root-level Forms folder after final combination", + { mapCanonicalId: (): string => "Forms/note" }, + ], + [ + "root-level Forms base folder after final combination", + { baseFolder: "Forms", mapCanonicalId: (): string => "note" }, + ], + [ + "appended extension blocked .lock final destination", + { mapCanonicalId: (): string => ".lock" }, + ], + [ + "appended extension blocked desktop.ini final destination", + { mapCanonicalId: (): string => "desktop.ini" }, + ], + ])("rejects unsafe final SharePoint destination: %s", async (_label, options) => { + const transport = createSharePointTransport(options); + + await expect(transport.upload("safe", new Uint8Array([1]))).rejects.toMatchObject({ + name: "SharePointTransportError", + code: "SHAREPOINT_PATH_INVALID", + }); + }); + + it("rejects decoded SharePoint path segments longer than 255 characters", async () => { + const transport = createSharePointTransport({ + mapCanonicalId: () => "a".repeat(256), + }); + + await expect(transport.upload("safe", new Uint8Array([1]))).rejects.toMatchObject({ + name: "SharePointTransportError", + code: "SHAREPOINT_PATH_INVALID", + }); + }); + + it("rejects decoded SharePoint relative paths longer than 400 characters", async () => { + const transport = createSharePointTransport({ + baseFolder: "a".repeat(200), + mapCanonicalId: () => `${"b".repeat(196)}.docx`, + }); + + await expect(transport.upload("safe", new Uint8Array([1]))).rejects.toMatchObject({ + name: "SharePointTransportError", + code: "SHAREPOINT_PATH_INVALID", + }); + }); + + it("enforces the 250 MB simple-upload cap without allocating a 250 MB DOCX", () => { + expect(() => validateSharePointDocxSize(SHAREPOINT_SIMPLE_UPLOAD_MAX_BYTES)).not.toThrow(); + expect(() => validateSharePointDocxSize(SHAREPOINT_SIMPLE_UPLOAD_MAX_BYTES + 1)).toThrow( + expect.objectContaining({ + name: "SharePointTransportError", + code: "SHAREPOINT_DOCX_TOO_LARGE", + }), + ); + }); + + it("guards upload size before auth or network work", async () => { + const accessTokenProvider = vi.fn(async () => "token"); + const fetchImpl = vi.fn(); + const transport = createSharePointTransport({ accessTokenProvider, fetchImpl }); + const tooLarge = { byteLength: SHAREPOINT_SIMPLE_UPLOAD_MAX_BYTES + 1 } as Uint8Array; + + await expect(transport.upload("too-large", tooLarge)).rejects.toMatchObject({ + name: "SharePointTransportError", + code: "SHAREPOINT_DOCX_TOO_LARGE", + }); + expect(accessTokenProvider).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("wraps auth failures without exposing tokens or secrets in the error string", async () => { + const secretBearingCause = new Error("secret-value"); + const transport = createSharePointTransport({ + accessTokenProvider: async () => { + throw secretBearingCause; + }, + }); + + await expect(transport.upload("auth", new Uint8Array([1]))).rejects.toSatisfy( + (error: unknown) => + error instanceof SharePointTransportError && + error.code === "SHAREPOINT_AUTH_FAILED" && + error.cause === undefined && + !String(error).includes("secret-value"), + ); + }); + + it("wraps network failures without exposing bearer tokens in the error string", async () => { + const tokenBearingCause = new Error("test-token"); + const transport = createSharePointTransport({ + accessTokenProvider: async () => "test-token", + fetchImpl: async () => { + throw tokenBearingCause; + }, + }); + + await expect(transport.upload("network", new Uint8Array([1]))).rejects.toSatisfy( + (error: unknown) => + error instanceof SharePointTransportError && + error.code === "SHAREPOINT_NETWORK_FAILED" && + error.cause === undefined && + !String(error).includes("test-token"), + ); + }); + + it("returns bounded Graph error context for non-2xx responses without leaking body text", async () => { + const transport = createSharePointTransport({ + fetchImpl: async () => + new Response( + JSON.stringify({ + error: { + code: "accessDenied", + message: "test-token secret-value", + }, + }), + { + status: 403, + headers: { + "request-id": "request-123", + }, + }, + ), + }); + + await expect(transport.upload("denied", new Uint8Array([1]))).rejects.toSatisfy( + (error: unknown) => + error instanceof SharePointTransportError && + error.code === "SHAREPOINT_HTTP_FAILED" && + error.context?.status === 403 && + error.context.requestId === "request-123" && + error.context.graphErrorCode === "accessDenied" && + !String(error).includes("test-token") && + !String(error).includes("secret-value"), + ); + }); + + it("sanitizes Graph request IDs and error codes before adding them to errors", async () => { + const transport = createSharePointTransport({ + fetchImpl: async () => + ({ + ok: false, + status: 429, + headers: { + get: (name: string) => + name.toLowerCase() === "request-id" ? "request-\n123\u007F" : null, + }, + text: async () => + JSON.stringify({ + error: { + code: "tooMany\nRequests\u007F", + }, + }), + }) as Response, + }); + + await expect(transport.upload("throttled", new Uint8Array([1]))).rejects.toSatisfy( + (error: unknown) => + error instanceof SharePointTransportError && + error.code === "SHAREPOINT_HTTP_FAILED" && + error.context?.requestId === "request-123" && + error.context.graphErrorCode === "tooManyRequests" && + !String(error).includes("\n") && + !String(error).includes("\u007F"), + ); + }); + + it("does not treat x-ms-ags-diagnostic as a Graph request ID", async () => { + const transport = createSharePointTransport({ + fetchImpl: async () => + new Response(JSON.stringify({ error: { code: "accessDenied" } }), { + status: 403, + headers: { + "x-ms-ags-diagnostic": "diagnostic-should-not-be-request-id", + }, + }), + }); + + await expect(transport.upload("denied", new Uint8Array([1]))).rejects.toSatisfy( + (error: unknown) => + error instanceof SharePointTransportError && + error.code === "SHAREPOINT_HTTP_FAILED" && + error.context?.requestId === undefined, + ); + }); + + it("rejects invalid JSON and missing driveItem IDs", async () => { + const invalidJsonTransport = createSharePointTransport({ + fetchImpl: async () => new Response("not-json secret-value", { status: 200 }), + }); + const missingIdTransport = createSharePointTransport({ + fetchImpl: async () => Response.json({ name: "missing id" }, { status: 201 }), + }); + + await expect( + invalidJsonTransport.upload("invalid-json", new Uint8Array([1])), + ).rejects.toSatisfy( + (error: unknown) => + error instanceof SharePointTransportError && + error.code === "SHAREPOINT_RESPONSE_INVALID" && + error.cause === undefined && + !String(error).includes("secret-value"), + ); + await expect( + missingIdTransport.upload("missing-id", new Uint8Array([1])), + ).rejects.toMatchObject({ + name: "SharePointTransportError", + code: "SHAREPOINT_RESPONSE_INVALID", + }); + }); +}); + async function createTempDocx(name: string): Promise { const tempDirectory = await createTempDirectory(); const docxPath = join(tempDirectory, name); @@ -759,6 +1252,80 @@ async function createTempDirectory(): Promise { return tempDirectory; } +interface FetchCall { + readonly url: string; + readonly method: string | undefined; + readonly authorization: string | undefined; + readonly contentType: string | undefined; + readonly body: readonly number[]; +} + +function createSharePointTransport( + options: { + readonly accessTokenProvider?: SharePointAccessTokenProvider; + readonly baseFolder?: string; + readonly fetchImpl?: typeof fetch; + readonly mapCanonicalId?: (canonicalId: string) => string; + } = {}, +): SharePointTransport { + let transportOptions: SharePointTransportOptions = { + siteId: "site id#100%", + driveId: "drive id#100%", + accessTokenProvider: options.accessTokenProvider ?? (async () => "test-token"), + fetch: options.fetchImpl ?? createSuccessfulSharePointFetch([], { id: "drive-item" }), + }; + + if (options.baseFolder !== undefined) { + transportOptions = { ...transportOptions, baseFolder: options.baseFolder }; + } + + if (options.mapCanonicalId !== undefined) { + transportOptions = { ...transportOptions, mapCanonicalId: options.mapCanonicalId }; + } + + return new SharePointTransport(transportOptions); +} + +function createSuccessfulSharePointFetch( + calls: FetchCall[], + driveItem: { + readonly id: string; + readonly webUrl?: string; + readonly eTag?: string; + }, +): typeof fetch { + return async (input, init) => { + const headers = new Headers(init?.headers); + const body = init?.body; + + calls.push({ + url: String(input), + method: init?.method, + authorization: headers.get("Authorization") ?? undefined, + contentType: headers.get("Content-Type") ?? undefined, + body: body instanceof Uint8Array ? [...body] : [], + }); + + const responseBody: { + id: string; + webUrl?: string; + eTag?: string; + } = { + id: driveItem.id, + }; + + if (driveItem.webUrl !== undefined) { + responseBody.webUrl = driveItem.webUrl; + } + + if (driveItem.eTag !== undefined) { + responseBody.eTag = driveItem.eTag; + } + + return Response.json(responseBody, { status: 201 }); + }; +} + function createSuccessfulDoctorRunner(): PandocRunner { return vi.fn(async () => ({ stdout: "pandoc 3.10",