diff --git a/backend/.env.example b/backend/.env.example index b6195ac..04c0ea9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -23,7 +23,16 @@ 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= +# 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 81b3a88..868f338 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,88 @@ 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`. + 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` (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`: + - `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`. +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** + +| 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 | +| 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 +`{ googleClientId, googlePickerApiKey, googleCloudProjectNumber }` for the +frontend Picker. Restrict the API key by HTTP referrer in Cloud Console. + +#### Getting `GOOGLE_PICKER_API_KEY` + +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://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/*`) +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 +`POST /api/auth/storage/validate` and `POST /api/auth/storage` with +`storageRef: { id: "", name: "…" }`. + ## Endpoints | Method | Path | Notes | @@ -142,7 +223,10 @@ 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, 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 | | 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..f540d13 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -32,24 +32,87 @@ 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, + googleCloudProjectNumber: authService.googleCloudProjectNumber || null, }); }); + /** + * 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; + 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..33e3316 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,11 @@ 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) + * @param {string} [deps.googleCloudProjectNumber] Cloud project number for Picker setAppId */ constructor(deps = {}) { this.sessionSecret = deps.sessionSecret ?? process.env.SESSION_SECRET; @@ -41,6 +54,17 @@ 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.googleCloudProjectNumber = + deps.googleCloudProjectNumber ?? + process.env.GOOGLE_CLOUD_PROJECT_NUMBER ?? + null; this._encryptionKey = this._parseEncryptionKey(this.encryptionKeyB64); this._passportConfigured = false; @@ -287,7 +311,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 +366,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 +513,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 +579,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 +616,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 +634,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 +667,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..70c16c1 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,699 @@ 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; + } + + /** + * 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 + */ + 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 this._getDriveFileMeta(drive, current.id); + return { + id: current.id, + name: MANIFEST_PATH, + content, + 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 this._getDriveFileMeta(drive, legacy.id); + return { + id: legacy.id, + name: LEGACY_MANIFEST_PATH, + content, + etag: meta.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 this._getDriveFileMeta(drive, existing.id); + etag = meta.etag; + } + await drive.files.update( + { + fileId: existing.id, + media, + fields: 'id,name', + }, + etag ? { headers: { 'If-Match': etag } } : undefined, + ); + return; + } + + await drive.files.create({ + requestBody: { + name, + parents: [parentId], + mimeType: DRIVE_JSON_MIME, + }, + media, + fields: 'id,name', + }); + } + + /** @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..1c12156 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -37,17 +37,64 @@ 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, 503); + assert.match(res.body.error, /not configured/i); +}); + +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, 501); - assert.match(res.body.error, /Phase 3/); + 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', + 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/callback returns 501 until Phase 3', async () => { +test('GET /api/auth/google/token requires authentication', async () => { const app = createTestApp(); - const res = await request(app).get('/api/auth/google/callback'); - assert.equal(res.status, 501); + const res = await request(app).get('/api/auth/google/token'); + assert.equal(res.status, 401); }); 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..943f5e0 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -254,6 +254,156 @@ 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 ?? '' }; + } + // Drive v3: etag is HTTP-header only (fields=etag → 400). + if (!file.etag) { + file.etag = `"etag-${fileId}"`; + } + 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 || ''); + const parentId = match?.[1]; + return { + data: { + files: list(parentId).map(({ content: _c, ...meta }) => meta), + }, + }; + }, + create: async ({ requestBody, media }) => { + 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: { id: entry.id, name: entry.name }, + headers: { etag: entry.etag }, + }; + }, + 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 }, + headers: { etag: file.etag }, + }; + }, + }, + }; +} + test('listGitHubRepos maps node id and repo metadata', async () => { const storageService = new StorageService(); const client = createMockGitHubClient(); @@ -391,11 +541,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 +755,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 3e845c3..09c92bb 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -188,18 +188,18 @@ One adapter behind the existing provider-neutral interface; no rewrite of Phases ### Backend -- [ ] `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] `npm install passport-google-oauth20 googleapis` (+ dev types) +- [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 -- [ ] `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 --- @@ -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 --- 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/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..0d7899b 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,12 @@ 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', + googleCloudProjectNumber: '123456', + }), + getGoogleAccessToken: vi.fn().mockResolvedValue({ accessToken: 'ya29.token' }), ...overrides, } } @@ -142,13 +154,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({ + clientId: '123456-abc.apps.googleusercontent.com', + accessToken: 'ya29.token', + projectNumber: '123456', + }), + ) + 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..cb73ec5 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,32 @@ 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 + project number). + * @returns {Promise<{ + * googleClientId: string | null, + * googlePickerApiKey: string | null, + * googleCloudProjectNumber: 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..939ef39 --- /dev/null +++ b/frontend/src/lib/googlePicker.js @@ -0,0 +1,139 @@ +/** + * Google Picker — folder selection via the official drive-picker web component. + * + * 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. + */ + +import '@googleworkspace/drive-picker-element' + +/** + * Resolve Cloud project number for PickerBuilder.setAppId. + * + * @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 +} + +/** + * @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.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] + * @returns {Promise<{ id: string, name: string, url: string | null } | null>} + */ +export async function openDriveFolderPicker({ + clientId, + accessToken, + projectNumber, + apiKey, + useDeveloperKey = false, + title = 'Choose a Drive folder for Vizably', +}) { + 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') + } + + if (typeof document === 'undefined') { + throw new Error('Google Picker requires a browser environment') + } + + const appId = resolvePickerAppId(projectNumber, clientId) + + return new Promise((resolve, reject) => { + 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') + + if (useDeveloperKey && apiKey) { + host.setAttribute('developer-key', apiKey) + } + + 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 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 f3c5e12..2addc2d 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,85 @@ 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(), + ]) + 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({ + clientId: config.googleClientId, + accessToken: tokenPayload.accessToken, + projectNumber: config.googleCloudProjectNumber, + }) + 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 +284,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 +293,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 +361,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 +588,27 @@ export default function ConnectView({ )} - + {isGitHub && ( + + )} @@ -534,7 +619,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 }) => ( 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" + } +}