diff --git a/README.md b/README.md index 02097fc..ebcdd56 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,147 @@ Microsoft references: - [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) +### Google Drive Transport + +`GoogleDriveTransport` publishes DOCX bytes to Google Drive as a converted +Google Doc. It implements the same `Transport.upload(canonicalId, docx)` +contract as the other transports. + +```ts +import { OAuth2Client } from "google-auth-library"; +import { + GOOGLE_DRIVE_FILE_SCOPE, + GoogleDriveTransport, + convertMarkdownToDocx, +} from "@agentic-tooling/polydoc-core"; + +const docx = await convertMarkdownToDocx({ + markdown, + referenceDocxPath: "./reference.docx", +}); + +// The consuming application owns the OAuth flow and the resulting tokens. +const auth = new OAuth2Client({ clientId, clientSecret, redirectUri }); +auth.setCredentials({ refresh_token: refreshTokenFromYourOwnStore }); + +const transport = new GoogleDriveTransport({ + auth, + folderId: "drive-folder-id", + mapCanonicalId: (canonicalId) => `TeamWiki — ${canonicalId}`, + resolveExistingFileId: (canonicalId) => manifest.get(canonicalId)?.fileId, +}); + +const destination = await transport.upload("handbook-intro", docx); + +console.log(GOOGLE_DRIVE_FILE_SCOPE); // https://www.googleapis.com/auth/drive.file +console.log(destination.destinationId); // Drive file ID +console.log(destination.fileId); // same ID, for Drive consumers +console.log(destination.webViewLink); // optional Drive webViewLink +``` + +Auth uses `google-auth-library` through `@googleapis/drive`. The required scope +is `drive.file`, exported as `GOOGLE_DRIVE_FILE_SCOPE`, which is the +least-privilege Drive scope: the application may only see and manage files it +created itself. This package requests no broader Drive scope. + +`google-auth-library` is an optional peer dependency. It is only needed if you +construct an auth client and pass `auth`; consumers that inject `driveClient` +directly, or that use another transport, do not need it installed. + +**Token handling is left to the consumer.** The library takes an already +authorized auth client and does nothing else with it. It does not run an OAuth +loopback server, does not open a browser, does not read environment variables, +does not persist tokens to disk, and does not touch a keychain. It never +performs a token refresh itself and never writes tokens anywhere; the auth +client you supply does its own in-memory refresh during a request, exactly as it +would outside this library. Persisting the resulting refresh token, if you want +one, is your responsibility. Consumers that use a service account, workload +identity federation, or their own token cache can pass any auth client the Drive +v3 client accepts. + +The `@googleapis/drive` SDK is imported lazily on the first upload, so consumers +that only use another transport never pay to load it. + +Uploads use the Drive v3 files resource: + +```txt +POST /drive/v3/files (new document) +PATCH /drive/v3/files/{fileId} (existing document) +``` + +Both calls send the DOCX bytes as the media body with DOCX content type, and set +`mimeType: application/vnd.google-apps.document` on the file metadata so Drive +converts the upload into a native Google Doc on import. The Doc MIME type is +exported as `GOOGLE_DOC_MIME_TYPE`. + +The response must include a non-empty Drive file `id`, and any `mimeType` Drive +reports back must be the Google Doc type; otherwise the upload fails closed +rather than returning a partial destination. That second check catches the case +where the conversion did not happen and the DOCX was stored as an opaque blob — +usually a `resolveExistingFileId` pointing at a plain DOCX upload, where every +subsequent publish would silently write a file nobody can open as a Doc. The +offending Drive file ID is included in both the message and the error `context` +so it can be deleted or adopted. + +Drive has no path namespace, so `mapCanonicalId` returns a **document name**, +not a path. The default mapper uses the canonical ID verbatim except that a +trailing `.docx` extension is stripped, because a converted Google Doc should +not be named `foo.docx`. Names must be a single trimmed segment: slashes, +control characters, NUL bytes, and names over 255 characters are rejected before +any Drive call. A canonical ID that looks like a path, such as +`teamwiki/basic-note`, therefore needs an explicit `mapCanonicalId`. + +Drive addresses documents by opaque file ID rather than by path, so this package +cannot rediscover a previously published document on its own and deliberately +keeps no local state. `resolveExistingFileId` is the create-or-update hook: the +consumer owns the manifest that records `fileId` per canonical ID. When it +resolves to an ID, the transport calls `files.update` on that file; otherwise it +calls `files.create` and returns the new ID for the consumer to persist. + +`folderId` is applied as `parents` on create only. Drive rejects `parents` on +update, and re-parenting an already published document would be a surprising +side effect, so updates leave the document where it is. + +Two consequences of the `drive.file` scope are worth knowing before they show up +as confusing production errors: + +- **The OAuth client can only see files it created.** A `folderId` for a folder + created by hand in the Drive web UI returns `404 File not found`, because that + folder is invisible to this client. The folder must have been created by this + same OAuth client, or selected by the user through the Google Picker, which + grants per-file access. The same applies to any ID returned from + `resolveExistingFileId` that was created by a different client. +- **Service accounts have no Drive storage quota.** When authenticating as a + service account, always set `folderId` to a folder on a shared drive, or to a + folder that has been shared with the service account. An unparented create + fails with `storageQuotaExceeded`, because the file would otherwise land in a + personal Drive the service account does not have. + +Google Drive converts a text document into Google Docs format only up to 50 MB, +exported as `GOOGLE_DRIVE_DOCX_IMPORT_MAX_BYTES`. `GoogleDriveTransport` checks +this limit before any Drive call. + +Failures throw `GoogleDriveTransportError` with a stable `code`, actionable +`guidance`, and a bounded `context` carrying the HTTP status and the Drive error +reason. Raw Drive error objects and response bodies are never included, so +tokens cannot leak through an error message. A request that never reached the +Drive API, and so carries no HTTP status, is reported as +`GOOGLE_DRIVE_NETWORK_FAILED` rather than `GOOGLE_DRIVE_API_FAILED`, so callers +can tell a retryable transport failure from a decision by Drive. A `401`, and a +`403` whose Drive reason indicates insufficient permissions, map to +`GOOGLE_DRIVE_AUTH_FAILED`. + +Google publishing is one-way: this package does not import, diff, or +reverse-convert Google Docs. + +Google references: + +- [Drive `drive.file` scope](https://developers.google.com/workspace/drive/api/guides/api-specific-auth) +- [Upload file data](https://developers.google.com/workspace/drive/api/guides/manage-uploads) +- [`files.create`](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/create) +- [`files.update`](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/update) +- [Files you can store in Google Drive](https://support.google.com/drive/answer/37603) + ## Pandoc Contract This package shells out to the system `pandoc` binary through `execa` with an diff --git a/package.json b/package.json index 723702e..0d504b1 100644 --- a/package.json +++ b/package.json @@ -53,11 +53,21 @@ "@types/node": "^20.19.43", "@vitest/coverage-v8": "^4.1.10", "fflate": "^0.8.3", + "google-auth-library": "10.5.0", "typescript": "^7.0.2", "vitest": "^4.1.10" }, "dependencies": { "@azure/msal-node": "5.4.2", + "@googleapis/drive": "20.2.0", "execa": "^8.0.1" + }, + "peerDependencies": { + "google-auth-library": "^10" + }, + "peerDependenciesMeta": { + "google-auth-library": { + "optional": true + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d8bdf5..e3b2a20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@azure/msal-node': specifier: 5.4.2 version: 5.4.2 + '@googleapis/drive': + specifier: 20.2.0 + version: 20.2.0 execa: specifier: ^8.0.1 version: 8.0.1 @@ -27,6 +30,9 @@ importers: fflate: specifier: ^0.8.3 version: 0.8.3 + google-auth-library: + specifier: 10.5.0 + version: 10.5.0 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -131,6 +137,14 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@googleapis/drive@20.2.0': + resolution: {integrity: sha512-6xPjqqLl34jBXWYMO34ywLVQsT8FgH948x8YI9o6Tdqs7+n4QRyvkC+BcvVQaAPNc+pyqsvZAXcbdMEcGgDUGw==} + engines: {node: '>=12.0.0'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -150,6 +164,10 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -424,6 +442,26 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -431,13 +469,40 @@ packages: ast-v8-to-istanbul@1.0.5: resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -445,16 +510,54 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -466,6 +569,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -475,29 +581,105 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gaxios@7.3.0: + resolution: {integrity: sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.4: + resolution: {integrity: sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + googleapis-common@8.0.3: + resolution: {integrity: sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -517,9 +699,15 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -625,6 +813,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -635,6 +826,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -642,6 +837,14 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -650,10 +853,23 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -662,6 +878,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -670,6 +889,10 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -684,6 +907,14 @@ packages: resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} engines: {node: ^10 || ^12 || >=14} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -705,6 +936,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -722,6 +969,22 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -756,6 +1019,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + vite@8.1.5: resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -840,6 +1106,10 @@ packages: jsdom: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -850,6 +1120,14 @@ packages: engines: {node: '>=8'} hasBin: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + snapshots: '@azure/msal-common@16.11.2': {} @@ -925,6 +1203,21 @@ snapshots: tslib: 2.8.1 optional: true + '@googleapis/drive@20.2.0': + dependencies: + googleapis-common: 8.0.3 + transitivePeerDependencies: + - supports-color + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -943,6 +1236,9 @@ snapshots: '@oxc-project/types@0.139.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -1129,6 +1425,18 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -1137,10 +1445,36 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + buffer-equal-constant-time@1.0.1: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@6.2.2: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -1149,14 +1483,40 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + detect-libc@2.1.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1175,23 +1535,142 @@ snapshots: expect-type@1.4.0: {} + extend@3.0.2: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gaxios@7.3.0: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.4: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-stream@8.0.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.0 + gcp-metadata: 8.1.4 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + googleapis-common@8.0.3: + dependencies: + extend: 3.0.2 + gaxios: 7.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 + qs: 6.15.3 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + gopd@1.2.0: {} + + gtoken@8.0.0: + dependencies: + gaxios: 7.3.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + html-escaper@2.0.2: {} + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} + is-fullwidth-code-point@3.0.0: {} + is-stream@3.0.0: {} isexe@2.0.0: {} @@ -1209,8 +1688,18 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + js-tokens@10.0.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -1298,6 +1787,8 @@ snapshots: lodash.once@4.1.1: {} + lru-cache@10.4.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1312,28 +1803,53 @@ snapshots: dependencies: semver: 7.8.5 + math-intrinsics@1.1.0: {} + merge-stream@2.0.0: {} mimic-fn@4.0.0: {} + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minipass@7.1.3: {} + ms@2.1.3: {} nanoid@3.3.16: {} + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + npm-run-path@5.3.0: dependencies: path-key: 4.0.0 + object-inspect@1.13.4: {} + obug@2.1.4: {} onetime@6.0.0: dependencies: mimic-fn: 4.0.0 + package-json-from-dist@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1346,6 +1862,15 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -1377,6 +1902,34 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -1387,6 +1940,26 @@ snapshots: std-env@4.2.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-final-newline@3.0.0: {} supports-color@7.2.0: @@ -1432,6 +2005,8 @@ snapshots: undici-types@6.21.0: {} + url-template@2.0.8: {} + vite@8.1.5(@types/node@20.19.43): dependencies: lightningcss: 1.33.0 @@ -1471,6 +2046,8 @@ snapshots: transitivePeerDependencies: - msw + web-streams-polyfill@3.3.3: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -1479,3 +2056,15 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 diff --git a/src/google.ts b/src/google.ts new file mode 100644 index 0000000..c83e7cd --- /dev/null +++ b/src/google.ts @@ -0,0 +1,741 @@ +import { Readable } from "node:stream"; + +import type { BaseExternalAccountClient, GoogleAuth, OAuth2Client } from "google-auth-library"; + +import type { Transport, TransportUploadResult } from "./transport.js"; +import { DOCX_MIME_TYPE, TransportError, type TransportErrorCode } from "./transport.js"; + +/** + * Least-privilege Drive scope: the app may only see and manage files it created + * itself. This library never requests a broader Drive scope. + */ +export const GOOGLE_DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file"; + +/** Drive converts an uploaded DOCX on import when the file metadata uses this MIME type. */ +export const GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; + +/** + * Google documents a 50 MB ceiling for a text document converted to Google Docs + * format, so a DOCX above this size cannot be imported as a Doc. + * https://support.google.com/drive/answer/37603 + */ +export const GOOGLE_DRIVE_DOCX_IMPORT_MAX_BYTES = 50 * 1024 * 1024; + +/** Fields requested back from Drive after a create or update. */ +const GOOGLE_DRIVE_RESPONSE_FIELDS = "id,name,mimeType,webViewLink"; + +const MAX_GOOGLE_DRIVE_NAME_LENGTH = 255; +/** Drive resource IDs are short opaque tokens, unrelated to document name limits. */ +const MAX_GOOGLE_DRIVE_ID_LENGTH = 128; +const MAX_GOOGLE_DRIVE_CONTEXT_VALUE_LENGTH = 128; +const TRAILING_DOCX_EXTENSION = /\.docx$/i; +const GOOGLE_DRIVE_ID_PATTERN = /^[A-Za-z0-9_-]+$/; +const INSUFFICIENT_PERMISSION_REASON = /^insufficient/i; + +export type GoogleDriveTransportErrorCode = Extract< + TransportErrorCode, + | "GOOGLE_DRIVE_CONFIG_INVALID" + | "GOOGLE_DRIVE_AUTH_FAILED" + | "GOOGLE_DRIVE_NAME_INVALID" + | "GOOGLE_DRIVE_DOCX_TOO_LARGE" + | "GOOGLE_DRIVE_NETWORK_FAILED" + | "GOOGLE_DRIVE_API_FAILED" + | "GOOGLE_DRIVE_RESPONSE_INVALID" +>; + +export interface GoogleDriveTransportErrorContext { + readonly status?: number; + readonly reason?: string; + readonly fileId?: string; +} + +export class GoogleDriveTransportError extends TransportError { + readonly context: GoogleDriveTransportErrorContext | undefined; + + constructor( + code: GoogleDriveTransportErrorCode, + message: string, + guidance: string, + options: { + readonly cause?: unknown; + readonly context?: GoogleDriveTransportErrorContext; + } = {}, + ) { + super(code, message, guidance, "cause" in options ? { cause: options.cause } : {}); + this.name = "GoogleDriveTransportError"; + this.context = options.context; + } +} + +/** Auth clients accepted by the Drive v3 client. `OAuth2Client` is the usual choice. */ +export type GoogleDriveAuthClient = BaseExternalAccountClient | GoogleAuth | OAuth2Client; + +export interface GoogleDriveFileMetadata { + readonly id?: string | null; + readonly name?: string | null; + readonly mimeType?: string | null; + readonly webViewLink?: string | null; +} + +export interface GoogleDriveFileResponse { + readonly data?: GoogleDriveFileMetadata | null; +} + +export interface GoogleDriveMediaBody { + readonly mimeType: string; + readonly body: Readable; +} + +export interface GoogleDriveCreateFileParams { + readonly requestBody: { + readonly name: string; + readonly mimeType: string; + readonly parents?: string[]; + }; + readonly media: GoogleDriveMediaBody; + readonly fields: string; + readonly supportsAllDrives: boolean; +} + +export interface GoogleDriveUpdateFileParams { + readonly fileId: string; + readonly requestBody: { + readonly name: string; + readonly mimeType: string; + }; + readonly media: GoogleDriveMediaBody; + readonly fields: string; + readonly supportsAllDrives: boolean; +} + +/** + * Minimal structural view of the Drive v3 files resource used by this transport. + * Consumers can inject a stand-in for tests without constructing a real client. + */ +export interface GoogleDriveFilesClient { + readonly files: { + create(params: GoogleDriveCreateFileParams): Promise; + update(params: GoogleDriveUpdateFileParams): Promise; + }; +} + +export type GoogleDriveNameMapper = (canonicalId: string) => string; + +export type GoogleDriveExistingFileIdResolver = ( + canonicalId: string, +) => string | undefined | Promise; + +export interface GoogleDriveTransportDestination extends TransportUploadResult { + readonly kind: "google-drive"; + readonly fileId: string; + readonly name: string; + readonly webViewLink?: string; + readonly mimeType?: string; +} + +export interface GoogleDriveTransportOptionsBase { + readonly folderId?: string; + readonly mapCanonicalId?: GoogleDriveNameMapper; + readonly resolveExistingFileId?: GoogleDriveExistingFileIdResolver; +} + +export type GoogleDriveTransportOptions = GoogleDriveTransportOptionsBase & + ( + | { + readonly auth: GoogleDriveAuthClient; + readonly driveClient?: never; + } + | { + readonly driveClient: GoogleDriveFilesClient; + readonly auth?: never; + } + ); + +export class GoogleDriveTransport implements Transport { + readonly folderId: string | undefined; + + readonly #auth: GoogleDriveAuthClient | undefined; + readonly #mapCanonicalId: GoogleDriveNameMapper; + readonly #usesDefaultNameMapper: boolean; + readonly #resolveExistingFileId: GoogleDriveExistingFileIdResolver | undefined; + #driveClient: Promise | undefined; + + constructor(options: GoogleDriveTransportOptions) { + validateAuthShape(options); + + if (options.folderId !== undefined) { + validateFolderId(options.folderId); + } + + this.folderId = options.folderId; + this.#usesDefaultNameMapper = options.mapCanonicalId === undefined; + this.#mapCanonicalId = options.mapCanonicalId ?? defaultGoogleDocName; + this.#resolveExistingFileId = options.resolveExistingFileId; + this.#auth = options.auth; + this.#driveClient = + options.driveClient === undefined ? undefined : Promise.resolve(options.driveClient); + } + + async upload(canonicalId: string, docx: Uint8Array): Promise { + validateGoogleDriveDocxSize(docx.byteLength); + validateCanonicalId(canonicalId); + + const name = validateGoogleDocName( + mapCanonicalId(canonicalId, this.#mapCanonicalId), + this.#usesDefaultNameMapper, + ); + // Load-bearing copy: Readable.from() special-cases Buffer into a single + // chunk, but iterates a raw Uint8Array byte by byte, which would emit one + // chunk per byte and corrupt every upload. It also snapshots the bytes + // before any await, so caller mutation cannot race the upload. + const bytes = Buffer.from(docx); + const existingFileId = await this.#resolveFileId(canonicalId); + const driveClient = await this.#getDriveClient(); + const response = + existingFileId === undefined + ? await createFile(driveClient, canonicalId, name, bytes, this.folderId) + : await updateFile(driveClient, canonicalId, existingFileId, name, bytes); + + return parseFileResponse(response, canonicalId, name); + } + + /** + * The Drive SDK is a heavy transitive dependency, so it is imported on first + * upload rather than at module load. Consumers that only use another + * transport never pay for it. + */ + #getDriveClient(): Promise { + this.#driveClient ??= importDriveFilesClient(this.#auth).catch((cause: unknown) => { + // Never memoize a failed import, or one bad load would poison the + // transport for the rest of the process. + this.#driveClient = undefined; + throw cause; + }); + + return this.#driveClient; + } + + async #resolveFileId(canonicalId: string): Promise { + const resolver = this.#resolveExistingFileId; + + if (resolver === undefined) { + return undefined; + } + + let resolved: string | undefined; + + try { + resolved = await resolver(canonicalId); + } catch (cause) { + if (cause instanceof GoogleDriveTransportError) { + throw cause; + } + + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_CONFIG_INVALID", + `Resolving the existing Google Drive file ID failed for canonical ID ${JSON.stringify( + canonicalId, + )}.`, + "Fix resolveExistingFileId so it returns a stored Drive file ID or undefined.", + { cause }, + ); + } + + if (resolved === undefined) { + return undefined; + } + + if (typeof resolved !== "string" || resolved.trim() === "" || !isSafeDriveId(resolved)) { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_CONFIG_INVALID", + `resolveExistingFileId returned an invalid Google Drive file ID for canonical ID ${JSON.stringify( + canonicalId, + )}.`, + "Fix resolveExistingFileId so it returns the trimmed Drive file ID recorded by the consumer manifest, or undefined for a new document.", + ); + } + + return resolved; + } +} + +async function createFile( + driveClient: GoogleDriveFilesClient, + canonicalId: string, + name: string, + bytes: Buffer, + folderId: string | undefined, +): Promise { + const requestBody: { name: string; mimeType: string; parents?: string[] } = { + name, + mimeType: GOOGLE_DOC_MIME_TYPE, + }; + + if (folderId !== undefined) { + requestBody.parents = [folderId]; + } + + try { + return await driveClient.files.create({ + requestBody, + media: { mimeType: DOCX_MIME_TYPE, body: Readable.from(bytes) }, + fields: GOOGLE_DRIVE_RESPONSE_FIELDS, + supportsAllDrives: true, + }); + } catch (cause) { + throw createApiError(cause, canonicalId, "create"); + } +} + +async function updateFile( + driveClient: GoogleDriveFilesClient, + canonicalId: string, + fileId: string, + name: string, + bytes: Buffer, +): Promise { + try { + // Drive rejects `parents` on update, and this transport does not silently + // move an existing document, so folderId is intentionally create-only. + return await driveClient.files.update({ + fileId, + requestBody: { name, mimeType: GOOGLE_DOC_MIME_TYPE }, + media: { mimeType: DOCX_MIME_TYPE, body: Readable.from(bytes) }, + fields: GOOGLE_DRIVE_RESPONSE_FIELDS, + supportsAllDrives: true, + }); + } catch (cause) { + throw createApiError(cause, canonicalId, "update"); + } +} + +export function validateGoogleDriveDocxSize(byteLength: number): void { + if (!Number.isSafeInteger(byteLength) || byteLength < 0) { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_DOCX_TOO_LARGE", + "DOCX byte length must be a non-negative safe integer.", + "Pass a real Uint8Array produced by convertMarkdownToDocx().", + ); + } + + if (byteLength > GOOGLE_DRIVE_DOCX_IMPORT_MAX_BYTES) { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_DOCX_TOO_LARGE", + "Google Drive only converts text documents up to 50 MB into Google Docs format.", + "Split the source document or publish a smaller DOCX.", + ); + } +} + +/** + * Default Google Doc name mapper. Drive has no path namespace, so the canonical + * ID is used verbatim except for a trailing `.docx` extension: a converted + * Google Doc should not be named `foo.docx`. + */ +export function defaultGoogleDocName(canonicalId: string): string { + return canonicalId.replace(TRAILING_DOCX_EXTENSION, ""); +} + +async function importDriveFilesClient( + auth: GoogleDriveAuthClient | undefined, +): Promise { + if (auth === undefined) { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_CONFIG_INVALID", + "Google Drive transport has no auth client to build a Drive client with.", + `Pass a google-auth-library client as auth, authorized for the ${GOOGLE_DRIVE_FILE_SCOPE} scope, or pass driveClient.`, + ); + } + + const { drive } = await import("@googleapis/drive"); + const driveClient = drive({ version: "v3", auth }); + + return { + files: { + create: async (params) => driveClient.files.create(params), + update: async (params) => driveClient.files.update(params), + }, + }; +} + +function validateAuthShape(options: GoogleDriveTransportOptions): void { + const candidate = options as { + readonly auth?: unknown; + readonly driveClient?: unknown; + }; + const hasAuth = candidate.auth !== undefined; + const hasDriveClient = candidate.driveClient !== undefined; + + if (hasAuth && hasDriveClient) { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_CONFIG_INVALID", + "Google Drive transport auth configuration is ambiguous.", + `Pass either auth or driveClient, not both. The auth client must already hold the ${GOOGLE_DRIVE_FILE_SCOPE} scope.`, + ); + } + + if (!hasAuth && !hasDriveClient) { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_CONFIG_INVALID", + "Google Drive transport auth configuration is required.", + `Pass a google-auth-library client as auth, authorized for the ${GOOGLE_DRIVE_FILE_SCOPE} scope, or pass driveClient.`, + ); + } +} + +function validateFolderId(folderId: string): void { + if (typeof folderId !== "string" || folderId.trim() === "") { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_CONFIG_INVALID", + "Google Drive folderId must be a non-empty string when provided.", + "Pass the Drive folder ID that should own newly created documents, or omit folderId.", + ); + } + + if (!isSafeDriveId(folderId)) { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_CONFIG_INVALID", + "Google Drive folderId is not a valid Drive resource ID.", + "Pass the opaque Drive folder ID as it appears in the Drive URL: letters, digits, hyphens, and underscores only.", + ); + } +} + +function isSafeDriveId(value: string): boolean { + return value.length <= MAX_GOOGLE_DRIVE_ID_LENGTH && GOOGLE_DRIVE_ID_PATTERN.test(value); +} + +function validateCanonicalId(canonicalId: string): void { + if (typeof canonicalId !== "string" || canonicalId.trim() === "") { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_NAME_INVALID", + "A non-empty canonical ID is required for Google Drive upload.", + "Pass the stable document ID used by the source system.", + ); + } + + if (canonicalId !== canonicalId.trim() || canonicalId.includes("\0")) { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_NAME_INVALID", + "Google Drive canonical IDs must be trimmed and must not contain NUL bytes.", + "Normalize the source document ID before uploading.", + ); + } +} + +function mapCanonicalId(canonicalId: string, mapper: GoogleDriveNameMapper): string { + try { + const mappedName = mapper(canonicalId); + + if (typeof mappedName !== "string") { + throw new TypeError("Google Drive name mapper must return a string."); + } + + return mappedName; + } catch (cause) { + if (cause instanceof GoogleDriveTransportError) { + throw cause; + } + + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_NAME_INVALID", + `Google Drive name mapping failed for canonical ID ${JSON.stringify(canonicalId)}.`, + "Fix mapCanonicalId so it returns a valid Google Doc name.", + { cause }, + ); + } +} + +function validateGoogleDocName(name: string, usesDefaultMapper: boolean): string { + if (name.trim() === "") { + throw invalidGoogleDocNameError("must be non-empty", usesDefaultMapper); + } + + if (name !== name.trim()) { + throw invalidGoogleDocNameError( + "must not have leading or trailing whitespace", + usesDefaultMapper, + ); + } + + if (name.includes("\0") || containsControlCharacter(name)) { + throw invalidGoogleDocNameError( + "must not contain NUL bytes or control characters", + usesDefaultMapper, + ); + } + + if (name.includes("/")) { + throw invalidGoogleDocNameError( + "must not contain slashes, because Drive names are not paths", + usesDefaultMapper, + ); + } + + if (name.length > MAX_GOOGLE_DRIVE_NAME_LENGTH) { + throw invalidGoogleDocNameError( + `must not exceed ${MAX_GOOGLE_DRIVE_NAME_LENGTH} characters`, + usesDefaultMapper, + ); + } + + return name; +} + +function invalidGoogleDocNameError( + reason: string, + usesDefaultMapper: boolean, +): GoogleDriveTransportError { + return new GoogleDriveTransportError( + "GOOGLE_DRIVE_NAME_INVALID", + `The ${usesDefaultMapper ? "default-mapped" : "mapped"} Google Doc name ${reason}.`, + usesDefaultMapper + ? "This canonical ID is not usable as a Drive document name on its own; pass mapCanonicalId to map it to a valid name." + : "Return a single trimmed Drive document name from mapCanonicalId, not a path.", + ); +} + +function containsControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + + if (codePoint !== undefined && (codePoint <= 31 || codePoint === 127)) { + return true; + } + } + + return false; +} + +function createApiError( + cause: unknown, + canonicalId: string, + operation: "create" | "update", +): GoogleDriveTransportError { + if (cause instanceof GoogleDriveTransportError) { + return cause; + } + + const context = readDriveErrorContext(cause); + const details = [ + context.status === undefined ? undefined : `status ${context.status}`, + context.reason === undefined ? undefined : `Drive reason ${context.reason}`, + ].filter((detail): detail is string => detail !== undefined); + const suffix = details.length === 0 ? "" : ` (${details.join(", ")})`; + + if (context.status === undefined) { + // No HTTP status reached us, so the request never completed: transport-level + // failure (DNS, TLS, socket) rather than a decision by the Drive API. + return new GoogleDriveTransportError( + "GOOGLE_DRIVE_NETWORK_FAILED", + `Failed to reach the Google Drive API for canonical ID ${JSON.stringify(canonicalId)}.`, + "The request did not complete, so Drive never decided on it. This is usually connectivity, but a client misconfiguration can also prevent the request from being sent — do not retry indefinitely.", + { context }, + ); + } + + if (isAuthFailure(context)) { + return new GoogleDriveTransportError( + "GOOGLE_DRIVE_AUTH_FAILED", + `Google Drive rejected the credentials for canonical ID ${JSON.stringify( + canonicalId, + )}${suffix}.`, + `Refresh the auth client the consumer owns and confirm it is authorized for the ${GOOGLE_DRIVE_FILE_SCOPE} scope.`, + { context }, + ); + } + + return new GoogleDriveTransportError( + "GOOGLE_DRIVE_API_FAILED", + `Google Drive files.${operation} failed for canonical ID ${JSON.stringify( + canonicalId, + )}${suffix}.`, + "Check the Drive folder ID, the stored file ID, and the drive.file authorization of the supplied auth client.", + { context }, + ); +} + +/** + * A 401 is always an auth failure. A 403 is overloaded (quota, rate limit, + * sharing policy), so only the `insufficient*` reasons — the shape Drive + * returns for a token missing `drive.file` — are treated as auth failures. + */ +function isAuthFailure(context: GoogleDriveTransportErrorContext): boolean { + if (context.status === 401) { + return true; + } + + return ( + context.status === 403 && + context.reason !== undefined && + INSUFFICIENT_PERMISSION_REASON.test(context.reason) + ); +} + +function readDriveErrorContext(cause: unknown): GoogleDriveTransportErrorContext { + const status = readStatus(cause); + const reason = readReason(cause); + const context: { status?: number; reason?: string } = {}; + + if (status !== undefined) { + context.status = status; + } + + if (reason !== undefined) { + context.reason = reason; + } + + return context; +} + +function readStatus(cause: unknown): number | undefined { + const direct = getNumberProperty(cause, "status") ?? getNumberProperty(cause, "code"); + + if (direct !== undefined) { + return direct; + } + + const response = getObjectProperty(cause, "response"); + const responseStatus = getNumberProperty(response, "status"); + + if (responseStatus !== undefined) { + return responseStatus; + } + + return getNumberProperty(getObjectProperty(getObjectProperty(response, "data"), "error"), "code"); +} + +function readReason(cause: unknown): string | undefined { + const error = getObjectProperty( + getObjectProperty(getObjectProperty(cause, "response"), "data"), + "error", + ); + const errors = error === undefined ? undefined : error.errors; + const firstError = Array.isArray(errors) ? errors[0] : undefined; + + return ( + sanitizeContextValue(getStringProperty(firstError, "reason")) ?? + sanitizeContextValue(getStringProperty(error, "status")) + ); +} + +function sanitizeContextValue(value: string | undefined): string | undefined { + if (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_GOOGLE_DRIVE_CONTEXT_VALUE_LENGTH); +} + +function parseFileResponse( + response: GoogleDriveFileResponse, + canonicalId: string, + requestedName: string, +): GoogleDriveTransportDestination { + const file = response.data; + const fileId = getStringProperty(file, "id"); + + if (fileId === undefined || fileId.trim() === "") { + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_RESPONSE_INVALID", + `Google Drive did not return a file ID for canonical ID ${JSON.stringify(canonicalId)}.`, + "Retry the upload or inspect the Drive API response outside this library.", + ); + } + + const returnedMimeType = getStringProperty(file, "mimeType"); + + // Drive reports what the stored file actually is. Anything other than a + // Google Doc means the DOCX was stored as a blob instead of being converted: + // typically resolveExistingFileId pointing at a plain DOCX upload, where every + // future publish would silently write a file nobody can open as a Doc. + if ( + returnedMimeType !== undefined && + returnedMimeType !== "" && + returnedMimeType !== GOOGLE_DOC_MIME_TYPE + ) { + // fileId is unvalidated Drive response data, so it is sanitized for the + // human-readable strings. context.fileId stays raw: the consumer needs the + // real ID to delete or adopt the orphaned file. + const safeFileId = sanitizeContextValue(fileId) ?? "unknown"; + + throw new GoogleDriveTransportError( + "GOOGLE_DRIVE_RESPONSE_INVALID", + `Google Drive stored canonical ID ${JSON.stringify(canonicalId)} as ${ + sanitizeContextValue(returnedMimeType) ?? "an unknown type" + } instead of a Google Doc (file ID ${safeFileId}).`, + `Drive did not convert the upload. Remove or re-point the stored file ID for this canonical ID, and delete or adopt Drive file ${safeFileId} by hand.`, + { context: { fileId } }, + ); + } + + const returnedName = getStringProperty(file, "name"); + const destination: { + kind: "google-drive"; + destinationId: string; + fileId: string; + name: string; + webViewLink?: string; + mimeType?: string; + } = { + kind: "google-drive", + destinationId: fileId, + fileId, + name: returnedName === undefined || returnedName === "" ? requestedName : returnedName, + }; + const webViewLink = getStringProperty(file, "webViewLink"); + + if (webViewLink !== undefined && webViewLink.trim() !== "") { + destination.webViewLink = webViewLink; + } + + if (returnedMimeType !== undefined && returnedMimeType.trim() !== "") { + destination.mimeType = returnedMimeType; + } + + 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; +} + +function getNumberProperty(value: unknown, key: string): number | undefined { + if (typeof value !== "object" || value === null || !Object.hasOwn(value, key)) { + return undefined; + } + + const property = (value as Record)[key]; + + return typeof property === "number" && Number.isFinite(property) ? property : undefined; +} diff --git a/src/index.ts b/src/index.ts index 1f62662..9d04ad1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,28 @@ +export type { + GoogleDriveAuthClient, + GoogleDriveCreateFileParams, + GoogleDriveExistingFileIdResolver, + GoogleDriveFileMetadata, + GoogleDriveFileResponse, + GoogleDriveFilesClient, + GoogleDriveMediaBody, + GoogleDriveNameMapper, + GoogleDriveTransportDestination, + GoogleDriveTransportErrorCode, + GoogleDriveTransportErrorContext, + GoogleDriveTransportOptions, + GoogleDriveTransportOptionsBase, + GoogleDriveUpdateFileParams, +} from "./google.js"; +export { + defaultGoogleDocName, + GOOGLE_DOC_MIME_TYPE, + GOOGLE_DRIVE_DOCX_IMPORT_MAX_BYTES, + GOOGLE_DRIVE_FILE_SCOPE, + GoogleDriveTransport, + GoogleDriveTransportError, + validateGoogleDriveDocxSize, +} from "./google.js"; export type { ConvertMarkdownToDocxOptions, MarkdownPostprocessor, diff --git a/src/sharepoint.ts b/src/sharepoint.ts index ad58359..8200c5f 100644 --- a/src/sharepoint.ts +++ b/src/sharepoint.ts @@ -1,12 +1,12 @@ import { ConfidentialClientApplication } from "@azure/msal-node"; import type { Transport, TransportUploadResult } from "./transport.js"; -import { TransportError, type TransportErrorCode } from "./transport.js"; +import { DOCX_MIME_TYPE, TransportError, type TransportErrorCode } from "./transport.js"; + +export { DOCX_MIME_TYPE }; 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"; diff --git a/src/transport.ts b/src/transport.ts index ecaf169..401b58c 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -7,6 +7,13 @@ export type TransportErrorCode = | "TRANSPORT_DESTINATION_INVALID" | "TRANSPORT_DESTINATION_OUTSIDE_ROOT" | "TRANSPORT_WRITE_FAILED" + | "GOOGLE_DRIVE_CONFIG_INVALID" + | "GOOGLE_DRIVE_AUTH_FAILED" + | "GOOGLE_DRIVE_NAME_INVALID" + | "GOOGLE_DRIVE_DOCX_TOO_LARGE" + | "GOOGLE_DRIVE_NETWORK_FAILED" + | "GOOGLE_DRIVE_API_FAILED" + | "GOOGLE_DRIVE_RESPONSE_INVALID" | "SHAREPOINT_CONFIG_INVALID" | "SHAREPOINT_AUTH_FAILED" | "SHAREPOINT_PATH_INVALID" @@ -15,6 +22,9 @@ export type TransportErrorCode = | "SHAREPOINT_HTTP_FAILED" | "SHAREPOINT_RESPONSE_INVALID"; +export const DOCX_MIME_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + export interface TransportUploadResult { readonly destinationId: string; } diff --git a/tests/index.test.ts b/tests/index.test.ts index 02bd942..1d8201b 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,9 @@ import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { strFromU8, unzipSync } from "fflate"; +import { OAuth2Client } from "google-auth-library"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -12,8 +14,20 @@ import { createSharePointClientSecretAccessTokenProvider, DEFAULT_SOURCE_DATE_EPOCH, DOCX_MIME_TYPE, + defaultGoogleDocName, doctor, encodeSharePointRelativePath, + GOOGLE_DOC_MIME_TYPE, + GOOGLE_DRIVE_DOCX_IMPORT_MAX_BYTES, + GOOGLE_DRIVE_FILE_SCOPE, + type GoogleDriveCreateFileParams, + type GoogleDriveFileMetadata, + type GoogleDriveFileResponse, + type GoogleDriveFilesClient, + GoogleDriveTransport, + GoogleDriveTransportError, + type GoogleDriveTransportOptions, + type GoogleDriveUpdateFileParams, LocalFileTransport, MICROSOFT_GRAPH_DEFAULT_SCOPE, PandocError, @@ -29,6 +43,13 @@ import { validateSharePointDocxSize, } from "../src/index.js"; +// The only module mock in this suite. GoogleDriveTransport imports +// @googleapis/drive lazily and memoizes the client it builds, so that path is +// unreachable through dependency injection; every other seam stays DI. +const googleDriveModuleMock = vi.hoisted(() => ({ drive: vi.fn() })); + +vi.mock("@googleapis/drive", () => ({ drive: googleDriveModuleMock.drive })); + const fixtureInputPath = "tests/fixtures/golden/publish/basic-note/input.md"; const fixtureExpectedDocumentXmlPath = "tests/fixtures/golden/publish/basic-note/expected.document.xml"; @@ -1236,6 +1257,607 @@ describe("SharePoint transport", () => { }); }); +describe("Google Drive transport", () => { + it("creates a converted Google Doc from DOCX bytes when no file ID is known", async () => { + const harness = createGoogleDriveHarness({ + file: { + id: "file-1", + name: "Quarterly Plan", + mimeType: GOOGLE_DOC_MIME_TYPE, + webViewLink: "https://docs.google.com/document/d/file-1/edit", + }, + }); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + + const result = await transport.upload("Quarterly Plan.docx", new Uint8Array([1, 2, 3])); + + expect(result).toEqual({ + kind: "google-drive", + destinationId: "file-1", + fileId: "file-1", + name: "Quarterly Plan", + mimeType: GOOGLE_DOC_MIME_TYPE, + webViewLink: "https://docs.google.com/document/d/file-1/edit", + }); + expect(harness.update).not.toHaveBeenCalled(); + expect(harness.calls).toEqual([ + { + operation: "create", + fileId: undefined, + name: "Quarterly Plan", + requestMimeType: GOOGLE_DOC_MIME_TYPE, + mediaMimeType: DOCX_MIME_TYPE, + parents: undefined, + parameterKeys: ["fields", "media", "requestBody", "supportsAllDrives"], + requestBodyKeys: ["mimeType", "name"], + body: [1, 2, 3], + fields: "id,name,mimeType,webViewLink", + supportsAllDrives: true, + }, + ]); + }); + + it("omits optional destination fields Drive did not return", async () => { + const harness = createGoogleDriveHarness({ file: { id: "file-bare" } }); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + + await expect(transport.upload("Bare", new Uint8Array([1]))).resolves.toEqual({ + kind: "google-drive", + destinationId: "file-bare", + fileId: "file-bare", + name: "Bare", + }); + }); + + it("updates the existing Doc when resolveExistingFileId yields a stored file ID", async () => { + const harness = createGoogleDriveHarness({ file: { id: "file-existing", name: "Handbook" } }); + const seenIds: string[] = []; + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + resolveExistingFileId: async (canonicalId) => { + seenIds.push(canonicalId); + return "file-existing"; + }, + }); + + const result = await transport.upload("Handbook", new Uint8Array([4, 5])); + + expect(seenIds).toEqual(["Handbook"]); + expect(harness.create).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + kind: "google-drive", + destinationId: "file-existing", + fileId: "file-existing", + }); + expect(harness.calls).toEqual([ + { + operation: "update", + fileId: "file-existing", + name: "Handbook", + requestMimeType: GOOGLE_DOC_MIME_TYPE, + mediaMimeType: DOCX_MIME_TYPE, + parents: undefined, + parameterKeys: ["fields", "fileId", "media", "requestBody", "supportsAllDrives"], + requestBodyKeys: ["mimeType", "name"], + body: [4, 5], + fields: "id,name,mimeType,webViewLink", + supportsAllDrives: true, + }, + ]); + }); + + it("creates new Docs inside folderId but never re-parents an existing Doc", async () => { + const harness = createGoogleDriveHarness(); + const created = new GoogleDriveTransport({ + driveClient: harness.client, + folderId: "folder-1", + }); + const updated = new GoogleDriveTransport({ + driveClient: harness.client, + folderId: "folder-1", + resolveExistingFileId: () => "file-existing", + }); + + await created.upload("New Doc", new Uint8Array([1])); + await updated.upload("Existing Doc", new Uint8Array([2])); + + expect(harness.calls[0]).toMatchObject({ + operation: "create", + parents: ["folder-1"], + requestBodyKeys: ["mimeType", "name", "parents"], + }); + expect(harness.calls[1]).toMatchObject({ + operation: "update", + fileId: "file-existing", + parents: undefined, + parameterKeys: ["fields", "fileId", "media", "requestBody", "supportsAllDrives"], + requestBodyKeys: ["mimeType", "name"], + }); + expect(harness.calls[1]?.parameterKeys).not.toContain("addParents"); + }); + + it("names no Drive scope other than drive.file anywhere in the module source", async () => { + const googleSourcePath = fileURLToPath(new URL("../src/google.ts", import.meta.url)); + const googleSource = await readFile(googleSourcePath, "utf8"); + const namedScopes = googleSource.match(/https:\/\/www\.googleapis\.com\/auth\/[\w.]+/g) ?? []; + + expect(GOOGLE_DRIVE_FILE_SCOPE).toBe("https://www.googleapis.com/auth/drive.file"); + expect(namedScopes.length).toBeGreaterThan(0); + expect([...new Set(namedScopes)]).toEqual([GOOGLE_DRIVE_FILE_SCOPE]); + }); + + it("exposes no credentials and sends no auth material in Drive requests", async () => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + folderId: "folder-1", + }); + + await transport.upload("No Secrets", new Uint8Array([1])); + + // The transport does retain the consumer's auth client, but every internal + // field is a `#private`, so the only own property it can expose is the + // folder ID the consumer already gave it. + expect(Object.keys(transport)).toEqual(["folderId"]); + expect(JSON.stringify(transport)).toBe('{"folderId":"folder-1"}'); + + const params = harness.create.mock.calls[0]?.[0]; + + expect(Object.keys(params ?? {}).sort()).toEqual([ + "fields", + "media", + "requestBody", + "supportsAllDrives", + ]); + expect(params).not.toHaveProperty("auth"); + expect(params).not.toHaveProperty("headers"); + expect(params).not.toHaveProperty("oauth_token"); + }); + + it("strips a trailing .docx extension from the default Google Doc name", async () => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + + await transport.upload("Release Notes.DOCX", new Uint8Array([1])); + + expect(defaultGoogleDocName("Release Notes.docx")).toBe("Release Notes"); + expect(defaultGoogleDocName("Release Notes.DOCX")).toBe("Release Notes"); + expect(defaultGoogleDocName("docx-primer")).toBe("docx-primer"); + expect(harness.calls[0]?.name).toBe("Release Notes"); + }); + + it("maps opaque canonical IDs to Drive names through mapCanonicalId", async () => { + const harness = createGoogleDriveHarness(); + const seenIds: string[] = []; + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + mapCanonicalId: (canonicalId) => { + seenIds.push(canonicalId); + return "TeamWiki Note 123"; + }, + }); + + await transport.upload("urn:teamwiki:note:123", new Uint8Array([1])); + + expect(seenIds).toEqual(["urn:teamwiki:note:123"]); + expect(harness.calls[0]?.name).toBe("TeamWiki Note 123"); + }); + + it("snapshots DOCX bytes before asynchronous Drive work", async () => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + resolveExistingFileId: async () => undefined, + }); + const docx = new Uint8Array([1, 2, 3]); + + const upload = transport.upload("Snapshot", docx); + docx.fill(9); + await upload; + + expect(harness.calls[0]?.body).toEqual([1, 2, 3]); + }); + + it.each([ + [ + "both auth and driveClient", + (client: GoogleDriveFilesClient) => + ({ + auth: new OAuth2Client({ clientId: "client-id" }), + driveClient: client, + }) as unknown as GoogleDriveTransportOptions, + ], + ["neither auth nor driveClient", () => ({}) as unknown as GoogleDriveTransportOptions], + [ + "blank folder ID", + (client: GoogleDriveFilesClient) => + ({ driveClient: client, folderId: " " }) as GoogleDriveTransportOptions, + ], + [ + "folder ID with a path separator", + (client: GoogleDriveFilesClient) => + ({ driveClient: client, folderId: "folder/child" }) as GoogleDriveTransportOptions, + ], + ])("rejects invalid config: %s", (_label, buildOptions) => { + expect(() => new GoogleDriveTransport(buildOptions(createGoogleDriveHarness().client))).toThrow( + expect.objectContaining({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_CONFIG_INVALID", + }), + ); + }); + + it.each(["", " ", " leading", "trailing ", "bad\0id"])( + "rejects invalid canonical ID %j", + async (canonicalId) => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + + await expect(transport.upload(canonicalId, new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_NAME_INVALID", + }); + expect(harness.create).not.toHaveBeenCalled(); + }, + ); + + it.each([ + "teamwiki/basic-note", + " leading", + "trailing ", + "control\u001Fname", + "\u007Fdelete", + "", + "a".repeat(256), + ])("rejects unsafe mapped Google Doc name %j", async (mappedName) => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + mapCanonicalId: () => mappedName, + }); + + await expect(transport.upload("safe", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_NAME_INVALID", + guidance: expect.stringContaining("mapCanonicalId"), + }); + expect(harness.create).not.toHaveBeenCalled(); + }); + + it("wraps mapper failures with an actionable typed error and original cause", async () => { + const cause = new Error("mapper exploded"); + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + mapCanonicalId: () => { + throw cause; + }, + }); + + await expect(transport.upload("opaque:id", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_NAME_INVALID", + guidance: expect.stringContaining("mapCanonicalId"), + cause, + }); + expect(harness.create).not.toHaveBeenCalled(); + }); + + it("wraps resolveExistingFileId failures and rejects unusable resolved IDs", async () => { + const cause = new Error("manifest unavailable"); + const throwingHarness = createGoogleDriveHarness(); + const throwingTransport = new GoogleDriveTransport({ + driveClient: throwingHarness.client, + resolveExistingFileId: () => { + throw cause; + }, + }); + const invalidHarness = createGoogleDriveHarness(); + const invalidTransport = new GoogleDriveTransport({ + driveClient: invalidHarness.client, + resolveExistingFileId: () => " ", + }); + + await expect(throwingTransport.upload("doc", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_CONFIG_INVALID", + guidance: expect.stringContaining("resolveExistingFileId"), + cause, + }); + await expect(invalidTransport.upload("doc", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_CONFIG_INVALID", + }); + expect(throwingHarness.create).not.toHaveBeenCalled(); + expect(invalidHarness.create).not.toHaveBeenCalled(); + expect(invalidHarness.update).not.toHaveBeenCalled(); + }); + + it("enforces the 50 MB Google Docs import cap before any Drive call", async () => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + const tooLarge = { byteLength: GOOGLE_DRIVE_DOCX_IMPORT_MAX_BYTES + 1 } as Uint8Array; + + await expect(transport.upload("too-large", tooLarge)).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_DOCX_TOO_LARGE", + }); + expect(harness.create).not.toHaveBeenCalled(); + expect(harness.update).not.toHaveBeenCalled(); + }); + + it("maps Drive API rejections to bounded context without leaking tokens", async () => { + const harness = createGoogleDriveHarness({ + createError: Object.assign(new Error("ya29.secret-access-token was rejected"), { + status: 403, + response: { + status: 403, + data: { + error: { + code: 403, + message: "ya29.secret-access-token was rejected", + errors: [{ domain: "global", reason: "storageQuota\nExceeded\u007F" }], + }, + }, + }, + }), + }); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + + await expect(transport.upload("denied", new Uint8Array([1]))).rejects.toSatisfy( + (error: unknown) => + error instanceof GoogleDriveTransportError && + error.code === "GOOGLE_DRIVE_API_FAILED" && + error.context?.status === 403 && + error.context.reason === "storageQuotaExceeded" && + error.cause === undefined && + !String(error).includes("ya29.secret-access-token") && + !String(error).includes("\n") && + !String(error).includes("\u007F"), + ); + }); + + it("maps a 403 with an insufficient-permission reason to an auth failure", async () => { + const harness = createGoogleDriveHarness({ + createError: Object.assign(new Error("Insufficient permissions"), { + status: 403, + response: { + status: 403, + data: { + error: { + code: 403, + errors: [{ domain: "global", reason: "insufficientFilePermissions" }], + }, + }, + }, + }), + }); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + + await expect(transport.upload("no-scope", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_AUTH_FAILED", + guidance: expect.stringContaining(GOOGLE_DRIVE_FILE_SCOPE), + context: { status: 403, reason: "insufficientFilePermissions" }, + }); + }); + + it("distinguishes an unreachable Drive API from a Drive API decision", async () => { + const networkHarness = createGoogleDriveHarness({ + createError: Object.assign(new Error("getaddrinfo ENOTFOUND www.googleapis.com"), { + code: "ENOTFOUND", + }), + }); + // A non-Error rejection carries no status either, so it must not be + // reported as a terminal Drive decision. + const opaqueHarness = createGoogleDriveHarness({ createError: "boom" }); + const networkTransport = new GoogleDriveTransport({ driveClient: networkHarness.client }); + const opaqueTransport = new GoogleDriveTransport({ driveClient: opaqueHarness.client }); + + await expect(networkTransport.upload("offline", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_NETWORK_FAILED", + context: {}, + }); + await expect(opaqueTransport.upload("opaque", new Uint8Array([1]))).rejects.toSatisfy( + (error: unknown) => + error instanceof GoogleDriveTransportError && + error.code === "GOOGLE_DRIVE_NETWORK_FAILED" && + error.cause === undefined && + JSON.stringify(error.context) === "{}", + ); + }); + + it("leaves context undefined on validation errors that never reached Drive", async () => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + + await expect(transport.upload("bad/name", new Uint8Array([1]))).rejects.toSatisfy( + (error: unknown) => + error instanceof GoogleDriveTransportError && + error.code === "GOOGLE_DRIVE_NAME_INVALID" && + error.context === undefined, + ); + }); + + it("rejects a Drive response whose stored file is not a Google Doc", async () => { + const harness = createGoogleDriveHarness({ + file: { id: "file-blob", name: "Handbook", mimeType: DOCX_MIME_TYPE }, + }); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + resolveExistingFileId: () => "file-blob", + }); + + await expect(transport.upload("Handbook", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_RESPONSE_INVALID", + message: expect.stringContaining("file-blob"), + guidance: expect.stringContaining("file-blob"), + context: { fileId: "file-blob" }, + }); + }); + + it("propagates a 404 from files.update without silently creating a duplicate", async () => { + const harness = createGoogleDriveHarness({ + updateError: Object.assign(new Error("File not found: file-gone."), { + status: 404, + response: { + status: 404, + data: { error: { code: 404, errors: [{ domain: "global", reason: "notFound" }] } }, + }, + }), + }); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + resolveExistingFileId: () => "file-gone", + }); + + await expect(transport.upload("Vanished", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_API_FAILED", + context: { status: 404, reason: "notFound" }, + }); + expect(harness.create).not.toHaveBeenCalled(); + }); + + it("rejects a mapCanonicalId that does not return a string", async () => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + mapCanonicalId: () => 42 as unknown as string, + }); + + await expect(transport.upload("doc", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_NAME_INVALID", + cause: expect.objectContaining({ name: "TypeError" }), + }); + expect(harness.create).not.toHaveBeenCalled(); + }); + + it.each([ + ["null, the natural miss for a Map-style manifest", null], + ["a number", 42], + ["an object", {}], + ["a Drive ID with a slash", "folder/file"], + ])("rejects resolveExistingFileId returning %s", async (_label, resolved) => { + const harness = createGoogleDriveHarness(); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + resolveExistingFileId: () => resolved as unknown as string | undefined, + }); + + await expect(transport.upload("doc", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_CONFIG_INVALID", + guidance: expect.stringContaining("resolveExistingFileId"), + }); + expect(harness.create).not.toHaveBeenCalled(); + expect(harness.update).not.toHaveBeenCalled(); + }); + + it("blames the default mapper only when the default mapper produced the bad name", async () => { + const harness = createGoogleDriveHarness(); + const defaultMapped = new GoogleDriveTransport({ driveClient: harness.client }); + const customMapped = new GoogleDriveTransport({ + driveClient: harness.client, + mapCanonicalId: (canonicalId) => canonicalId, + }); + + await expect(defaultMapped.upload("teamwiki/note", new Uint8Array([1]))).rejects.toMatchObject({ + code: "GOOGLE_DRIVE_NAME_INVALID", + message: expect.stringContaining("default-mapped"), + guidance: expect.stringContaining("pass mapCanonicalId"), + }); + await expect(customMapped.upload("teamwiki/note", new Uint8Array([1]))).rejects.toMatchObject({ + code: "GOOGLE_DRIVE_NAME_INVALID", + guidance: expect.stringContaining("Return a single trimmed Drive document name"), + }); + }); + + it("builds the lazily imported Drive client once for concurrent uploads", async () => { + const files = createStubDriveFiles(); + googleDriveModuleMock.drive.mockReset(); + googleDriveModuleMock.drive.mockReturnValue({ files }); + const auth = new OAuth2Client({ clientId: "client-id" }); + const transport = new GoogleDriveTransport({ auth }); + + const [first, second] = await Promise.all([ + transport.upload("A", new Uint8Array([1])), + transport.upload("B", new Uint8Array([2])), + ]); + + expect(googleDriveModuleMock.drive).toHaveBeenCalledTimes(1); + expect(googleDriveModuleMock.drive).toHaveBeenCalledWith({ version: "v3", auth }); + expect(files.create).toHaveBeenCalledTimes(2); + expect(first?.fileId).toBe("file-1"); + expect(second?.fileId).toBe("file-1"); + }); + + it("does not memoize a failed Drive client construction", async () => { + const failure = new Error("Drive client construction failed"); + const files = createStubDriveFiles(); + googleDriveModuleMock.drive.mockReset(); + googleDriveModuleMock.drive + .mockImplementationOnce(() => { + throw failure; + }) + .mockImplementation(() => ({ files })); + const transport = new GoogleDriveTransport({ + auth: new OAuth2Client({ clientId: "client-id" }), + }); + + await expect(transport.upload("Retry", new Uint8Array([1]))).rejects.toBe(failure); + expect(files.create).not.toHaveBeenCalled(); + + // The first attempt must not be cached, so this retries the import. + await expect(transport.upload("Retry", new Uint8Array([1]))).resolves.toMatchObject({ + kind: "google-drive", + fileId: "file-1", + }); + expect(googleDriveModuleMock.drive).toHaveBeenCalledTimes(2); + }); + + it("maps Drive 401 responses to an auth failure", async () => { + const harness = createGoogleDriveHarness({ + file: { id: "file-1" }, + updateError: Object.assign(new Error("Invalid Credentials"), { + status: 401, + response: { + status: 401, + data: { error: { code: 401, status: "UNAUTHENTICATED" } }, + }, + }), + }); + const transport = new GoogleDriveTransport({ + driveClient: harness.client, + resolveExistingFileId: () => "file-1", + }); + + await expect(transport.upload("stale-token", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_AUTH_FAILED", + guidance: expect.stringContaining(GOOGLE_DRIVE_FILE_SCOPE), + context: { status: 401, reason: "UNAUTHENTICATED" }, + }); + }); + + it.each([ + ["missing data", null], + ["missing id", { name: "no id here" }], + ["blank id", { id: " " }], + ])("fails closed on an invalid Drive response: %s", async (_label, file) => { + const harness = createGoogleDriveHarness({ file }); + const transport = new GoogleDriveTransport({ driveClient: harness.client }); + + await expect(transport.upload("bad-response", new Uint8Array([1]))).rejects.toMatchObject({ + name: "GoogleDriveTransportError", + code: "GOOGLE_DRIVE_RESPONSE_INVALID", + }); + }); +}); + async function createTempDocx(name: string): Promise { const tempDirectory = await createTempDirectory(); const docxPath = join(tempDirectory, name); @@ -1326,6 +1948,89 @@ function createSuccessfulSharePointFetch( }; } +interface GoogleDriveCall { + readonly operation: "create" | "update"; + readonly fileId: string | undefined; + readonly name: string; + readonly requestMimeType: string; + readonly mediaMimeType: string; + readonly parents: readonly string[] | undefined; + readonly parameterKeys: readonly string[]; + readonly requestBodyKeys: readonly string[]; + readonly body: readonly number[]; + readonly fields: string; + readonly supportsAllDrives: boolean; +} + +function createGoogleDriveHarness( + options: { + readonly file?: GoogleDriveFileMetadata | null; + readonly createError?: unknown; + readonly updateError?: unknown; + } = {}, +) { + const calls: GoogleDriveCall[] = []; + const file = "file" in options ? options.file : { id: "file-1" }; + const respond = async ( + operation: "create" | "update", + params: GoogleDriveCreateFileParams | GoogleDriveUpdateFileParams, + error: unknown, + ): Promise => { + calls.push(await recordGoogleDriveCall(operation, params)); + + if (error !== undefined) { + throw error; + } + + return { data: file }; + }; + const create = vi.fn(async (params: GoogleDriveCreateFileParams) => + respond("create", params, options.createError), + ); + const update = vi.fn(async (params: GoogleDriveUpdateFileParams) => + respond("update", params, options.updateError), + ); + const client: GoogleDriveFilesClient = { files: { create, update } }; + + return { calls, client, create, update }; +} + +function createStubDriveFiles() { + const response: GoogleDriveFileResponse = { + data: { id: "file-1", name: "Stub", mimeType: GOOGLE_DOC_MIME_TYPE }, + }; + + return { + create: vi.fn(async (_params: GoogleDriveCreateFileParams) => response), + update: vi.fn(async (_params: GoogleDriveUpdateFileParams) => response), + }; +} + +async function recordGoogleDriveCall( + operation: "create" | "update", + params: GoogleDriveCreateFileParams | GoogleDriveUpdateFileParams, +): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of params.media.body) { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + + return { + operation, + fileId: "fileId" in params ? params.fileId : undefined, + name: params.requestBody.name, + requestMimeType: params.requestBody.mimeType, + mediaMimeType: params.media.mimeType, + parents: "parents" in params.requestBody ? params.requestBody.parents : undefined, + parameterKeys: Object.keys(params).sort(), + requestBodyKeys: Object.keys(params.requestBody).sort(), + body: [...Buffer.concat(chunks)], + fields: params.fields, + supportsAllDrives: params.supportsAllDrives, + }; +} + function createSuccessfulDoctorRunner(): PandocRunner { return vi.fn(async () => ({ stdout: "pandoc 3.10", diff --git a/vitest.config.ts b/vitest.config.ts index 19384e8..8cecaa0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["tests/**/*.test.ts"], + restoreMocks: true, }, });