From 95a32638831f80b84afad1e8447fd1534c7af106 Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Mon, 20 Jul 2026 17:09:30 -0600 Subject: [PATCH 1/7] chore: mark Google OAuth installation as completed in auth storage guide TODO --- docs/guides/auth_storage_guide/TODO.md | 2 +- package-lock.json | 1207 ++++++++++++++++++++++++ package.json | 6 + 3 files changed, 1214 insertions(+), 1 deletion(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md index 3e845c3..b2dcde2 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -188,7 +188,7 @@ One adapter behind the existing provider-neutral interface; no rewrite of Phases ### Backend -- [ ] `npm install passport-google-oauth20 googleapis` (+ dev types) +- [x] `npm install passport-google-oauth20 googleapis` (+ dev types) - [ ] Add Google OAuth vars + `GOOGLE_PICKER_API_KEY` to `.env.example` and `backend/README.md` - [ ] Passport Google strategy; wire `GET /google` + `GET /google/callback` - [ ] `getGoogleDriveClient(user)` + `refreshGoogleToken(user)` — real implementations diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b596ec7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1207 @@ +{ + "name": "vizably", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "googleapis": "^173.0.0", + "passport-google-oauth20": "^2.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz", + "integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "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", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "173.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz", + "integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.2.tgz", + "integrity": "sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "7.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad9318f --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "googleapis": "^173.0.0", + "passport-google-oauth20": "^2.0.0" + } +} From 2d088ba210dfde9d573ef40aff3d5dc6fa358d84 Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Mon, 20 Jul 2026 17:26:36 -0600 Subject: [PATCH 2/7] feat(auth): implement Google OAuth integration and Drive API support --- backend/.env.example | 8 +- backend/README.md | 52 +- backend/package-lock.json | 703 ++++++++++++++++++++++++ backend/package.json | 3 + backend/routes/auth.js | 49 +- backend/services/authService.js | 191 ++++++- backend/services/storageService.js | 714 ++++++++++++++++++++++++- backend/tests/auth.test.js | 55 +- backend/tests/authService.test.js | 57 +- backend/tests/storageService.test.js | 227 +++++++- docs/guides/auth_storage_guide/TODO.md | 10 +- 11 files changed, 1991 insertions(+), 78 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index b6195ac..fa1c62c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -23,7 +23,13 @@ GITHUB_APP_PRIVATE_KEY= # Token encryption at rest (generate with: openssl rand -base64 32) ENCRYPTION_KEY= - +# Google OAuth + Picker (Phase 3 — see backend/README.md § Google OAuth setup) +# Cloud Console → APIs & Services → enable Drive API + Google Picker API. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback +# Browser API key for Google Picker (restrict by HTTP referrer). Served via GET /api/auth/config. +GOOGLE_PICKER_API_KEY= # --- Phase 5 (planned, not yet read by the server) ---------------------- # JWT_SECRET=replace-me-with-a-long-random-string diff --git a/backend/README.md b/backend/README.md index 81b3a88..965b0e9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -78,8 +78,7 @@ via `tests/helpers/testEnv.js`; never commit real secrets to the repo. MemoryStore. Restarts log everyone out; multiple server instances do not share sessions. Switch to a persistent store (Redis, etc.) before production deploy. -Google OAuth and `GOOGLE_PICKER_API_KEY` are deferred to Phase 3 — not read by -the server yet. Phase 5 placeholders (`JWT_SECRET`, `DATABASE_URL`) remain in +Phase 5 placeholders (`JWT_SECRET`, `DATABASE_URL`) remain in [`.env.example`](.env.example) comments only. ### GitHub App setup (Phase 1) @@ -135,6 +134,51 @@ codrlabs/vizably org or equivalent) with: See also [`docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md`](../docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md) § OAuth App Configuration. +### Google OAuth setup (Phase 3) + +Phase 0 locked **`drive.file` only** (no `drive.metadata.readonly`). Users pick +an existing folder with the **Google Picker** (client-side); the backend never +lists Drive folders. + +1. [Google Cloud Console](https://console.cloud.google.com/) → create or select a project. +2. **APIs & Services → Library** → enable **Google Drive API** and **Google Picker API**. +3. **APIs & Services → OAuth consent screen** → External (or Internal for Workspace). + Add scopes: `openid`, `email`, `profile`, and + `https://www.googleapis.com/auth/drive.file`. +4. **APIs & Services → Credentials → Create credentials → OAuth client ID** → + Application type **Web application**. + - Authorized JavaScript origins: `http://localhost:5173` (Vite) and + `http://localhost:3000` if needed. + - Authorized redirect URI: + `http://localhost:3000/api/auth/google/callback` +5. Copy Client ID + Client secret into `backend/.env`: + - `GOOGLE_CLIENT_ID` + - `GOOGLE_CLIENT_SECRET` + - `GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback` +6. Create a **Browser API key** for Picker (see below) → `GOOGLE_PICKER_API_KEY`. + +`GET /api/auth/config` returns `{ googleClientId, googlePickerApiKey }` for the +frontend Picker. Restrict the API key by HTTP referrer in Cloud Console. + +#### Getting `GOOGLE_PICKER_API_KEY` + +1. Cloud Console → **APIs & Services → Credentials → Create credentials → API key**. +2. Open the new key → **Application restrictions → HTTP referrers (web sites)**. + Add: + - `http://localhost:5173/*` + - `http://localhost:3000/*` + - your production frontend origin when you deploy (e.g. `https://app.vizably.example/*`) +3. **API restrictions → Restrict key** → select **Google Picker API** (and + optionally **Google Drive API** if the Picker docs for your setup require it). +4. Save → copy the key into `backend/.env` as `GOOGLE_PICKER_API_KEY`. +5. Never commit the key. Treat it as public-ish (it ships to the browser) but + always keep referrer + API restrictions on. + +Sign-in flow: `GET /api/auth/google` → Google consent → callback → frontend +`/connect?provider=google`. Folder selection is Picker-only; then +`POST /api/auth/storage/validate` and `POST /api/auth/storage` with +`storageRef: { id: "", name: "…" }`. + ## Endpoints | Method | Path | Notes | @@ -142,7 +186,9 @@ See also [`docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation. | GET | `/health` | liveness probe | | GET | `/api/auth/github` | start GitHub OAuth | | GET | `/api/auth/github/callback` | GitHub OAuth callback | -| GET | `/api/auth/google` | stub (501) until Phase 3 | +| GET | `/api/auth/google` | start Google OAuth (`drive.file`) | +| GET | `/api/auth/google/callback` | Google OAuth callback | +| GET | `/api/auth/config` | `{ googleClientId, googlePickerApiKey }` | | GET | `/api/auth/storages` | list GitHub repos (`?provider=github`) | | POST | `/api/auth/storage/validate` | fit-check selected storage | | POST | `/api/auth/storage` | load or init account storage | diff --git a/backend/package-lock.json b/backend/package-lock.json index 46266ec..cfb123d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,14 +15,17 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "express-session": "^1.19.0", + "googleapis": "^173.0.0", "passport": "^0.7.0", "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "puppeteer": "^24.3.0" }, "devDependencies": { "@types/express-session": "^1.19.0", "@types/passport": "^1.0.17", "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2.0.17", "nodemon": "^3.1.14", "supertest": "^7.2.2" } @@ -50,6 +53,102 @@ "node": ">=6.9.0" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -241,6 +340,16 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@puppeteer/browsers": { "version": "2.13.2", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", @@ -373,6 +482,18 @@ "@types/passport-oauth2": "*" } }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", @@ -646,6 +767,26 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", @@ -670,6 +811,15 @@ "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "license": "Apache-2.0" }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -742,6 +892,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -972,6 +1128,20 @@ } } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -1074,6 +1244,21 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1340,6 +1525,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -1382,6 +1573,29 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1416,6 +1630,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -1456,6 +1686,18 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -1516,6 +1758,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz", + "integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1591,6 +1861,27 @@ "node": ">= 14" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "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", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1604,6 +1895,125 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "173.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz", + "integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.2.tgz", + "integrity": "sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "7.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1616,6 +2026,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -1842,6 +2265,27 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1860,6 +2304,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -1872,6 +2325,27 @@ "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -1981,6 +2455,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2011,6 +2494,53 @@ "node": ">= 0.4.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-fetch/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -2139,6 +2669,12 @@ "node": ">= 14" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2207,6 +2743,18 @@ "node": ">= 0.8.0" } }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", @@ -2235,6 +2783,37 @@ "node": ">= 0.4.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -2457,6 +3036,21 @@ "node": ">=4" } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2562,6 +3156,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2634,6 +3249,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -2729,6 +3356,21 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2741,6 +3383,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -2939,6 +3594,12 @@ "node": ">= 0.8" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -2957,12 +3618,36 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "license": "Apache-2.0" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -2980,6 +3665,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index ceaaa0c..c806cd3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,14 +18,17 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "express-session": "^1.19.0", + "googleapis": "^173.0.0", "passport": "^0.7.0", "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "puppeteer": "^24.3.0" }, "devDependencies": { "@types/express-session": "^1.19.0", "@types/passport": "^1.0.17", "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2.0.17", "nodemon": "^3.1.14", "supertest": "^7.2.2" } diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 8379da2..396f801 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -32,24 +32,57 @@ function makeAuthRouter({ authService, storageService }) { }, ); - router.get('/google', (_req, res) => { - res.status(501).json({ - error: 'Google sign-in is not available until Phase 3', - }); + router.get('/google', (req, res, next) => { + if (!authService.isGoogleConfigured()) { + return res.status(503).json({ + error: + 'Google sign-in is not configured. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REDIRECT_URI.', + }); + } + req.session.authProvider = 'google'; + return authService.authenticateGoogle()(req, res, next); }); - router.get('/google/callback', (_req, res) => { - res.status(501).json({ - error: 'Google sign-in is not available until Phase 3', + router.get( + '/google/callback', + (req, res, next) => { + if (!authService.isGoogleConfigured()) { + return res.status(503).json({ + error: 'Google sign-in is not configured.', + }); + } + return authService.authenticateGoogle({ + failureRedirect: `${frontendOrigin}/connect?provider=google&error=auth_failed`, + })(req, res, next); + }, + (req, res) => { + res.redirect(`${frontendOrigin}/connect?provider=google`); + }, + ); + + /** + * Public config for client-side Google Picker (browser API key + OAuth client id). + * Restrict the key by HTTP referrer in Cloud Console. + */ + router.get('/config', (_req, res) => { + return res.json({ + googleClientId: authService.googleClientId || null, + googlePickerApiKey: authService.googlePickerApiKey || null, }); }); router.get('/storages', requireAuth, async (req, res) => { try { const provider = req.query.provider; + if (provider === 'google') { + return res.status(400).json({ + error: + 'Google folders are selected via Google Picker; listStorages is GitHub-only', + }); + } if (provider !== 'github') { return res.status(400).json({ - error: 'Only provider=github is supported in Phase 1', + error: 'Only provider=github is supported for listStorages', }); } diff --git a/backend/services/authService.js b/backend/services/authService.js index defe32e..ddd8419 100644 --- a/backend/services/authService.js +++ b/backend/services/authService.js @@ -3,15 +3,23 @@ * and authenticated provider clients for the storage service. * * No user DB: the session payload (identity + encrypted tokens + attached - * `storage`) is the user. Google auth/Drive clients are stubbed until Phase 3. + * `storage`) is the user. Google uses `drive.file` only (Picker selects folders). */ const crypto = require('crypto'); const session = require('express-session'); const passport = require('passport'); const GitHubStrategy = require('passport-github2').Strategy; +const GoogleStrategy = require('passport-google-oauth20').Strategy; +const { google } = require('googleapis'); const { Octokit } = require('@octokit/rest'); -const GOOGLE_NOT_AVAILABLE = 'Google auth is not available until Phase 3'; +/** Phase 0 choice: openid/email/profile + drive.file (Picker flow). */ +const GOOGLE_SCOPES = [ + 'openid', + 'email', + 'profile', + 'https://www.googleapis.com/auth/drive.file', +]; class AuthService { /** @@ -23,6 +31,10 @@ class AuthService { * @param {string} [deps.githubCallbackUrl] * @param {string} [deps.githubAppId] numeric GitHub App id (for installation tokens) * @param {string} [deps.githubAppPrivateKey] PEM private key for the GitHub App + * @param {string} [deps.googleClientId] + * @param {string} [deps.googleClientSecret] + * @param {string} [deps.googleCallbackUrl] + * @param {string} [deps.googlePickerApiKey] browser key for Google Picker (frontend) */ constructor(deps = {}) { this.sessionSecret = deps.sessionSecret ?? process.env.SESSION_SECRET; @@ -41,6 +53,13 @@ class AuthService { this.githubAppPrivateKey = this._loadGitHubAppPrivateKey( deps.githubAppPrivateKey, ); + this.googleClientId = deps.googleClientId ?? process.env.GOOGLE_CLIENT_ID; + this.googleClientSecret = + deps.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET; + this.googleCallbackUrl = + deps.googleCallbackUrl ?? process.env.GOOGLE_REDIRECT_URI; + this.googlePickerApiKey = + deps.googlePickerApiKey ?? process.env.GOOGLE_PICKER_API_KEY ?? null; this._encryptionKey = this._parseEncryptionKey(this.encryptionKeyB64); this._passportConfigured = false; @@ -287,7 +306,30 @@ class AuthService { ); } - // Google strategy: Phase 3 — intentionally not registered here. + if ( + this.googleClientId && + this.googleClientSecret && + this.googleCallbackUrl + ) { + passport.use( + 'google', + new GoogleStrategy( + { + clientID: this.googleClientId, + clientSecret: this.googleClientSecret, + callbackURL: this.googleCallbackUrl, + scope: GOOGLE_SCOPES, + }, + (accessToken, refreshToken, profile, done) => { + try { + done(null, this._buildGoogleUser(accessToken, refreshToken, profile)); + } catch (err) { + done(err); + } + }, + ), + ); + } this._passportConfigured = true; } @@ -319,6 +361,65 @@ class AuthService { }; } + /** + * @param {string} accessToken + * @param {string | undefined} refreshToken + * @param {import('passport-google-oauth20').Profile} profile + */ + _buildGoogleUser(accessToken, refreshToken, profile) { + const primaryEmail = + profile.emails?.find((entry) => entry.verified)?.value ?? + profile.emails?.[0]?.value ?? + null; + + /** @type {{ accessToken: string, refreshToken?: string }} */ + const googleTokens = { + accessToken: this.encrypt(accessToken), + }; + if (refreshToken) { + googleTokens.refreshToken = this.encrypt(refreshToken); + } + + return { + id: String(profile.id), + provider: 'google', + username: primaryEmail || profile.displayName || String(profile.id), + displayName: profile.displayName || primaryEmail || String(profile.id), + email: primaryEmail, + avatarUrl: profile.photos?.[0]?.value ?? null, + tokens: { + google: googleTokens, + }, + storage: null, + }; + } + + /** True when Google OAuth env/deps are complete and the Passport strategy is registered. */ + isGoogleConfigured() { + return Boolean( + this.googleClientId && this.googleClientSecret && this.googleCallbackUrl, + ); + } + + /** @private */ + _hasGoogleOAuthCredentials() { + return this.isGoogleConfigured(); + } + + /** @private */ + _createGoogleOAuth2Client() { + if (!this._hasGoogleOAuthCredentials()) { + throw new Error( + 'GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REDIRECT_URI are required', + ); + } + return new google.auth.OAuth2( + this.googleClientId, + this.googleClientSecret, + this.googleCallbackUrl, + ); + } + /** * AES-256-GCM encrypt. Returns `iv.authTag.ciphertext` (base64 segments). * @param {string} plaintext @@ -407,22 +508,63 @@ class AuthService { } /** - * Phase 3 stub — returns null until Google Drive adapter lands. - * @param {object} _user - * @returns {null} + * Authenticated Drive v3 client. Uses OAuth2.setCredentials (never GoogleAuth + * with a raw access_token object). + * @param {object} user session payload + * @returns {import('googleapis').drive_v3.Drive | null} */ - getGoogleDriveClient(_user) { - return null; + getGoogleDriveClient(user) { + const encryptedAccess = user?.tokens?.google?.accessToken; + if (!encryptedAccess) { + return null; + } + if (!this.googleClientId || !this.googleClientSecret || !this.googleCallbackUrl) { + return null; + } + + const oauth2 = this._createGoogleOAuth2Client(); + /** @type {{ access_token: string, refresh_token?: string }} */ + const credentials = { + access_token: this.decrypt(encryptedAccess), + }; + if (user.tokens.google.refreshToken) { + credentials.refresh_token = this.decrypt(user.tokens.google.refreshToken); + } + oauth2.setCredentials(credentials); + + return google.drive({ version: 'v3', auth: oauth2 }); } /** - * Phase 3 stub. - * @param {object} _user + * Refresh an expired Google access token; updates `user.tokens.google` in place. + * @param {object} user session payload (mutated) */ - async refreshGoogleToken(_user) { - const err = new Error(GOOGLE_NOT_AVAILABLE); - err.code = 'GOOGLE_NOT_AVAILABLE'; - throw err; + async refreshGoogleToken(user) { + const encryptedRefresh = user?.tokens?.google?.refreshToken; + if (!encryptedRefresh) { + const err = new Error('Google refresh token is not available for this user'); + err.code = 'GOOGLE_REFRESH_UNAVAILABLE'; + throw err; + } + + const oauth2 = this._createGoogleOAuth2Client(); + oauth2.setCredentials({ + refresh_token: this.decrypt(encryptedRefresh), + }); + + const { credentials } = await oauth2.refreshAccessToken(); + if (!credentials.access_token) { + throw new Error('Google token refresh did not return an access token'); + } + + user.tokens = user.tokens || {}; + user.tokens.google = user.tokens.google || {}; + user.tokens.google.accessToken = this.encrypt(credentials.access_token); + if (credentials.refresh_token) { + user.tokens.google.refreshToken = this.encrypt(credentials.refresh_token); + } + + return user; } /** @@ -432,10 +574,10 @@ class AuthService { * @param {object} user session payload * @param {object} [options] * @param {object} [options.storageRef] - * @returns {Promise<{ githubClient?: import('@octokit/rest').Octokit, githubUserClient?: import('@octokit/rest').Octokit, driveClient?: null }>} + * @returns {Promise<{ githubClient?: import('@octokit/rest').Octokit, githubUserClient?: import('@octokit/rest').Octokit, driveClient?: import('googleapis').drive_v3.Drive }>} */ async clientsFor(user, options = {}) { - /** @type {{ githubClient?: import('@octokit/rest').Octokit, githubUserClient?: import('@octokit/rest').Octokit, driveClient?: null }} */ + /** @type {{ githubClient?: import('@octokit/rest').Octokit, githubUserClient?: import('@octokit/rest').Octokit, driveClient?: import('googleapis').drive_v3.Drive }} */ const clients = {}; const storageRef = options.storageRef; const fullName = storageRef?.full_name || storageRef?.repo; @@ -469,6 +611,8 @@ class AuthService { clients.githubClient = clients.githubUserClient; } + // Drive client keeps refresh_token on the OAuth2 client so googleapis can + // refresh on 401; call refreshGoogleToken explicitly after auth errors. const driveClient = this.getGoogleDriveClient(user); if (driveClient) { clients.driveClient = driveClient; @@ -485,6 +629,20 @@ class AuthService { return passport.authenticate('github', { session: true, ...options }); } + /** + * Google OAuth — request offline access so we store a refresh token. + * @param {object} [options] + */ + authenticateGoogle(options = {}) { + return passport.authenticate('google', { + session: true, + accessType: 'offline', + prompt: 'consent', + scope: GOOGLE_SCOPES, + ...options, + }); + } + /** * Re-serialize the session user after in-memory mutations (e.g. storage attach, * scan index update). @@ -504,3 +662,4 @@ class AuthService { } module.exports = AuthService; +module.exports.GOOGLE_SCOPES = GOOGLE_SCOPES; diff --git a/backend/services/storageService.js b/backend/services/storageService.js index 1e0f915..8dff7b9 100644 --- a/backend/services/storageService.js +++ b/backend/services/storageService.js @@ -3,10 +3,11 @@ * * Speaks the on-disk contract in docs/guides/auth_storage_guide/accountStorageContract.md. * Accepts pre-built authenticated clients (no AuthService dependency). - * GitHub adapter implemented; Google/Drive stubbed until Phase 3. + * GitHub + Google Drive adapters (Drive uses generation/ETag preconditions). */ const crypto = require('crypto'); const { randomUUID } = require('crypto'); +const { Readable } = require('stream'); const MANIFEST_PATH = 'vizably.json'; /** Pre-rename store root — still loadable; rewritten to `MANIFEST_PATH` on load. */ @@ -14,7 +15,8 @@ const LEGACY_MANIFEST_PATH = 'equalview.json'; const SCANS_DIR = 'scans'; const INDEX_PATH = `${SCANS_DIR}/index.json`; const SUPPORTED_SCHEMA_VERSION = 1; -const GOOGLE_NOT_AVAILABLE = 'Google storage is not available until Phase 3'; +const DRIVE_FOLDER_MIME = 'application/vnd.google-apps.folder'; +const DRIVE_JSON_MIME = 'application/json'; /** * @typedef {'loadable' | 'initializable' | 'unrelated' | 'incompatible' | 'invalid'} FitCheckStatus @@ -31,7 +33,7 @@ const GOOGLE_NOT_AVAILABLE = 'Google storage is not available until Phase 3'; * @typedef {object} StorageClients * @property {import('@octokit/rest').Octokit} [githubClient] repo IO (installation token when available) * @property {import('@octokit/rest').Octokit} [githubUserClient] user OAuth token for capability probes - * @property {object} [driveClient] + * @property {import('googleapis').drive_v3.Drive} [driveClient] */ class StorageService { @@ -62,7 +64,10 @@ class StorageService { */ async validateStorage(provider, storageRef, clients) { if (provider === 'google') { - return this._googleNotAvailableValidation(); + if (!clients.driveClient) { + return this._invalidResult('missing_drive_client'); + } + return this._validateDriveStorage(storageRef, clients.driveClient); } if (provider !== 'github') { return this._invalidResult('unsupported_provider'); @@ -85,7 +90,10 @@ class StorageService { */ async loadAccount(provider, storageRef, clients) { if (provider === 'google') { - throw new Error(GOOGLE_NOT_AVAILABLE); + if (!clients.driveClient) { + throw new Error('Google Drive client is required to load account storage'); + } + return this._loadDriveAccount(storageRef, clients.driveClient); } if (provider !== 'github' || !clients.githubClient) { throw new Error('GitHub client is required to load account storage'); @@ -167,7 +175,10 @@ class StorageService { */ async initStorage(provider, storageRef, owner, clients) { if (provider === 'google') { - throw new Error(GOOGLE_NOT_AVAILABLE); + if (!clients.driveClient) { + throw new Error('Google Drive client is required to initialize account storage'); + } + return this._initDriveStorage(storageRef, owner, clients.driveClient); } if (provider !== 'github' || !clients.githubClient) { throw new Error('GitHub client is required to initialize account storage'); @@ -268,8 +279,12 @@ class StorageService { * @param {StorageClients} clients */ async saveScanResults(account, scanResult, url, clients) { - if (account?.storage?.provider === 'google') { - throw new Error(GOOGLE_NOT_AVAILABLE); + const provider = account?.storage?.provider ?? account?.storageRef?.provider; + if (provider === 'google') { + if (!clients.driveClient) { + throw new Error('Google Drive client is required to save scan results'); + } + return this._saveDriveScanResults(account, scanResult, url, clients.driveClient); } if (!clients.githubClient) { throw new Error('GitHub client is required to save scan results'); @@ -336,8 +351,12 @@ class StorageService { * @param {StorageClients} clients */ async getScanById(account, scanId, clients) { - if (account?.storage?.provider === 'google') { - throw new Error(GOOGLE_NOT_AVAILABLE); + const provider = account?.storage?.provider ?? account?.storageRef?.provider; + if (provider === 'google') { + if (!clients.driveClient) { + throw new Error('Google Drive client is required to load a saved scan'); + } + return this._getDriveScanById(account, scanId, clients.driveClient); } if (!clients.githubClient) { throw new Error('GitHub client is required to load a saved scan'); @@ -494,15 +513,6 @@ class StorageService { }; } - /** @private */ - _googleNotAvailableValidation() { - return { - status: 'invalid', - reason: 'provider_not_available', - capabilities: { canRead: false, canWrite: false, canCreate: false }, - }; - } - /** @private */ _invalidResult(reason) { return { @@ -1129,6 +1139,672 @@ class StorageService { return lastCommit; } + + // ─── Google Drive adapter ───────────────────────────────────────────── + + /** + * @param {object} storageRef `{ id }` Drive folder id (from Picker) + * @param {import('googleapis').drive_v3.Drive} drive + * @private + */ + async _validateDriveStorage(storageRef, drive) { + const folderId = storageRef?.id; + if (!folderId) { + return this._invalidResult('missing_folder_id'); + } + + let capabilities; + try { + capabilities = await this._probeDriveCapabilities(drive, folderId); + } catch (err) { + if (err?.code === 404 || err?.status === 404) { + return this._invalidResult('not_found'); + } + if (err?.code === 403 || err?.status === 403) { + return { + status: 'invalid', + reason: 'access_denied', + capabilities: { canRead: false, canWrite: false, canCreate: false }, + }; + } + throw err; + } + + const manifests = await this._listDriveNamedFiles(drive, folderId, [ + MANIFEST_PATH, + LEGACY_MANIFEST_PATH, + ]); + const currentManifests = manifests.filter((f) => f.name === MANIFEST_PATH); + const legacyManifests = manifests.filter((f) => f.name === LEGACY_MANIFEST_PATH); + + if (currentManifests.length > 1 || legacyManifests.length > 1) { + return { + status: 'invalid', + reason: 'duplicate_manifest', + capabilities, + }; + } + + const manifestMeta = currentManifests[0] || legacyManifests[0]; + if (!manifestMeta) { + const children = await this._listDriveChildren(drive, folderId); + const status = children.length === 0 ? 'initializable' : 'unrelated'; + return { status, reason: null, capabilities }; + } + + let manifest; + try { + const raw = await this._readDriveFileContent(drive, manifestMeta.id); + manifest = this._parseJson(raw, 'manifest'); + } catch { + return { + status: 'invalid', + reason: 'malformed_manifest', + capabilities, + }; + } + + const manifestCheck = this._assessManifest(manifest); + if (manifestCheck.status !== 'loadable') { + return { + status: manifestCheck.status, + reason: manifestCheck.reason, + capabilities, + }; + } + + const { manifest: normalized } = this._normalizeManifestBrand(manifest); + const scansFolderId = await this._findDriveScansFolder(drive, folderId); + const { index, repaired } = scansFolderId + ? await this._reconcileDriveIndex(drive, scansFolderId) + : { index: { schemaVersion: SUPPORTED_SCHEMA_VERSION, scans: [] }, repaired: false }; + + let reason = repaired ? 'repairable' : manifestCheck.reason; + if (!repaired && (manifestMeta.name === LEGACY_MANIFEST_PATH || manifest.equalview === true)) { + reason = 'migration_required'; + } + + return { + status: 'loadable', + reason, + capabilities, + manifestSummary: { + accountId: normalized.account.id, + schemaVersion: normalized.schemaVersion, + scanCount: index.scans.length, + updatedAt: normalized.account.updatedAt, + }, + }; + } + + /** + * @private + */ + async _loadDriveAccount(storageRef, drive) { + const folderId = storageRef?.id; + if (!folderId) { + throw new Error('Google storageRef requires id (Drive folder id)'); + } + + const manifestMeta = await this._readDriveAccountManifest(drive, folderId); + if (!manifestMeta) { + throw new Error('Account manifest not found'); + } + + const parsed = this._parseJson(manifestMeta.content, 'manifest'); + const manifestCheck = this._assessManifest(parsed); + if (manifestCheck.status === 'incompatible' || manifestCheck.status === 'invalid') { + throw new Error(manifestCheck.reason || 'Invalid account manifest'); + } + + const { manifest, migrated: brandMigrated } = this._normalizeManifestBrand(parsed); + const scansFolderId = await this._ensureDriveScansFolder(drive, folderId); + const { index, repaired, scanFiles } = await this._reconcileDriveIndex(drive, scansFolderId); + + let reason = manifestCheck.reason ?? null; + const needsManifestWrite = repaired || brandMigrated || manifestMeta.legacy; + if (repaired) { + reason = 'repairable'; + } else if (brandMigrated || manifestMeta.legacy) { + reason = 'migration_required'; + } + + if (needsManifestWrite) { + const updated = this._updateManifestSummary(manifest, index); + if (repaired) { + await this._writeDriveJsonFile( + drive, + scansFolderId, + 'index.json', + index, + await this._findDriveChild(drive, scansFolderId, 'index.json'), + ); + } + await this._writeDriveJsonFile( + drive, + folderId, + MANIFEST_PATH, + updated, + manifestMeta.legacy ? null : manifestMeta, + ); + } + + const folderMeta = await drive.files.get({ + fileId: folderId, + fields: 'id,name,webViewLink', + }); + + return { + provider: 'google', + storageRef: this._normalizeDriveStorageRef(storageRef, folderMeta.data), + accountId: manifest.account.id, + settings: manifest.settings ?? { autoDelete90d: true }, + scanCount: index.scans.length, + manifest, + index, + scanFiles, + reason, + }; + } + + /** + * @private + */ + async _initDriveStorage(storageRef, owner, drive) { + let folderId = storageRef?.id; + if (!folderId && storageRef?.name) { + const created = await drive.files.create({ + requestBody: { + name: storageRef.name, + mimeType: DRIVE_FOLDER_MIME, + }, + fields: 'id,name,webViewLink', + }); + folderId = created.data.id; + storageRef = { + ...storageRef, + id: folderId, + name: created.data.name, + webViewLink: created.data.webViewLink, + }; + } + if (!folderId) { + throw new Error('Google storageRef requires id (Drive folder id) or name'); + } + + const validation = await this._validateDriveStorage({ ...storageRef, id: folderId }, drive); + if (validation.status === 'loadable') { + throw new Error('Storage already contains a Vizably account'); + } + if (validation.status === 'incompatible' || validation.status === 'invalid') { + throw new Error(validation.reason || `Cannot initialize storage (${validation.status})`); + } + if (!validation.capabilities.canWrite) { + throw new Error('Storage is not writable'); + } + + const existingManifest = await this._readDriveAccountManifest(drive, folderId); + if (existingManifest) { + throw new Error('Storage was initialized by another session'); + } + + const folderMeta = await drive.files.get({ + fileId: folderId, + fields: 'id,name,webViewLink', + }); + + const now = new Date().toISOString(); + const manifest = { + vizably: true, + kind: 'account-store', + schemaVersion: SUPPORTED_SCHEMA_VERSION, + minReaderSchemaVersion: SUPPORTED_SCHEMA_VERSION, + account: { + id: randomUUID(), + createdAt: now, + updatedAt: now, + }, + storage: { + provider: 'google', + providerStorageId: folderId, + ownerId: String(owner.id), + ownerDisplay: owner.username || owner.displayName || owner.email || 'unknown', + folderName: folderMeta.data.name, + }, + settings: { + autoDelete90d: true, + }, + summary: { + scanCount: 0, + lastScanAt: null, + }, + features: [], + }; + + const index = { + schemaVersion: SUPPORTED_SCHEMA_VERSION, + scans: [], + }; + + const scansFolderId = await this._ensureDriveScansFolder(drive, folderId); + await this._writeDriveJsonFile(drive, folderId, MANIFEST_PATH, manifest, null); + await this._writeDriveJsonFile(drive, scansFolderId, 'index.json', index, null); + + return { + provider: 'google', + storageRef: this._normalizeDriveStorageRef( + { ...storageRef, id: folderId }, + folderMeta.data, + ), + accountId: manifest.account.id, + settings: manifest.settings, + scanCount: 0, + manifest, + index, + }; + } + + /** + * Scan file first, then index + manifest with ETag preconditions; retry on 412. + * @private + */ + async _saveDriveScanResults(account, scanResult, url, drive) { + const prepared = this._prepareScanWrite(scanResult, url); + const storageRef = account.storageRef ?? account.storage; + const folderId = storageRef?.id; + if (!folderId) { + throw new Error('Google storageRef requires id (Drive folder id)'); + } + + const maxAttempts = 3; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await this._saveDriveScanResultsOnce(drive, folderId, prepared); + } catch (err) { + const canRetry = this._isDriveConflict(err) && attempt < maxAttempts - 1; + if (!canRetry) { + throw err; + } + } + } + throw new Error('Google Drive write failed after retries'); + } + + /** @private */ + async _saveDriveScanResultsOnce(drive, folderId, prepared) { + const { + scanId, + host, + url, + scannedAt, + scanPath, + scanContent, + scanSize, + scanSha256, + issues, + topSeverity, + } = prepared; + + const manifestMeta = await this._readDriveAccountManifest(drive, folderId); + if (!manifestMeta) { + throw new Error('Account manifest not found'); + } + + const { manifest } = this._normalizeManifestBrand( + this._parseJson(manifestMeta.content, 'manifest'), + ); + const scansFolderId = await this._ensureDriveScansFolder(drive, folderId); + const { index } = await this._reconcileDriveIndex(drive, scansFolderId); + + index.scans = index.scans.filter((entry) => entry.id !== scanId); + index.scans.unshift({ + id: scanId, + url, + host, + scannedAt, + score: this._scoreFromIssues(issues), + issues, + topSeverity, + file: scanPath, + size: scanSize, + sha256: scanSha256, + }); + + const updatedManifest = this._updateManifestSummary(manifest, index, scannedAt); + updatedManifest.account.updatedAt = scannedAt; + + const scanFileName = `${scanId}_${host}.json`; + // Immutable scan file first — truth survives if later cache writes conflict. + await this._writeDriveJsonFile( + drive, + scansFolderId, + scanFileName, + JSON.parse(scanContent), + null, + ); + + const indexMeta = await this._findDriveChild(drive, scansFolderId, 'index.json'); + await this._writeDriveJsonFile(drive, scansFolderId, 'index.json', index, indexMeta); + + await this._writeDriveJsonFile( + drive, + folderId, + MANIFEST_PATH, + updatedManifest, + manifestMeta.legacy ? null : manifestMeta, + ); + + return { + scanId, + path: scanPath, + scanCount: index.scans.length, + scans: index.scans, + }; + } + + /** @private */ + async _getDriveScanById(account, scanId, drive) { + if (!scanId || typeof scanId !== 'string') { + const err = new Error('Scan id is required'); + err.status = 400; + err.code = 'SCAN_ID_REQUIRED'; + throw err; + } + + const storageRef = account.storageRef ?? account.storage; + const folderId = storageRef?.id; + if (!folderId) { + throw new Error('Google storageRef requires id (Drive folder id)'); + } + + const scansFolderId = await this._findDriveScansFolder(drive, folderId); + if (!scansFolderId) { + const err = new Error('Scan not found'); + err.status = 404; + err.code = 'SCAN_NOT_FOUND'; + throw err; + } + + const children = await this._listDriveChildren(drive, scansFolderId); + const match = children.find( + (entry) => + entry.mimeType !== DRIVE_FOLDER_MIME && + entry.name.startsWith(`${scanId}_`) && + entry.name.endsWith('.json'), + ); + if (!match) { + const err = new Error('Scan not found'); + err.status = 404; + err.code = 'SCAN_NOT_FOUND'; + throw err; + } + + let payload; + try { + payload = this._parseJson(await this._readDriveFileContent(drive, match.id), 'scan'); + } catch { + const err = new Error('Scan file is malformed'); + err.status = 500; + err.code = 'SCAN_MALFORMED'; + throw err; + } + + if (payload.id !== scanId || !payload.result || !payload.url) { + const err = new Error('Scan not found'); + err.status = 404; + err.code = 'SCAN_NOT_FOUND'; + throw err; + } + + return { + id: payload.id, + url: payload.url, + scannedAt: payload.scannedAt ?? null, + result: payload.result, + }; + } + + /** @private */ + _normalizeDriveStorageRef(storageRef, folderMeta = {}) { + return { + provider: 'google', + id: storageRef.id || folderMeta.id, + name: storageRef.name || folderMeta.name || null, + webViewLink: storageRef.webViewLink || folderMeta.webViewLink || null, + }; + } + + /** @private */ + async _probeDriveCapabilities(drive, folderId) { + const { data } = await drive.files.get({ + fileId: folderId, + fields: 'id,name,mimeType,capabilities', + }); + if (data.mimeType !== DRIVE_FOLDER_MIME) { + const err = new Error('storageRef.id must be a Drive folder'); + err.status = 400; + throw err; + } + const caps = data.capabilities || {}; + const canWrite = Boolean(caps.canAddChildren ?? caps.canEdit ?? true); + const canRead = Boolean(caps.canListChildren ?? caps.canDownload ?? true); + return { + canRead, + canWrite, + canCreate: canWrite, + }; + } + + /** @private */ + async _listDriveChildren(drive, folderId) { + /** @type {Array} */ + const files = []; + let pageToken; + do { + const { data } = await drive.files.list({ + q: `'${folderId}' in parents and trashed = false`, + fields: 'nextPageToken, files(id,name,mimeType,md5Checksum,modifiedTime)', + pageSize: 100, + pageToken, + }); + files.push(...(data.files || [])); + pageToken = data.nextPageToken; + } while (pageToken); + return files; + } + + /** @private */ + async _listDriveNamedFiles(drive, folderId, names) { + const children = await this._listDriveChildren(drive, folderId); + const wanted = new Set(names); + return children.filter((f) => wanted.has(f.name) && f.mimeType !== DRIVE_FOLDER_MIME); + } + + /** @private */ + async _findDriveChild(drive, folderId, name) { + const matches = await this._listDriveNamedFiles(drive, folderId, [name]); + return matches[0] || null; + } + + /** @private */ + async _findDriveScansFolder(drive, rootFolderId) { + const children = await this._listDriveChildren(drive, rootFolderId); + const folders = children.filter( + (f) => f.name === SCANS_DIR && f.mimeType === DRIVE_FOLDER_MIME, + ); + if (folders.length > 1) { + throw new Error('Duplicate scans/ folder in Drive store'); + } + return folders[0]?.id ?? null; + } + + /** @private */ + async _ensureDriveScansFolder(drive, rootFolderId) { + const existing = await this._findDriveScansFolder(drive, rootFolderId); + if (existing) { + return existing; + } + const { data } = await drive.files.create({ + requestBody: { + name: SCANS_DIR, + mimeType: DRIVE_FOLDER_MIME, + parents: [rootFolderId], + }, + fields: 'id', + }); + return data.id; + } + + /** + * @returns {Promise<{ id: string, name: string, content: string, etag?: string, legacy: boolean } | null>} + * @private + */ + async _readDriveAccountManifest(drive, folderId) { + const current = await this._findDriveChild(drive, folderId, MANIFEST_PATH); + if (current) { + const content = await this._readDriveFileContent(drive, current.id); + const meta = await drive.files.get({ fileId: current.id, fields: 'id,name,etag' }); + return { + id: current.id, + name: MANIFEST_PATH, + content, + etag: meta.data.etag, + legacy: false, + }; + } + const legacy = await this._findDriveChild(drive, folderId, LEGACY_MANIFEST_PATH); + if (legacy) { + const content = await this._readDriveFileContent(drive, legacy.id); + const meta = await drive.files.get({ fileId: legacy.id, fields: 'id,name,etag' }); + return { + id: legacy.id, + name: LEGACY_MANIFEST_PATH, + content, + etag: meta.data.etag, + legacy: true, + }; + } + return null; + } + + /** @private */ + async _readDriveFileContent(drive, fileId) { + const res = await drive.files.get( + { fileId, alt: 'media' }, + { responseType: 'text' }, + ); + return typeof res.data === 'string' ? res.data : JSON.stringify(res.data); + } + + /** + * Create or update a JSON file; pass `existing` with id (and optional etag) + * so updates use If-Match generation/ETag preconditions. + * @private + */ + async _writeDriveJsonFile(drive, parentId, name, value, existing) { + const body = `${JSON.stringify(value, null, 2)}\n`; + const media = { + mimeType: DRIVE_JSON_MIME, + body: Readable.from([body]), + }; + + if (existing?.id) { + let etag = existing.etag; + if (!etag) { + const meta = await drive.files.get({ + fileId: existing.id, + fields: 'etag', + }); + etag = meta.data.etag; + } + await drive.files.update( + { + fileId: existing.id, + media, + fields: 'id,name,etag', + }, + etag ? { headers: { 'If-Match': etag } } : undefined, + ); + return; + } + + await drive.files.create({ + requestBody: { + name, + parents: [parentId], + mimeType: DRIVE_JSON_MIME, + }, + media, + fields: 'id,name,etag', + }); + } + + /** @private */ + async _reconcileDriveIndex(drive, scansFolderId) { + const children = await this._listDriveChildren(drive, scansFolderId); + const scanFiles = children.filter( + (entry) => + entry.mimeType !== DRIVE_FOLDER_MIME && + entry.name.endsWith('.json') && + entry.name !== 'index.json', + ); + + /** @type {Array} */ + const rebuiltScans = []; + + for (const file of scanFiles) { + try { + const content = await this._readDriveFileContent(drive, file.id); + const payload = JSON.parse(content); + if (!payload.id || !payload.url || !payload.result) { + continue; + } + const { issues, topSeverity } = this._summarizeScanResult(payload.result); + const host = this._hostFromUrl(payload.url); + rebuiltScans.push({ + id: payload.id, + url: payload.url, + host, + scannedAt: payload.scannedAt || new Date().toISOString(), + score: this._scoreFromIssues(issues), + issues, + topSeverity, + file: `${SCANS_DIR}/${file.name}`, + size: Buffer.byteLength(content, 'utf8'), + sha256: crypto.createHash('sha256').update(content).digest('hex'), + }); + } catch { + // Skip corrupt scan files during reconcile. + } + } + + rebuiltScans.sort((a, b) => Date.parse(b.scannedAt) - Date.parse(a.scannedAt)); + + const index = { + schemaVersion: SUPPORTED_SCHEMA_VERSION, + scans: rebuiltScans, + }; + + const existingIndex = await this._findDriveChild(drive, scansFolderId, 'index.json'); + let repaired = true; + if (existingIndex) { + try { + const raw = await this._readDriveFileContent(drive, existingIndex.id); + const parsed = JSON.parse(raw); + repaired = !this._indexesEqual(parsed, index); + } catch { + repaired = true; + } + } + + return { index, repaired, scanFiles }; + } + + /** @private */ + _isDriveConflict(err) { + const status = err?.code || err?.status || err?.response?.status; + return status === 412 || status === 409; + } } module.exports = StorageService; diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js index c44e72d..8bfc779 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -37,17 +37,56 @@ test('GET /api/auth/status returns unauthenticated by default', async () => { assert.equal(res.body.user, null); }); -test('GET /api/auth/google returns 501 until Phase 3', async () => { - const app = createTestApp(); +test('GET /api/auth/google returns 503 when Google OAuth is not configured', async () => { + // Explicitly clear Google creds so a developer .env cannot register the strategy. + const authService = new AuthService({ + sessionSecret: TEST_SESSION_SECRET, + encryptionKey: TEST_ENCRYPTION_KEY, + githubClientId: 'test-client-id', + githubClientSecret: 'test-client-secret', + githubCallbackUrl: 'http://localhost:3000/api/auth/github/callback', + googleClientId: '', + googleClientSecret: '', + googleCallbackUrl: '', + }); + const app = createTestApp({ authService }); const res = await request(app).get('/api/auth/google'); - assert.equal(res.status, 501); - assert.match(res.body.error, /Phase 3/); + assert.equal(res.status, 503); + assert.match(res.body.error, /not configured/i); }); -test('GET /api/auth/google/callback returns 501 until Phase 3', async () => { - const app = createTestApp(); - const res = await request(app).get('/api/auth/google/callback'); - assert.equal(res.status, 501); +test('GET /api/auth/google initiates OAuth redirect when configured', async () => { + const authService = new AuthService({ + sessionSecret: TEST_SESSION_SECRET, + encryptionKey: TEST_ENCRYPTION_KEY, + githubClientId: 'test-client-id', + githubClientSecret: 'test-client-secret', + githubCallbackUrl: 'http://localhost:3000/api/auth/github/callback', + googleClientId: 'google-client-id.apps.googleusercontent.com', + googleClientSecret: 'google-client-secret', + googleCallbackUrl: 'http://localhost:3000/api/auth/google/callback', + }); + const app = createTestApp({ authService }); + const res = await request(app).get('/api/auth/google'); + assert.equal(res.status, 302); + assert.match(res.headers.location, /accounts\.google\.com/); + assert.match(res.headers.location, /drive\.file/); +}); + +test('GET /api/auth/config returns Google Picker settings', async () => { + const authService = new AuthService({ + sessionSecret: TEST_SESSION_SECRET, + encryptionKey: TEST_ENCRYPTION_KEY, + googleClientId: 'google-client-id.apps.googleusercontent.com', + googleClientSecret: 'google-client-secret', + googleCallbackUrl: 'http://localhost:3000/api/auth/google/callback', + googlePickerApiKey: 'picker-key-test', + }); + const app = createTestApp({ authService }); + const res = await request(app).get('/api/auth/config'); + assert.equal(res.status, 200); + assert.equal(res.body.googleClientId, 'google-client-id.apps.googleusercontent.com'); + assert.equal(res.body.googlePickerApiKey, 'picker-key-test'); }); test('GET /api/auth/storages requires authentication', async () => { diff --git a/backend/tests/authService.test.js b/backend/tests/authService.test.js index cd68c7c..c503789 100644 --- a/backend/tests/authService.test.js +++ b/backend/tests/authService.test.js @@ -67,27 +67,76 @@ test('getGitHubClient returns authenticated Octokit', () => { assert.equal(typeof client.rest.repos.listForAuthenticatedUser, 'function'); }); -test('getGoogleDriveClient returns null until Phase 3', () => { +test('getGoogleDriveClient returns null without Google tokens', () => { const authService = new AuthService({ sessionSecret: TEST_SESSION_SECRET, encryptionKey: TEST_ENCRYPTION_KEY, + googleClientId: 'google-client-id.apps.googleusercontent.com', + googleClientSecret: 'google-client-secret', + googleCallbackUrl: 'http://localhost:3000/api/auth/google/callback', }); assert.equal(authService.getGoogleDriveClient({}), null); }); -test('refreshGoogleToken rejects until Phase 3', async () => { +test('getGoogleDriveClient returns Drive client with setCredentials', () => { const authService = new AuthService({ sessionSecret: TEST_SESSION_SECRET, encryptionKey: TEST_ENCRYPTION_KEY, + googleClientId: 'google-client-id.apps.googleusercontent.com', + googleClientSecret: 'google-client-secret', + googleCallbackUrl: 'http://localhost:3000/api/auth/google/callback', + }); + + const client = authService.getGoogleDriveClient({ + tokens: { + google: { + accessToken: authService.encrypt('ya29.access'), + refreshToken: authService.encrypt('1//refresh'), + }, + }, + }); + + assert.ok(client); + assert.equal(typeof client.files.list, 'function'); +}); + +test('refreshGoogleToken rejects without a refresh token', async () => { + const authService = new AuthService({ + sessionSecret: TEST_SESSION_SECRET, + encryptionKey: TEST_ENCRYPTION_KEY, + googleClientId: 'google-client-id.apps.googleusercontent.com', + googleClientSecret: 'google-client-secret', + googleCallbackUrl: 'http://localhost:3000/api/auth/google/callback', }); await assert.rejects( - () => authService.refreshGoogleToken({}), - /not available until Phase 3/, + () => authService.refreshGoogleToken({ tokens: { google: { accessToken: 'x' } } }), + /refresh token is not available/, ); }); +test('clientsFor builds driveClient when Google token present', async () => { + const authService = new AuthService({ + sessionSecret: TEST_SESSION_SECRET, + encryptionKey: TEST_ENCRYPTION_KEY, + googleClientId: 'google-client-id.apps.googleusercontent.com', + googleClientSecret: 'google-client-secret', + googleCallbackUrl: 'http://localhost:3000/api/auth/google/callback', + }); + + const clients = await authService.clientsFor({ + tokens: { + google: { + accessToken: authService.encrypt('ya29.access'), + }, + }, + }); + + assert.ok(clients.driveClient); + assert.equal(typeof clients.driveClient.files.list, 'function'); +}); + test('clientsFor builds githubClient only when token present', async () => { const authService = new AuthService({ sessionSecret: TEST_SESSION_SECRET, diff --git a/backend/tests/storageService.test.js b/backend/tests/storageService.test.js index 8e68e54..e057143 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -254,6 +254,143 @@ function createMockGitHubClient(initial = {}) { }; } +/** + * Minimal Drive v3 mock: parentId → children with optional content/etag. + * @param {{ folderId: string, files?: Record> }} initial + */ +function createMockDriveClient(initial = {}) { + const rootId = initial.folderId || 'folder-1'; + /** @type {Record>} */ + const byParent = { + [rootId]: [...(initial.files?.[rootId] || [])], + ...(initial.files || {}), + }; + /** @type {Record} */ + const byId = { + [rootId]: { + id: rootId, + name: initial.folderName || 'vizably-scans', + mimeType: 'application/vnd.google-apps.folder', + webViewLink: `https://drive.google.com/drive/folders/${rootId}`, + capabilities: { + canEdit: true, + canAddChildren: true, + canListChildren: true, + canDownload: true, + }, + }, + }; + + for (const children of Object.values(byParent)) { + for (const child of children) { + byId[child.id] = { ...child, parents: undefined }; + } + } + + let counter = 0; + const nextId = (prefix) => { + counter += 1; + return `${prefix}-${counter}`; + }; + + const list = (parentId) => byParent[parentId] || []; + const findByName = (parentId, name) => + list(parentId).find((f) => f.name === name) || null; + + const readStream = async (body) => { + if (typeof body === 'string') return body; + if (Buffer.isBuffer(body)) return body.toString('utf8'); + if (body && typeof body[Symbol.asyncIterator] === 'function') { + let out = ''; + for await (const chunk of body) { + out += chunk; + } + return out; + } + if (body && typeof body.read === 'function') { + const chunks = []; + for await (const chunk of body) { + chunks.push(chunk); + } + return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8'); + } + return String(body ?? ''); + }; + + return { + list, + findByName, + files: { + get: async ({ fileId, alt, fields }, opts) => { + const file = byId[fileId]; + if (!file) { + const err = new Error('Not Found'); + err.code = 404; + err.status = 404; + throw err; + } + if (alt === 'media') { + return { data: file.content ?? '' }; + } + const data = { ...file }; + if (fields?.includes('etag') && !data.etag) { + data.etag = `"etag-${fileId}"`; + } + return { data }; + }, + list: async ({ q }) => { + const match = /'([^']+)' in parents/.exec(q || ''); + const parentId = match?.[1]; + return { + data: { + files: list(parentId).map(({ content: _c, ...meta }) => meta), + }, + }; + }, + create: async ({ requestBody, media, fields }) => { + const id = nextId('file'); + const parentId = requestBody.parents?.[0] || rootId; + const content = media?.body ? await readStream(media.body) : ''; + const entry = { + id, + name: requestBody.name, + mimeType: requestBody.mimeType || media?.mimeType || 'application/json', + content, + etag: `"etag-${id}"`, + webViewLink: `https://drive.google.com/file/d/${id}`, + }; + byId[id] = entry; + if (!byParent[parentId]) byParent[parentId] = []; + byParent[parentId].push(entry); + if (entry.mimeType === 'application/vnd.google-apps.folder') { + byParent[id] = byParent[id] || []; + } + return { data: entry }; + }, + update: async ({ fileId, media }, options) => { + const file = byId[fileId]; + if (!file) { + const err = new Error('Not Found'); + err.code = 404; + throw err; + } + const ifMatch = options?.headers?.['If-Match']; + if (ifMatch && file.etag && ifMatch !== file.etag) { + const err = new Error('Precondition Failed'); + err.code = 412; + err.status = 412; + throw err; + } + if (media?.body) { + file.content = await readStream(media.body); + } + file.etag = `"etag-${fileId}-${Date.now()}"`; + return { data: { id: file.id, name: file.name, etag: file.etag } }; + }, + }, + }; +} + test('listGitHubRepos maps node id and repo metadata', async () => { const storageService = new StorageService(); const client = createMockGitHubClient(); @@ -391,11 +528,50 @@ test('validateStorage returns invalid for malformed manifest', async () => { assert.equal(result.reason, 'malformed_manifest'); }); -test('validateStorage stubs google provider until Phase 3', async () => { +test('validateStorage returns initializable for empty Drive folder', async () => { const storageService = new StorageService(); - const result = await storageService.validateStorage('google', { id: 'folder' }, {}); - assert.equal(result.status, 'invalid'); - assert.equal(result.reason, 'provider_not_available'); + const drive = createMockDriveClient({ folderId: 'folder-1' }); + const result = await storageService.validateStorage( + 'google', + { id: 'folder-1', name: 'vizably-scans' }, + { driveClient: drive }, + ); + assert.equal(result.status, 'initializable'); + assert.equal(result.capabilities.canWrite, true); +}); + +test('validateStorage returns loadable for Drive folder with vizably.json', async () => { + const storageService = new StorageService(); + const drive = createMockDriveClient({ + folderId: 'folder-1', + files: { + 'folder-1': [ + { + id: 'manifest-1', + name: 'vizably.json', + mimeType: 'application/json', + content: JSON.stringify(manifest({ + storage: { + provider: 'google', + providerStorageId: 'folder-1', + ownerId: '42', + ownerDisplay: 'sam', + folderName: 'vizably-scans', + }, + })), + etag: '"etag-manifest"', + }, + ], + }, + }); + + const result = await storageService.validateStorage( + 'google', + { id: 'folder-1' }, + { driveClient: drive }, + ); + assert.equal(result.status, 'loadable'); + assert.equal(result.manifestSummary.accountId, manifest().account.id); }); test('initStorage writes manifest and index skeleton', async () => { @@ -566,17 +742,40 @@ test('saveScanResults writes scan file, index, and manifest in one commit', asyn assert.equal(updatedManifest.summary.scanCount, 1); }); -test('saveScanResults rejects google storage until Phase 3', async () => { +test('initStorage + saveScanResults write Drive store with ETag updates', async () => { const storageService = new StorageService(); - await assert.rejects( - () => - storageService.saveScanResults( - { storage: { provider: 'google' } }, - { problems: {}, whatsGood: [] }, - 'https://example.com', - {}, - ), - /not available until Phase 3/, + const drive = createMockDriveClient({ folderId: 'folder-1' }); + + const inited = await storageService.initStorage( + 'google', + { id: 'folder-1', name: 'vizably-scans' }, + { id: '42', username: 'sam' }, + { driveClient: drive }, + ); + + assert.equal(inited.provider, 'google'); + assert.equal(inited.storageRef.id, 'folder-1'); + assert.ok(drive.findByName('folder-1', 'vizably.json')); + + const saved = await storageService.saveScanResults( + { + storage: { provider: 'google', id: 'folder-1' }, + }, + { + problems: { visualAccessibility: [], structureAndSemantics: [], multimedia: [] }, + whatsGood: [], + }, + 'https://codrlabs.com', + { driveClient: drive }, + ); + + assert.ok(saved.scanId); + assert.equal(saved.scanCount, 1); + const scansFolder = drive.findByName('folder-1', 'scans'); + assert.ok(scansFolder); + assert.ok( + drive.list('folder-1').some((f) => f.name === 'scans') || + drive.list(scansFolder.id).some((f) => f.name.endsWith('_codrlabs.com.json')), ); }); diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md index b2dcde2..18d8193 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -189,11 +189,11 @@ One adapter behind the existing provider-neutral interface; no rewrite of Phases ### Backend - [x] `npm install passport-google-oauth20 googleapis` (+ dev types) -- [ ] Add Google OAuth vars + `GOOGLE_PICKER_API_KEY` to `.env.example` and `backend/README.md` -- [ ] Passport Google strategy; wire `GET /google` + `GET /google/callback` -- [ ] `getGoogleDriveClient(user)` + `refreshGoogleToken(user)` — real implementations -- [ ] Drive adapter in `storageService`: fit-check, load, init, save with generation/ETag -- [ ] Backend scopes: `drive.file` only (per Phase 0 choice) +- [x] Add Google OAuth vars + `GOOGLE_PICKER_API_KEY` to `.env.example` and `backend/README.md` +- [x] Passport Google strategy; wire `GET /google` + `GET /google/callback` +- [x] `getGoogleDriveClient(user)` + `refreshGoogleToken(user)` — real implementations +- [x] Drive adapter in `storageService`: fit-check, load, init, save with generation/ETag +- [x] Backend scopes: `drive.file` only (per Phase 0 choice) ### Frontend From 10fe70867f2d5391717564ce92fe784c3addd24f Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Mon, 20 Jul 2026 17:34:28 -0600 Subject: [PATCH 3/7] feat(auth): add endpoint for Google access token and enhance Google Picker integration --- backend/README.md | 1 + backend/routes/auth.js | 29 +++ backend/tests/auth.test.js | 6 + docs/guides/auth_storage_guide/TODO.md | 6 +- frontend/src/App.jsx | 7 +- frontend/src/__tests__/apiClient.test.js | 28 ++- frontend/src/__tests__/connectView.test.jsx | 71 +++++- frontend/src/lib/apiClient.js | 25 +- frontend/src/lib/googlePicker.js | 134 +++++++++++ frontend/src/views/ConnectView.jsx | 240 ++++++++++++++------ frontend/src/views/SignInView.jsx | 3 +- 11 files changed, 462 insertions(+), 88 deletions(-) create mode 100644 frontend/src/lib/googlePicker.js diff --git a/backend/README.md b/backend/README.md index 965b0e9..edd57a6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -189,6 +189,7 @@ Sign-in flow: `GET /api/auth/google` → Google consent → callback → fronten | GET | `/api/auth/google` | start Google OAuth (`drive.file`) | | GET | `/api/auth/google/callback` | Google OAuth callback | | GET | `/api/auth/config` | `{ googleClientId, googlePickerApiKey }` | +| GET | `/api/auth/google/token` | session Google access token (Picker only) | | GET | `/api/auth/storages` | list GitHub repos (`?provider=github`) | | POST | `/api/auth/storage/validate` | fit-check selected storage | | POST | `/api/auth/storage` | load or init account storage | diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 396f801..171e448 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -71,6 +71,35 @@ function makeAuthRouter({ authService, storageService }) { }); }); + /** + * Short-lived Google access token for the Picker (session cookie auth). + * Never logged; only returned to the signed-in browser. + */ + router.get('/google/token', requireAuth, async (req, res) => { + try { + if (req.user?.provider !== 'google') { + return res.status(400).json({ error: 'Not a Google session' }); + } + if (!req.user?.tokens?.google?.accessToken) { + return res.status(400).json({ error: 'Google access token unavailable' }); + } + if (req.user.tokens.google.refreshToken) { + try { + await authService.refreshGoogleToken(req.user); + await authService.persistUser(req); + } catch { + // Use the existing access token if refresh fails. + } + } + return res.json({ + accessToken: authService.decrypt(req.user.tokens.google.accessToken), + }); + } catch (err) { + console.error(err); + return res.status(500).json({ error: 'Failed to issue Google access token' }); + } + }); + router.get('/storages', requireAuth, async (req, res) => { try { const provider = req.query.provider; diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js index 8bfc779..161fc8c 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -89,6 +89,12 @@ test('GET /api/auth/config returns Google Picker settings', async () => { assert.equal(res.body.googlePickerApiKey, 'picker-key-test'); }); +test('GET /api/auth/google/token requires authentication', async () => { + const app = createTestApp(); + const res = await request(app).get('/api/auth/google/token'); + assert.equal(res.status, 401); +}); + test('GET /api/auth/storages requires authentication', async () => { const app = createTestApp(); const res = await request(app).get('/api/auth/storages?provider=github'); diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md index 18d8193..6889fbf 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -197,9 +197,9 @@ One adapter behind the existing provider-neutral interface; no rewrite of Phases ### Frontend -- [ ] `googleLogin()` → full redirect to `/api/auth/google` -- [ ] ConnectView: launch **Google Picker** for folder selection -- [ ] Enable Google connect path in `App.jsx` / landing +- [x] `googleLogin()` → full redirect to `/api/auth/google` +- [x] ConnectView: launch **Google Picker** for folder selection +- [x] Enable Google connect path in `App.jsx` / landing --- diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d52582e..3dbac74 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -273,7 +273,10 @@ function AppRoutes() { } const auth = (p) => { - if (p === 'google') return + if (p === 'google') { + apiClient.googleLogin() + return + } apiClient.githubLogin() } @@ -303,7 +306,7 @@ function AppRoutes() { const [params] = useSearchParams() const connectProvider = params.get('provider') || provider const storageError = params.get('error') === 'auth_failed' - ? 'GitHub sign-in failed. Try again.' + ? `${connectProvider === 'google' ? 'Google' : 'GitHub'} sign-in failed. Try again.` : null if (authLoading) return diff --git a/frontend/src/__tests__/apiClient.test.js b/frontend/src/__tests__/apiClient.test.js index 96610c0..97fb911 100644 --- a/frontend/src/__tests__/apiClient.test.js +++ b/frontend/src/__tests__/apiClient.test.js @@ -49,9 +49,33 @@ describe('ApiClient', () => { expect(globalThis.location.href).toBe('/api/auth/github') }) - it('googleLogin throws until Phase 3', () => { + it('googleLogin redirects to the backend OAuth route', () => { const client = new ApiClient() - expect(() => client.googleLogin()).toThrow(/Phase 3/) + client.googleLogin() + expect(globalThis.location.href).toBe('/api/auth/google') + }) + + it('getAuthConfig calls GET /api/auth/config', async () => { + const fetchImpl = mockFetch({ + googleClientId: 'client.apps.googleusercontent.com', + googlePickerApiKey: 'picker-key', + }) + const client = new ApiClient({ fetchImpl }) + + const config = await client.getAuthConfig() + + expect(fetchImpl).toHaveBeenCalledWith('/api/auth/config', expect.any(Object)) + expect(config.googlePickerApiKey).toBe('picker-key') + }) + + it('getGoogleAccessToken calls GET /api/auth/google/token', async () => { + const fetchImpl = mockFetch({ accessToken: 'ya29.token' }) + const client = new ApiClient({ fetchImpl }) + + const result = await client.getGoogleAccessToken() + + expect(fetchImpl).toHaveBeenCalledWith('/api/auth/google/token', expect.any(Object)) + expect(result.accessToken).toBe('ya29.token') }) it('getAuthStatus calls GET /api/auth/status', async () => { diff --git a/frontend/src/__tests__/connectView.test.jsx b/frontend/src/__tests__/connectView.test.jsx index 18967d1..8f242e6 100644 --- a/frontend/src/__tests__/connectView.test.jsx +++ b/frontend/src/__tests__/connectView.test.jsx @@ -10,6 +10,12 @@ const REPO = { html_url: 'https://github.com/sam/site-audits', } +const FOLDER = { + id: 'folder-1', + name: 'Vizably scans', + url: 'https://drive.google.com/drive/folders/folder-1', +} + function mockClient(overrides = {}) { return { listStorages: vi.fn().mockResolvedValue({ provider: 'github', storages: [REPO] }), @@ -20,6 +26,11 @@ function mockClient(overrides = {}) { manifestSummary: { scanCount: 3, schemaVersion: 1, accountId: 'a1' }, }), setupStorage: vi.fn().mockResolvedValue({ success: true }), + getAuthConfig: vi.fn().mockResolvedValue({ + googleClientId: '123456-abc.apps.googleusercontent.com', + googlePickerApiKey: 'picker-key', + }), + getGoogleAccessToken: vi.fn().mockResolvedValue({ accessToken: 'ya29.token' }), ...overrides, } } @@ -142,13 +153,65 @@ describe('ConnectView', () => { expect(button).toBeDisabled() }) - it('shows Google deferred message for google provider', () => { + it('opens Google Picker and validates the chosen folder', async () => { + const client = mockClient() + const openFolderPicker = vi.fn().mockResolvedValue(FOLDER) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /choose folder in google drive/i })) + + await waitFor(() => + expect(openFolderPicker).toHaveBeenCalledWith({ + apiKey: 'picker-key', + clientId: '123456-abc.apps.googleusercontent.com', + accessToken: 'ya29.token', + }), + ) + await waitFor(() => + expect(client.validateStorage).toHaveBeenCalledWith('google', { + id: FOLDER.id, + name: FOLDER.name, + url: FOLDER.url, + }), + ) + expect(screen.getByText(/Selected:/i)).toBeInTheDocument() + expect(screen.getByText('Vizably account found')).toBeInTheDocument() + }) + + it('creates a new Google Drive folder via setupStorage init', async () => { + const onDone = vi.fn() + const client = mockClient() + render( - , + , ) - expect(screen.getByText(/Phase 3/i)).toBeInTheDocument() - expect(screen.queryByText(/load my account/i)).not.toBeInTheDocument() + fireEvent.click(screen.getByText(/Create a new folder/i)) + const input = screen.getByDisplayValue('Vizably') + fireEvent.change(input, { target: { value: 'My Vizably' } }) + + const button = screen.getByRole('button', { name: /create folder & continue/i }) + fireEvent.click(button) + + await waitFor(() => + expect(client.setupStorage).toHaveBeenCalledWith('google', { name: 'My Vizably' }, 'init'), + ) + expect(onDone).toHaveBeenCalled() }) it('renders storageError prop', async () => { diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index 03097c4..61b251e 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -10,8 +10,6 @@ * @typedef {import('../../../shared/types.js').Problem} Problem */ -const GOOGLE_LOGIN_UNAVAILABLE = 'Google sign-in is not available until Phase 3' - export class ApiClient { /** * @param {object} [opts] @@ -63,9 +61,28 @@ export class ApiClient { globalThis.location.href = `${this.baseUrl}/api/auth/github` } - /** Phase 3 stub — Google sign-in is not wired in Phase 2. */ + /** Full-page redirect to Google OAuth. */ googleLogin() { - throw new Error(GOOGLE_LOGIN_UNAVAILABLE) + if (typeof globalThis.location === 'undefined') { + return + } + globalThis.location.href = `${this.baseUrl}/api/auth/google` + } + + /** + * Public Google Picker config (client id + browser API key). + * @returns {Promise<{ googleClientId: string | null, googlePickerApiKey: string | null }>} + */ + getAuthConfig() { + return this._request('/api/auth/config') + } + + /** + * Session-bound Google access token for the Picker. + * @returns {Promise<{ accessToken: string }>} + */ + getGoogleAccessToken() { + return this._request('/api/auth/google/token') } /** @returns {Promise<{ authenticated: boolean, user: object | null }>} */ diff --git a/frontend/src/lib/googlePicker.js b/frontend/src/lib/googlePicker.js new file mode 100644 index 0000000..81aa663 --- /dev/null +++ b/frontend/src/lib/googlePicker.js @@ -0,0 +1,134 @@ +/** + * Google Picker helpers — load the client library and open a folder picker. + * Requires drive.file OAuth scope (backend) + GOOGLE_PICKER_API_KEY. + */ + +const GAPI_SCRIPT = 'https://apis.google.com/js/api.js' + +/** + * @param {string} src + * @returns {Promise} + */ +function loadScript(src) { + return new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${src}"]`) + if (existing) { + if (existing.dataset.loaded === 'true' || globalThis.gapi) { + resolve() + return + } + existing.addEventListener('load', () => resolve(), { once: true }) + existing.addEventListener('error', () => reject(new Error('Failed to load Google API script')), { + once: true, + }) + return + } + const script = document.createElement('script') + script.src = src + script.async = true + script.onload = () => { + script.dataset.loaded = 'true' + resolve() + } + script.onerror = () => reject(new Error('Failed to load Google API script')) + document.head.appendChild(script) + }) +} + +/** + * @returns {Promise} + */ +async function ensureGapiPicker() { + await loadScript(GAPI_SCRIPT) + const gapi = globalThis.gapi + if (!gapi) { + throw new Error('Google API failed to initialize') + } + if (gapi.picker) { + return gapi + } + await new Promise((resolve, reject) => { + gapi.load('picker', { + callback: resolve, + onerror: () => reject(new Error('Failed to load Google Picker')), + }) + }) + return gapi +} + +/** + * Open a folder-only Google Picker. + * + * @param {object} opts + * @param {string} opts.apiKey GOOGLE_PICKER_API_KEY + * @param {string} opts.clientId GOOGLE_CLIENT_ID (OAuth web client) + * @param {string} opts.accessToken user access token with drive.file + * @param {string} [opts.title] + * @returns {Promise<{ id: string, name: string, url: string | null } | null>} + * Resolves to the chosen folder, or null if the user cancelled. + */ +export async function openDriveFolderPicker({ + apiKey, + clientId, + accessToken, + title = 'Choose a Drive folder for Vizably', +}) { + if (!apiKey) { + throw new Error('Google Picker API key is not configured (GOOGLE_PICKER_API_KEY)') + } + if (!clientId) { + throw new Error('Google OAuth client id is not configured (GOOGLE_CLIENT_ID)') + } + if (!accessToken) { + throw new Error('Google access token is required to open the Picker') + } + + const gapi = await ensureGapiPicker() + const google = globalThis.google + if (!google?.picker) { + throw new Error('Google Picker is unavailable') + } + + return new Promise((resolve, reject) => { + try { + const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) + .setIncludeFolders(true) + .setSelectFolderEnabled(true) + .setMimeTypes('application/vnd.google-apps.folder') + + // Cloud project number is the numeric prefix of the OAuth client id. + const appId = String(clientId).split('-')[0] + + const picker = new google.picker.PickerBuilder() + .setTitle(title) + .setAppId(appId) + .setDeveloperKey(apiKey) + .setOAuthToken(accessToken) + .addView(view) + .setSelectableMimeTypes('application/vnd.google-apps.folder') + .setCallback((data) => { + if (data.action === google.picker.Action.CANCEL) { + resolve(null) + return + } + if (data.action === google.picker.Action.PICKED) { + const doc = data.docs?.[0] + if (!doc?.id) { + resolve(null) + return + } + resolve({ + id: doc.id, + name: doc.name || 'Drive folder', + url: doc.url || null, + }) + } + }) + .build() + + picker.setVisible(true) + } catch (err) { + reject(err) + } + }) +} diff --git a/frontend/src/views/ConnectView.jsx b/frontend/src/views/ConnectView.jsx index f3c5e12..3c9aa9e 100644 --- a/frontend/src/views/ConnectView.jsx +++ b/frontend/src/views/ConnectView.jsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Button, Card } from '../design-system' import { Ico, GoogleMark } from '../lib/icons' import { apiClient } from '../lib/apiClient' +import { openDriveFolderPicker } from '../lib/googlePicker' import { PROVIDERS } from '../data/placeholders' const STATUS_UI = { @@ -69,6 +70,14 @@ function storageRefFromRepo(repo) { } } +function storageRefFromFolder(folder) { + return { + id: folder.id, + name: folder.name, + url: folder.url || undefined, + } +} + function findRepoByName(storages, name) { const trimmed = name.trim() if (!trimmed) return null @@ -81,7 +90,8 @@ function findRepoByName(storages, name) { } /** - * Connect storage — pick a GitHub repo, run fit-check, load or init account. + * Connect storage — pick a GitHub repo or Google Drive folder, run fit-check, + * load or init account. * * @param {object} props * @param {'github' | 'google'} props.provider @@ -89,6 +99,7 @@ function findRepoByName(storages, name) { * @param {() => void} props.onCancel * @param {string} [props.storageError] * @param {import('../lib/apiClient').ApiClient} [props.client] + * @param {typeof openDriveFolderPicker} [props.openFolderPicker] */ export default function ConnectView({ provider, @@ -96,14 +107,18 @@ export default function ConnectView({ onCancel, storageError = null, client = apiClient, + openFolderPicker = openDriveFolderPicker, }) { const pv = PROVIDERS[provider] || PROVIDERS.github const isGitHub = provider === 'github' + const isGoogle = provider === 'google' const [mode, setMode] = useState('existing') const [newRepoName, setNewRepoName] = useState(pv.dest) const [storages, setStorages] = useState([]) const [selectedId, setSelectedId] = useState('') + const [pickedFolder, setPickedFolder] = useState(null) + const [pickingFolder, setPickingFolder] = useState(false) const [validation, setValidation] = useState(null) const [loadingRepos, setLoadingRepos] = useState(false) const [validating, setValidating] = useState(false) @@ -117,13 +132,20 @@ export default function ConnectView({ ) const activeStorageRef = useMemo(() => { + if (isGoogle) { + if (mode === 'existing') { + return pickedFolder ? storageRefFromFolder(pickedFolder) : null + } + const name = newRepoName.trim() + return name ? { name } : null + } if (!isGitHub) return null if (mode === 'existing') { return selectedRepo ? storageRefFromRepo(selectedRepo) : null } const match = findRepoByName(storages, newRepoName) return match ? storageRefFromRepo(match) : null - }, [isGitHub, mode, selectedRepo, storages, newRepoName]) + }, [isGoogle, isGitHub, mode, pickedFolder, selectedRepo, storages, newRepoName]) const loadStorages = useCallback(async () => { if (!isGitHub) return @@ -151,15 +173,22 @@ export default function ConnectView({ setError(storageError) }, [storageError]) + useEffect(() => { + setPickedFolder(null) + setValidation(null) + setNewRepoName(pv.dest) + setMode('existing') + }, [provider, pv.dest]) + const runValidation = useCallback(async (storageRef) => { - if (!storageRef) { + if (!storageRef?.id) { setValidation(null) return } setValidating(true) setError(null) try { - const result = await client.validateStorage('github', storageRef) + const result = await client.validateStorage(provider, storageRef) setValidation(result) } catch (err) { setValidation(null) @@ -167,40 +196,79 @@ export default function ConnectView({ } finally { setValidating(false) } - }, [client]) + }, [client, provider]) useEffect(() => { - if (!isGitHub || !activeStorageRef) { + // New Google folders are created on init — no fit-check until they exist. + if (isGoogle && mode === 'new') { + setValidation(null) + return + } + if (!activeStorageRef?.id) { setValidation(null) return } runValidation(activeStorageRef) - }, [isGitHub, activeStorageRef, runValidation]) + }, [isGoogle, mode, activeStorageRef, runValidation]) const statusUi = validation ? STATUS_UI[validation.status] : null - const proposedAction = statusUi?.action ?? null + const proposedAction = + isGoogle && mode === 'new' && activeStorageRef + ? 'init' + : (statusUi?.action ?? null) const canWrite = validation?.capabilities?.canWrite !== false const initBlocked = proposedAction === 'init' && validation && !canWrite - const confirmBlocked = - !validation || - !proposedAction || - validation.status === 'incompatible' || - validation.status === 'invalid' || - initBlocked || - (mode === 'new' && !activeStorageRef) + + const confirmBlocked = isGoogle && mode === 'new' + ? !activeStorageRef || confirming + : ( + !validation || + !proposedAction || + validation.status === 'incompatible' || + validation.status === 'invalid' || + initBlocked || + (mode === 'new' && !activeStorageRef) + ) const confirmLabel = useMemo(() => { - if (mode === 'new' && !activeStorageRef) { + if (isGoogle && mode === 'new') { + return activeStorageRef ? 'Create folder & continue' : 'Enter a folder name' + } + if (isGitHub && mode === 'new' && !activeStorageRef) { return 'Create repo on GitHub first' } if (statusUi?.button) return statusUi.button return 'Continue' - }, [mode, activeStorageRef, statusUi]) + }, [isGoogle, isGitHub, mode, activeStorageRef, statusUi]) + + const handlePickFolder = async () => { + setPickingFolder(true) + setError(null) + try { + const [config, tokenPayload] = await Promise.all([ + client.getAuthConfig(), + client.getGoogleAccessToken(), + ]) + const folder = await openFolderPicker({ + apiKey: config.googlePickerApiKey, + clientId: config.googleClientId, + accessToken: tokenPayload.accessToken, + }) + if (folder) { + setPickedFolder(folder) + setMode('existing') + } + } catch (err) { + setError(err.message || 'Failed to open Google Picker') + } finally { + setPickingFolder(false) + } + } const handleConfirm = async () => { if (confirmBlocked || !activeStorageRef || !proposedAction) return - if (mode === 'new' && !activeStorageRef) { + if (isGitHub && mode === 'new' && !activeStorageRef) { setError( `Create "${newRepoName.trim()}" on GitHub, install the Vizably app on it, then refresh the list.`, ) @@ -210,7 +278,7 @@ export default function ConnectView({ setConfirming(true) setError(null) try { - await client.setupStorage('github', activeStorageRef, proposedAction) + await client.setupStorage(provider, activeStorageRef, proposedAction) onDone() } catch (err) { setError(err.message || 'Failed to connect storage') @@ -219,7 +287,7 @@ export default function ConnectView({ } } - const providerIcon = provider === 'google' ? GoogleMark(20) : Ico('Github', 20) + const providerIcon = isGoogle ? GoogleMark(20) : Ico('Github', 20) const Option = ({ id, icon, title, desc, children }) => { const active = mode === id @@ -287,32 +355,6 @@ export default function ConnectView({ ) } - if (!isGitHub) { - return ( -
-
-

- Google sign-in coming in Phase 3 -

-

- Google Drive storage uses the Google Picker and is not wired yet. Use GitHub for now. -

- -
-
- ) - } - return (
- {mode === 'new' && !activeStorageRef && newRepoName.trim() && ( + {isGitHub && mode === 'new' && !activeStorageRef && newRepoName.trim() && (

Create this repository on GitHub, install the Vizably app on it, then{' '} + + ) : loadingRepos ? (

Loading repositories…

@@ -505,25 +582,27 @@ export default function ConnectView({ )} - + {isGitHub && ( + + )} @@ -534,7 +613,26 @@ export default function ConnectView({

)} - {validation && statusUi && !validating && ( + {isGoogle && mode === 'new' && activeStorageRef && ( +
+
+ Ready to create +
+

+ We'll create “{activeStorageRef.name}” in your Drive and set it up for Vizably. +

+
+ )} + + {validation && statusUi && !validating && !(isGoogle && mode === 'new') && (
diff --git a/frontend/src/views/SignInView.jsx b/frontend/src/views/SignInView.jsx index 6a7ae24..ffecb0d 100644 --- a/frontend/src/views/SignInView.jsx +++ b/frontend/src/views/SignInView.jsx @@ -6,8 +6,7 @@ import { Ico, GoogleMark } from '../lib/icons' * identify you; saved scans live in YOUR own storage (a private repo or * your Drive), never on vizably's servers. * - * NOTE: real OAuth is not wired yet — `onAuth(provider)` continues the - * placeholder flow into ConnectView. + * `onAuth(provider)` starts the full-page OAuth redirect (GitHub or Google). */ export default function SignInView({ onNav, onAuth }) { const Provider = ({ id, icon, label, sub }) => ( From eafc1f92461a638d5167cdef1fa61805db9c14af Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Mon, 20 Jul 2026 17:51:01 -0600 Subject: [PATCH 4/7] docs: update README with Google OAuth troubleshooting tips and publishing status details --- backend/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/README.md b/backend/README.md index edd57a6..c673761 100644 --- a/backend/README.md +++ b/backend/README.md @@ -145,6 +145,16 @@ lists Drive folders. 3. **APIs & Services → OAuth consent screen** → External (or Internal for Workspace). Add scopes: `openid`, `email`, `profile`, and `https://www.googleapis.com/auth/drive.file`. + Choose a **publishing status**: + - **Testing** — only emails under **Test users** can sign in. Add your own + Google account there (or use **Internal** for Workspace). Missing yourself + from Test users causes Google’s **`Error 403: access_denied`**. + - **In production** — any Google account can start the consent flow. Until + Google **verifies** the app for `drive.file` (a sensitive scope), users see + an **unverified app** warning; for local/dev click **Advanced → Go to + {app} (unsafe)** and continue. Real public traffic needs OAuth verification + (privacy policy, demo video, scope justification) — submit that before + shipping Vizably to end users. 4. **APIs & Services → Credentials → Create credentials → OAuth client ID** → Application type **Web application**. - Authorized JavaScript origins: `http://localhost:5173` (Vite) and @@ -157,6 +167,15 @@ lists Drive folders. - `GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback` 6. Create a **Browser API key** for Picker (see below) → `GOOGLE_PICKER_API_KEY`. +**Troubleshooting Google sign-in** + +| Symptom | Likely cause | Fix | +| ------- | ------------ | --- | +| `Error 403: access_denied` while status is **Testing** | Account not a test user | [Consent screen](https://console.cloud.google.com/apis/credentials/consent) → **Test users** → add your Gmail → retry (incognito if Google cached a deny) | +| `Error 403: access_denied` after publish | Cached deny, wrong project, or scope not on consent screen | Confirm scopes include `drive.file`; redirect URI matches `.env`; retry in a fresh browser profile | +| “Google hasn’t verified this app” | Published but unverified (`drive.file`) | Expected for local/dev — use **Advanced → Continue**; submit verification before production launch | +| `redirect_uri_mismatch` | Callback URL not registered | Add exactly `http://localhost:3000/api/auth/google/callback` on the OAuth client | + `GET /api/auth/config` returns `{ googleClientId, googlePickerApiKey }` for the frontend Picker. Restrict the API key by HTTP referrer in Cloud Console. From 6c31e9a4236fec2a985bbf94ba9d7c080d6ead00 Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Wed, 22 Jul 2026 00:22:45 -0600 Subject: [PATCH 5/7] feat(auth): add Google Cloud project number to configuration and enhance Picker integration --- backend/.env.example | 3 ++ backend/README.md | 11 ++++- backend/routes/auth.js | 1 + backend/services/authService.js | 5 +++ backend/services/storageService.js | 49 ++++++++++++++++----- backend/tests/auth.test.js | 2 + backend/tests/storageService.test.js | 27 +++++++++--- frontend/src/__tests__/connectView.test.jsx | 2 + frontend/src/lib/apiClient.js | 8 +++- frontend/src/lib/googlePicker.js | 49 +++++++++++++++++---- frontend/src/views/ConnectView.jsx | 1 + 11 files changed, 128 insertions(+), 30 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index fa1c62c..04c0ea9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,6 +30,9 @@ GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback # Browser API key for Google Picker (restrict by HTTP referrer). Served via GET /api/auth/config. GOOGLE_PICKER_API_KEY= +# Cloud project number (IAM & Admin → Settings) — required for Picker setAppId with drive.file. +# Often matches the numeric prefix of GOOGLE_CLIENT_ID; set explicitly if the Picker is blank. +GOOGLE_CLOUD_PROJECT_NUMBER= # --- Phase 5 (planned, not yet read by the server) ---------------------- # JWT_SECRET=replace-me-with-a-long-random-string diff --git a/backend/README.md b/backend/README.md index c673761..2857900 100644 --- a/backend/README.md +++ b/backend/README.md @@ -166,6 +166,10 @@ lists Drive folders. - `GOOGLE_CLIENT_SECRET` - `GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback` 6. Create a **Browser API key** for Picker (see below) → `GOOGLE_PICKER_API_KEY`. +7. Copy the **Project number** from **IAM & Admin → Settings** into + `GOOGLE_CLOUD_PROJECT_NUMBER` (digits only). Picker `setAppId` needs this for + `drive.file`; a wrong value often shows a **blank white Picker**. The OAuth + client id prefix is usually the same number — set it explicitly if unsure. **Troubleshooting Google sign-in** @@ -175,8 +179,11 @@ lists Drive folders. | `Error 403: access_denied` after publish | Cached deny, wrong project, or scope not on consent screen | Confirm scopes include `drive.file`; redirect URI matches `.env`; retry in a fresh browser profile | | “Google hasn’t verified this app” | Published but unverified (`drive.file`) | Expected for local/dev — use **Advanced → Continue**; submit verification before production launch | | `redirect_uri_mismatch` | Callback URL not registered | Add exactly `http://localhost:3000/api/auth/google/callback` on the OAuth client | +| Picker opens **blank / white** | Wrong project number, API key referrers, or Picker API off | Set `GOOGLE_CLOUD_PROJECT_NUMBER`; enable **Google Picker API**; restrict API key referrers to `http://localhost:5173/*` (and restart backend) | +| `Invalid field selection etag` | Old Drive client requesting `fields=etag` | Fixed in storage service — Drive v3 returns ETag only on HTTP headers | -`GET /api/auth/config` returns `{ googleClientId, googlePickerApiKey }` for the +`GET /api/auth/config` returns +`{ googleClientId, googlePickerApiKey, googleCloudProjectNumber }` for the frontend Picker. Restrict the API key by HTTP referrer in Cloud Console. #### Getting `GOOGLE_PICKER_API_KEY` @@ -207,7 +214,7 @@ Sign-in flow: `GET /api/auth/google` → Google consent → callback → fronten | GET | `/api/auth/github/callback` | GitHub OAuth callback | | GET | `/api/auth/google` | start Google OAuth (`drive.file`) | | GET | `/api/auth/google/callback` | Google OAuth callback | -| GET | `/api/auth/config` | `{ googleClientId, googlePickerApiKey }` | +| GET | `/api/auth/config` | `{ googleClientId, googlePickerApiKey, googleCloudProjectNumber }` | | GET | `/api/auth/google/token` | session Google access token (Picker only) | | GET | `/api/auth/storages` | list GitHub repos (`?provider=github`) | | POST | `/api/auth/storage/validate` | fit-check selected storage | diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 171e448..f540d13 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -68,6 +68,7 @@ function makeAuthRouter({ authService, storageService }) { return res.json({ googleClientId: authService.googleClientId || null, googlePickerApiKey: authService.googlePickerApiKey || null, + googleCloudProjectNumber: authService.googleCloudProjectNumber || null, }); }); diff --git a/backend/services/authService.js b/backend/services/authService.js index ddd8419..33e3316 100644 --- a/backend/services/authService.js +++ b/backend/services/authService.js @@ -35,6 +35,7 @@ class AuthService { * @param {string} [deps.googleClientSecret] * @param {string} [deps.googleCallbackUrl] * @param {string} [deps.googlePickerApiKey] browser key for Google Picker (frontend) + * @param {string} [deps.googleCloudProjectNumber] Cloud project number for Picker setAppId */ constructor(deps = {}) { this.sessionSecret = deps.sessionSecret ?? process.env.SESSION_SECRET; @@ -60,6 +61,10 @@ class AuthService { deps.googleCallbackUrl ?? process.env.GOOGLE_REDIRECT_URI; this.googlePickerApiKey = deps.googlePickerApiKey ?? process.env.GOOGLE_PICKER_API_KEY ?? null; + this.googleCloudProjectNumber = + deps.googleCloudProjectNumber ?? + process.env.GOOGLE_CLOUD_PROJECT_NUMBER ?? + null; this._encryptionKey = this._parseEncryptionKey(this.encryptionKeyB64); this._passportConfigured = false; diff --git a/backend/services/storageService.js b/backend/services/storageService.js index 8dff7b9..70c16c1 100644 --- a/backend/services/storageService.js +++ b/backend/services/storageService.js @@ -1655,6 +1655,36 @@ class StorageService { return data.id; } + /** + * Drive v3 does not expose `etag` as a fields= selection ("Invalid field + * selection etag"). Optimistic concurrency uses the HTTP ETag header. + * @private + */ + _driveEtagFromResponse(res) { + if (!res) return undefined; + const headers = res.headers; + if (headers) { + if (typeof headers.get === 'function') { + return headers.get('etag') || headers.get('ETag') || undefined; + } + return headers.etag || headers.ETag || headers['etag'] || undefined; + } + return undefined; + } + + /** + * @returns {Promise<{ id: string, name: string, etag?: string }>} + * @private + */ + async _getDriveFileMeta(drive, fileId) { + const meta = await drive.files.get({ fileId, fields: 'id,name' }); + return { + id: meta.data.id, + name: meta.data.name, + etag: this._driveEtagFromResponse(meta), + }; + } + /** * @returns {Promise<{ id: string, name: string, content: string, etag?: string, legacy: boolean } | null>} * @private @@ -1663,24 +1693,24 @@ class StorageService { const current = await this._findDriveChild(drive, folderId, MANIFEST_PATH); if (current) { const content = await this._readDriveFileContent(drive, current.id); - const meta = await drive.files.get({ fileId: current.id, fields: 'id,name,etag' }); + const meta = await this._getDriveFileMeta(drive, current.id); return { id: current.id, name: MANIFEST_PATH, content, - etag: meta.data.etag, + etag: meta.etag, legacy: false, }; } const legacy = await this._findDriveChild(drive, folderId, LEGACY_MANIFEST_PATH); if (legacy) { const content = await this._readDriveFileContent(drive, legacy.id); - const meta = await drive.files.get({ fileId: legacy.id, fields: 'id,name,etag' }); + const meta = await this._getDriveFileMeta(drive, legacy.id); return { id: legacy.id, name: LEGACY_MANIFEST_PATH, content, - etag: meta.data.etag, + etag: meta.etag, legacy: true, }; } @@ -1711,17 +1741,14 @@ class StorageService { if (existing?.id) { let etag = existing.etag; if (!etag) { - const meta = await drive.files.get({ - fileId: existing.id, - fields: 'etag', - }); - etag = meta.data.etag; + const meta = await this._getDriveFileMeta(drive, existing.id); + etag = meta.etag; } await drive.files.update( { fileId: existing.id, media, - fields: 'id,name,etag', + fields: 'id,name', }, etag ? { headers: { 'If-Match': etag } } : undefined, ); @@ -1735,7 +1762,7 @@ class StorageService { mimeType: DRIVE_JSON_MIME, }, media, - fields: 'id,name,etag', + fields: 'id,name', }); } diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js index 161fc8c..1c12156 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -81,12 +81,14 @@ test('GET /api/auth/config returns Google Picker settings', async () => { googleClientSecret: 'google-client-secret', googleCallbackUrl: 'http://localhost:3000/api/auth/google/callback', googlePickerApiKey: 'picker-key-test', + googleCloudProjectNumber: '1234567890', }); const app = createTestApp({ authService }); const res = await request(app).get('/api/auth/config'); assert.equal(res.status, 200); assert.equal(res.body.googleClientId, 'google-client-id.apps.googleusercontent.com'); assert.equal(res.body.googlePickerApiKey, 'picker-key-test'); + assert.equal(res.body.googleCloudProjectNumber, '1234567890'); }); test('GET /api/auth/google/token requires authentication', async () => { diff --git a/backend/tests/storageService.test.js b/backend/tests/storageService.test.js index e057143..943f5e0 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -332,11 +332,18 @@ function createMockDriveClient(initial = {}) { if (alt === 'media') { return { data: file.content ?? '' }; } - const data = { ...file }; - if (fields?.includes('etag') && !data.etag) { - data.etag = `"etag-${fileId}"`; + // Drive v3: etag is HTTP-header only (fields=etag → 400). + if (!file.etag) { + file.etag = `"etag-${fileId}"`; } - return { data }; + const data = { id: file.id, name: file.name, mimeType: file.mimeType }; + if (fields?.includes('capabilities') && file.capabilities) { + data.capabilities = file.capabilities; + } + if (fields?.includes('webViewLink') && file.webViewLink) { + data.webViewLink = file.webViewLink; + } + return { data, headers: { etag: file.etag } }; }, list: async ({ q }) => { const match = /'([^']+)' in parents/.exec(q || ''); @@ -347,7 +354,7 @@ function createMockDriveClient(initial = {}) { }, }; }, - create: async ({ requestBody, media, fields }) => { + create: async ({ requestBody, media }) => { const id = nextId('file'); const parentId = requestBody.parents?.[0] || rootId; const content = media?.body ? await readStream(media.body) : ''; @@ -365,7 +372,10 @@ function createMockDriveClient(initial = {}) { if (entry.mimeType === 'application/vnd.google-apps.folder') { byParent[id] = byParent[id] || []; } - return { data: entry }; + return { + data: { id: entry.id, name: entry.name }, + headers: { etag: entry.etag }, + }; }, update: async ({ fileId, media }, options) => { const file = byId[fileId]; @@ -385,7 +395,10 @@ function createMockDriveClient(initial = {}) { file.content = await readStream(media.body); } file.etag = `"etag-${fileId}-${Date.now()}"`; - return { data: { id: file.id, name: file.name, etag: file.etag } }; + return { + data: { id: file.id, name: file.name }, + headers: { etag: file.etag }, + }; }, }, }; diff --git a/frontend/src/__tests__/connectView.test.jsx b/frontend/src/__tests__/connectView.test.jsx index 8f242e6..3491ec3 100644 --- a/frontend/src/__tests__/connectView.test.jsx +++ b/frontend/src/__tests__/connectView.test.jsx @@ -29,6 +29,7 @@ function mockClient(overrides = {}) { getAuthConfig: vi.fn().mockResolvedValue({ googleClientId: '123456-abc.apps.googleusercontent.com', googlePickerApiKey: 'picker-key', + googleCloudProjectNumber: '123456', }), getGoogleAccessToken: vi.fn().mockResolvedValue({ accessToken: 'ya29.token' }), ...overrides, @@ -174,6 +175,7 @@ describe('ConnectView', () => { apiKey: 'picker-key', clientId: '123456-abc.apps.googleusercontent.com', accessToken: 'ya29.token', + projectNumber: '123456', }), ) await waitFor(() => diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index 61b251e..cb73ec5 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -70,8 +70,12 @@ export class ApiClient { } /** - * Public Google Picker config (client id + browser API key). - * @returns {Promise<{ googleClientId: string | null, googlePickerApiKey: string | null }>} + * Public Google Picker config (client id + browser API key + project number). + * @returns {Promise<{ + * googleClientId: string | null, + * googlePickerApiKey: string | null, + * googleCloudProjectNumber: string | null, + * }>} */ getAuthConfig() { return this._request('/api/auth/config') diff --git a/frontend/src/lib/googlePicker.js b/frontend/src/lib/googlePicker.js index 81aa663..dedfaf3 100644 --- a/frontend/src/lib/googlePicker.js +++ b/frontend/src/lib/googlePicker.js @@ -1,6 +1,9 @@ /** * Google Picker helpers — load the client library and open a folder picker. * Requires drive.file OAuth scope (backend) + GOOGLE_PICKER_API_KEY. + * + * setAppId must be the Cloud **project number** (IAM & Admin → Settings), not + * the project id string. Wrong appId → blank Picker with drive.file. */ const GAPI_SCRIPT = 'https://apis.google.com/js/api.js' @@ -56,6 +59,27 @@ async function ensureGapiPicker() { return gapi } +/** + * Resolve Cloud project number for PickerBuilder.setAppId. + * Prefer an explicit project number; fall back to the OAuth client id prefix + * (usually the same numeric project number). + * + * @param {string | null | undefined} projectNumber + * @param {string} clientId + * @returns {string} + */ +export function resolvePickerAppId(projectNumber, clientId) { + const explicit = projectNumber != null ? String(projectNumber).trim() : '' + if (explicit) return explicit + const prefix = String(clientId).split('-')[0] + if (!/^\d+$/.test(prefix)) { + throw new Error( + 'GOOGLE_CLOUD_PROJECT_NUMBER is required (Cloud Console → IAM & Admin → Settings → Project number)', + ) + } + return prefix +} + /** * Open a folder-only Google Picker. * @@ -63,7 +87,9 @@ async function ensureGapiPicker() { * @param {string} opts.apiKey GOOGLE_PICKER_API_KEY * @param {string} opts.clientId GOOGLE_CLIENT_ID (OAuth web client) * @param {string} opts.accessToken user access token with drive.file + * @param {string} [opts.projectNumber] Cloud project number for setAppId * @param {string} [opts.title] + * @param {string} [opts.origin] page origin (defaults to location.origin) * @returns {Promise<{ id: string, name: string, url: string | null } | null>} * Resolves to the chosen folder, or null if the user cancelled. */ @@ -71,7 +97,9 @@ export async function openDriveFolderPicker({ apiKey, clientId, accessToken, + projectNumber, title = 'Choose a Drive folder for Vizably', + origin, }) { if (!apiKey) { throw new Error('Google Picker API key is not configured (GOOGLE_PICKER_API_KEY)') @@ -83,29 +111,30 @@ export async function openDriveFolderPicker({ throw new Error('Google access token is required to open the Picker') } - const gapi = await ensureGapiPicker() + await ensureGapiPicker() const google = globalThis.google if (!google?.picker) { throw new Error('Google Picker is unavailable') } + const appId = resolvePickerAppId(projectNumber, clientId) + const pickerOrigin = + origin || + (typeof globalThis.location !== 'undefined' ? globalThis.location.origin : undefined) + return new Promise((resolve, reject) => { try { + // Folder-only view. Avoid stacking FOLDERS + setMimeTypes (blank UI). const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) .setIncludeFolders(true) .setSelectFolderEnabled(true) - .setMimeTypes('application/vnd.google-apps.folder') - - // Cloud project number is the numeric prefix of the OAuth client id. - const appId = String(clientId).split('-')[0] - const picker = new google.picker.PickerBuilder() + const builder = new google.picker.PickerBuilder() .setTitle(title) .setAppId(appId) .setDeveloperKey(apiKey) .setOAuthToken(accessToken) .addView(view) - .setSelectableMimeTypes('application/vnd.google-apps.folder') .setCallback((data) => { if (data.action === google.picker.Action.CANCEL) { resolve(null) @@ -124,8 +153,12 @@ export async function openDriveFolderPicker({ }) } }) - .build() + if (pickerOrigin) { + builder.setOrigin(pickerOrigin) + } + + const picker = builder.build() picker.setVisible(true) } catch (err) { reject(err) diff --git a/frontend/src/views/ConnectView.jsx b/frontend/src/views/ConnectView.jsx index 3c9aa9e..effb53a 100644 --- a/frontend/src/views/ConnectView.jsx +++ b/frontend/src/views/ConnectView.jsx @@ -253,6 +253,7 @@ export default function ConnectView({ apiKey: config.googlePickerApiKey, clientId: config.googleClientId, accessToken: tokenPayload.accessToken, + projectNumber: config.googleCloudProjectNumber, }) if (folder) { setPickedFolder(folder) From d60e4340c237e70a5c5cae7b0727e2f9bf26fda3 Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Wed, 22 Jul 2026 15:41:12 -0600 Subject: [PATCH 6/7] refactor(picker): update Google Picker integration to use drive-picker web component and remove deprecated API key requirement --- backend/README.md | 35 ++-- frontend/package-lock.json | 20 ++ frontend/package.json | 1 + frontend/src/__tests__/connectView.test.jsx | 1 - frontend/src/lib/googlePicker.js | 194 +++++++++----------- frontend/src/views/ConnectView.jsx | 7 +- 6 files changed, 133 insertions(+), 125 deletions(-) diff --git a/backend/README.md b/backend/README.md index 2857900..868f338 100644 --- a/backend/README.md +++ b/backend/README.md @@ -157,8 +157,8 @@ lists Drive folders. shipping Vizably to end users. 4. **APIs & Services → Credentials → Create credentials → OAuth client ID** → Application type **Web application**. - - Authorized JavaScript origins: `http://localhost:5173` (Vite) and - `http://localhost:3000` if needed. + - Authorized JavaScript origins: `http://localhost:5173` (required for + Picker) and `http://localhost:3000` if needed. - Authorized redirect URI: `http://localhost:3000/api/auth/google/callback` 5. Copy Client ID + Client secret into `backend/.env`: @@ -179,7 +179,8 @@ lists Drive folders. | `Error 403: access_denied` after publish | Cached deny, wrong project, or scope not on consent screen | Confirm scopes include `drive.file`; redirect URI matches `.env`; retry in a fresh browser profile | | “Google hasn’t verified this app” | Published but unverified (`drive.file`) | Expected for local/dev — use **Advanced → Continue**; submit verification before production launch | | `redirect_uri_mismatch` | Callback URL not registered | Add exactly `http://localhost:3000/api/auth/google/callback` on the OAuth client | -| Picker opens **blank / white** | Wrong project number, API key referrers, or Picker API off | Set `GOOGLE_CLOUD_PROJECT_NUMBER`; enable **Google Picker API**; restrict API key referrers to `http://localhost:5173/*` (and restart backend) | +| Picker opens **blank / white** | Restricted API key, wrong project number, or missing JS origin | Picker no longer sends `GOOGLE_PICKER_API_KEY` by default (session OAuth token is enough). Ensure `GOOGLE_CLOUD_PROJECT_NUMBER` and OAuth **Authorized JavaScript origins** include `http://localhost:5173`. Close DevTools if the iframe stays blank. | +| Picker: **“The API developer key is invalid”** | Key restrictions reject the Vite origin | Leave `GOOGLE_PICKER_API_KEY` unused for Picker (current default). If you force `useDeveloperKey`, fix referrers / enable **Google Picker API**. Create-folder can still work (OAuth-only). | | `Invalid field selection etag` | Old Drive client requesting `fields=etag` | Fixed in storage service — Drive v3 returns ETag only on HTTP headers | `GET /api/auth/config` returns @@ -188,17 +189,27 @@ frontend Picker. Restrict the API key by HTTP referrer in Cloud Console. #### Getting `GOOGLE_PICKER_API_KEY` -1. Cloud Console → **APIs & Services → Credentials → Create credentials → API key**. -2. Open the new key → **Application restrictions → HTTP referrers (web sites)**. - Add: +This is a **browser API key** (Credentials → API key), not the OAuth client +secret. Create-folder does not use it; only Google Picker does. + +1. Cloud Console → **APIs & Services → Library** → enable **Google Picker API** + (and **Google Drive API** if not already on) in the **same project** as your + OAuth client. +2. **APIs & Services → Credentials → Create credentials → API key**. +3. Open the new key → **Application restrictions → HTTP referrers (web sites)**. + Add exactly (Vite serves the Picker from the frontend origin): - `http://localhost:5173/*` - - `http://localhost:3000/*` + - `http://127.0.0.1:5173/*` (if you open the app that way) + - `http://localhost:3000/*` (optional) - your production frontend origin when you deploy (e.g. `https://app.vizably.example/*`) -3. **API restrictions → Restrict key** → select **Google Picker API** (and - optionally **Google Drive API** if the Picker docs for your setup require it). -4. Save → copy the key into `backend/.env` as `GOOGLE_PICKER_API_KEY`. -5. Never commit the key. Treat it as public-ish (it ships to the browser) but - always keep referrer + API restrictions on. +4. **API restrictions → Restrict key** → include at least **Google Picker API**. + If the Picker still fails, temporarily set API restrictions to **Don’t + restrict key** to confirm the key itself is fine, then re-add Picker (+ Drive + if needed). +5. Save → wait a minute for Google to propagate → copy the key into + `backend/.env` as `GOOGLE_PICKER_API_KEY` → **restart the backend**. +6. Never commit the key. Treat it as public-ish (it ships to the browser) but + always keep referrer + API restrictions on for real deploys. Sign-in flow: `GET /api/auth/google` → Google consent → callback → frontend `/connect?provider=google`. Folder selection is Picker-only; then diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ba8b2dc..957070c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "vizably", "version": "0.0.0", "dependencies": { + "@googleworkspace/drive-picker-react": "^0.2.0", "lucide-react": "^0.456.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -1168,6 +1169,25 @@ } } }, + "node_modules/@googleworkspace/drive-picker-element": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@googleworkspace/drive-picker-element/-/drive-picker-element-0.7.3.tgz", + "integrity": "sha512-z1hZh1HsPAQ19lencw2x3FcUVoymWYexcWgq66iXum4mUfWWaQ37oGtQ6hGvM8dyrC81G79P26gq7HhRtbGb2Q==", + "license": "Apache-2.0" + }, + "node_modules/@googleworkspace/drive-picker-react": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@googleworkspace/drive-picker-react/-/drive-picker-react-0.2.0.tgz", + "integrity": "sha512-3CIEZ7U+HDKd8UoXG3l/fPSZFhxajC3MYNIqAZQSba2totuYKVTQMOJsgonO7OnnJq88YD35n7whCF4i5QUyvA==", + "license": "Apache-2.0", + "dependencies": { + "@googleworkspace/drive-picker-element": "0.7.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 43589c0..7a418e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "test": "vitest" }, "dependencies": { + "@googleworkspace/drive-picker-react": "^0.2.0", "lucide-react": "^0.456.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/frontend/src/__tests__/connectView.test.jsx b/frontend/src/__tests__/connectView.test.jsx index 3491ec3..0d7899b 100644 --- a/frontend/src/__tests__/connectView.test.jsx +++ b/frontend/src/__tests__/connectView.test.jsx @@ -172,7 +172,6 @@ describe('ConnectView', () => { await waitFor(() => expect(openFolderPicker).toHaveBeenCalledWith({ - apiKey: 'picker-key', clientId: '123456-abc.apps.googleusercontent.com', accessToken: 'ya29.token', projectNumber: '123456', diff --git a/frontend/src/lib/googlePicker.js b/frontend/src/lib/googlePicker.js index dedfaf3..939ef39 100644 --- a/frontend/src/lib/googlePicker.js +++ b/frontend/src/lib/googlePicker.js @@ -1,68 +1,18 @@ /** - * Google Picker helpers — load the client library and open a folder picker. - * Requires drive.file OAuth scope (backend) + GOOGLE_PICKER_API_KEY. + * Google Picker — folder selection via the official drive-picker web component. * - * setAppId must be the Cloud **project number** (IAM & Admin → Settings), not - * the project id string. Wrong appId → blank Picker with drive.file. - */ - -const GAPI_SCRIPT = 'https://apis.google.com/js/api.js' - -/** - * @param {string} src - * @returns {Promise} + * Uses the session OAuth token (`drive.file`). We intentionally omit + * `developer-key` by default: restricted API keys commonly produce a blank + * Picker or “API developer key is invalid”. OAuth token + app id is enough. + * + * setAppId must be the Cloud **project number** (IAM & Admin → Settings). + * OAuth web client must list the Vite origin under Authorized JavaScript origins. */ -function loadScript(src) { - return new Promise((resolve, reject) => { - const existing = document.querySelector(`script[src="${src}"]`) - if (existing) { - if (existing.dataset.loaded === 'true' || globalThis.gapi) { - resolve() - return - } - existing.addEventListener('load', () => resolve(), { once: true }) - existing.addEventListener('error', () => reject(new Error('Failed to load Google API script')), { - once: true, - }) - return - } - const script = document.createElement('script') - script.src = src - script.async = true - script.onload = () => { - script.dataset.loaded = 'true' - resolve() - } - script.onerror = () => reject(new Error('Failed to load Google API script')) - document.head.appendChild(script) - }) -} -/** - * @returns {Promise} - */ -async function ensureGapiPicker() { - await loadScript(GAPI_SCRIPT) - const gapi = globalThis.gapi - if (!gapi) { - throw new Error('Google API failed to initialize') - } - if (gapi.picker) { - return gapi - } - await new Promise((resolve, reject) => { - gapi.load('picker', { - callback: resolve, - onerror: () => reject(new Error('Failed to load Google Picker')), - }) - }) - return gapi -} +import '@googleworkspace/drive-picker-element' /** * Resolve Cloud project number for PickerBuilder.setAppId. - * Prefer an explicit project number; fall back to the OAuth client id prefix - * (usually the same numeric project number). * * @param {string | null | undefined} projectNumber * @param {string} clientId @@ -80,30 +30,42 @@ export function resolvePickerAppId(projectNumber, clientId) { return prefix } +/** + * @param {unknown} detail + * @returns {string} + */ +function pickerErrorMessage(detail) { + if (!detail) return 'Google Picker failed' + if (typeof detail === 'string') return detail + if (typeof detail === 'object') { + const obj = /** @type {Record} */ (detail) + if (typeof obj.message === 'string') return obj.message + if (typeof obj.error === 'string') return obj.error + if (typeof obj.error_description === 'string') return obj.error_description + } + return 'Google Picker failed' +} + /** * Open a folder-only Google Picker. * * @param {object} opts - * @param {string} opts.apiKey GOOGLE_PICKER_API_KEY - * @param {string} opts.clientId GOOGLE_CLIENT_ID (OAuth web client) - * @param {string} opts.accessToken user access token with drive.file + * @param {string} opts.clientId GOOGLE_CLIENT_ID + * @param {string} opts.accessToken session access token with drive.file * @param {string} [opts.projectNumber] Cloud project number for setAppId + * @param {string} [opts.apiKey] optional; only used if `useDeveloperKey` is true + * @param {boolean} [opts.useDeveloperKey=false] * @param {string} [opts.title] - * @param {string} [opts.origin] page origin (defaults to location.origin) * @returns {Promise<{ id: string, name: string, url: string | null } | null>} - * Resolves to the chosen folder, or null if the user cancelled. */ export async function openDriveFolderPicker({ - apiKey, clientId, accessToken, projectNumber, + apiKey, + useDeveloperKey = false, title = 'Choose a Drive folder for Vizably', - origin, }) { - if (!apiKey) { - throw new Error('Google Picker API key is not configured (GOOGLE_PICKER_API_KEY)') - } if (!clientId) { throw new Error('Google OAuth client id is not configured (GOOGLE_CLIENT_ID)') } @@ -111,57 +73,67 @@ export async function openDriveFolderPicker({ throw new Error('Google access token is required to open the Picker') } - await ensureGapiPicker() - const google = globalThis.google - if (!google?.picker) { - throw new Error('Google Picker is unavailable') + if (typeof document === 'undefined') { + throw new Error('Google Picker requires a browser environment') } const appId = resolvePickerAppId(projectNumber, clientId) - const pickerOrigin = - origin || - (typeof globalThis.location !== 'undefined' ? globalThis.location.origin : undefined) return new Promise((resolve, reject) => { - try { - // Folder-only view. Avoid stacking FOLDERS + setMimeTypes (blank UI). - const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) - .setIncludeFolders(true) - .setSelectFolderEnabled(true) + const host = document.createElement('drive-picker') + host.setAttribute('app-id', appId) + host.setAttribute('client-id', clientId) + host.setAttribute('oauth-token', accessToken) + host.setAttribute('scope', 'https://www.googleapis.com/auth/drive.file') + host.setAttribute('title', title) + // Keep GIS from prompting when we already have a session token. + host.setAttribute('prompt', 'none') - const builder = new google.picker.PickerBuilder() - .setTitle(title) - .setAppId(appId) - .setDeveloperKey(apiKey) - .setOAuthToken(accessToken) - .addView(view) - .setCallback((data) => { - if (data.action === google.picker.Action.CANCEL) { - resolve(null) - return - } - if (data.action === google.picker.Action.PICKED) { - const doc = data.docs?.[0] - if (!doc?.id) { - resolve(null) - return - } - resolve({ - id: doc.id, - name: doc.name || 'Drive folder', - url: doc.url || null, - }) - } - }) + if (useDeveloperKey && apiKey) { + host.setAttribute('developer-key', apiKey) + } - if (pickerOrigin) { - builder.setOrigin(pickerOrigin) - } + const view = document.createElement('drive-picker-docs-view') + view.setAttribute('view-id', 'FOLDERS') + view.setAttribute('include-folders', 'true') + view.setAttribute('select-folder-enabled', 'true') + view.setAttribute('parent', 'root') + host.appendChild(view) - const picker = builder.build() - picker.setVisible(true) - } catch (err) { - reject(err) + const cleanup = () => { + host.remove() } + + host.addEventListener('picker-picked', (event) => { + const detail = /** @type {CustomEvent} */ (event).detail + const doc = detail?.docs?.[0] + cleanup() + if (!doc?.id) { + resolve(null) + return + } + resolve({ + id: doc.id, + name: doc.name || 'Drive folder', + url: doc.url || null, + }) + }) + + host.addEventListener('picker-canceled', () => { + cleanup() + resolve(null) + }) + + host.addEventListener('picker-error', (event) => { + cleanup() + reject(new Error(pickerErrorMessage(/** @type {CustomEvent} */ (event).detail))) + }) + + host.addEventListener('picker-oauth-error', (event) => { + cleanup() + reject(new Error(pickerErrorMessage(/** @type {CustomEvent} */ (event).detail))) + }) + + document.body.appendChild(host) }) } diff --git a/frontend/src/views/ConnectView.jsx b/frontend/src/views/ConnectView.jsx index effb53a..2addc2d 100644 --- a/frontend/src/views/ConnectView.jsx +++ b/frontend/src/views/ConnectView.jsx @@ -249,8 +249,13 @@ export default function ConnectView({ client.getAuthConfig(), client.getGoogleAccessToken(), ]) + if (!config.googleClientId) { + throw new Error('Google OAuth client id is not configured on the server') + } + if (!tokenPayload.accessToken) { + throw new Error('Google access token unavailable — sign out and sign in again') + } const folder = await openFolderPicker({ - apiKey: config.googlePickerApiKey, clientId: config.googleClientId, accessToken: tokenPayload.accessToken, projectNumber: config.googleCloudProjectNumber, From 20bc2ae379e1a6074ece185e508b459e6494131c Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Wed, 22 Jul 2026 15:50:30 -0600 Subject: [PATCH 7/7] chore(auth_storage_guide): update verification checklist in TODO.md to reflect completed tasks --- docs/guides/auth_storage_guide/TODO.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md index 6889fbf..09c92bb 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -224,15 +224,15 @@ One adapter behind the existing provider-neutral interface; no rewrite of Phases ## Verification checklist (run before marking complete) -- [ ] `grep -r "makeAuthRouter" backend/routes/` — factory used, mounted once -- [ ] `grep -r "mountAuthRoutes" backend/` — **zero** (no dual mount) -- [ ] `grep -r "credentials: 'include'" frontend/src/lib/apiClient.js` — present -- [ ] `grep -rn "validateStorage\|listStorages\|setupStorage" frontend/src/lib/apiClient.js` — all present -- [ ] `grep -r "getForAuthenticatedUser" backend/` — **zero** (use `repos.get`/`getContent`) -- [ ] `grep -r "credentials.*access_token" backend/` — **zero** (use `setCredentials`) -- [ ] `grep -r "PROVIDERS" backend/routes/auth.js` — **zero** (no frontend import) -- [ ] `grep -rn "drive.file\|drive.metadata.readonly" backend/` — scope matches the locked decision -- [ ] `grep -rn "sha" backend/services/storageService.js` — GitHub writes pass a blob sha +- [x] `grep -r "makeAuthRouter" backend/routes/` — factory used, mounted once +- [x] `grep -r "mountAuthRoutes" backend/` — **zero** (no dual mount) +- [x] `grep -r "credentials: 'include'" frontend/src/lib/apiClient.js` — present +- [x] `grep -rn "validateStorage\|listStorages\|setupStorage" frontend/src/lib/apiClient.js` — all present +- [x] `grep -r "getForAuthenticatedUser" backend/` — **zero** (use `repos.get`/`getContent`) +- [x] `grep -r "credentials.*access_token" backend/` — **zero** (use `setCredentials`) +- [x] `grep -r "PROVIDERS" backend/routes/auth.js` — **zero** (no frontend import) +- [x] `grep -rn "drive.file\|drive.metadata.readonly" backend/` — scope matches the locked decision +- [x] `grep -rn "sha" backend/services/storageService.js` — GitHub writes pass a blob sha - [ ] No tokens written to the store: review `initStorage` / `saveScanResults` payloads ---