From 1cb46d3716dbf9ce854ffe350477a14bb90da07e Mon Sep 17 00:00:00 2001 From: Andy Young Date: Thu, 2 Oct 2025 00:47:26 -0700 Subject: [PATCH 01/13] Add Puppeteer and /embed route --- package-lock.json | 758 +++++++++++++++++++++++++-- package.json | 1 + src/index.ts | 13 + src/restApiInstance.ts | 10 +- src/server/express.ts | 5 + src/tools/toolName.ts | 3 + src/tools/views/embed.html.ts | 54 ++ src/tools/views/getViewImage.test.ts | 6 +- src/tools/views/getViewImage.ts | 73 ++- src/tools/views/renderer.ts | 110 ++++ src/utils/getJwt.ts | 11 + 11 files changed, 962 insertions(+), 82 deletions(-) create mode 100644 src/tools/views/embed.html.ts create mode 100644 src/tools/views/renderer.ts diff --git a/package-lock.json b/package-lock.json index f9fb74f8..3e78e159 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "express": "^5.1.0", "fast-levenshtein": "^3.0.0", "jose": "^6.0.12", + "puppeteer": "^24.23.0", "ts-results-es": "^5.0.1", "zod": "^3.24.3", "zod-validation-error": "^4.0.1" @@ -96,6 +97,26 @@ "node": ">= 4" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -109,7 +130,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -1350,6 +1370,27 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.10.tgz", + "integrity": "sha512-3ZG500+ZeLql8rE0hjfhkycJjDj0pI/btEh3L9IkWUYcOrgP0xCNRq3HbtbqOPbvDhFaAWD88pDFtlLv8ns8gA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.2", + "tar-fs": "^3.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2464,6 +2505,12 @@ "win32" ] }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -2625,7 +2672,7 @@ "version": "22.15.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz", "integrity": "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==", - "dev": true, + "devOptional": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2674,6 +2721,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.33.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz", @@ -3124,6 +3181,15 @@ "node": ">=0.4.0" } }, + "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/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3171,7 +3237,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3191,8 +3256,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-hidden": { "version": "1.2.6", @@ -3255,6 +3319,18 @@ "node": ">=12" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz", @@ -3316,6 +3392,98 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bare-events": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", + "license": "Apache-2.0" + }, + "node_modules/bare-fs": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.5.tgz", + "integrity": "sha512-TCtu93KGLu6/aiGWzMr12TmSRS6nKdfhAnzTQRbXoSWxkbb9eRd53jQ51jG7g1gYjjtto3hbBrrhzg6djcgiKg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.2.2.tgz", + "integrity": "sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -3357,6 +3525,15 @@ "node": ">=8" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -3440,7 +3617,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -3505,6 +3681,19 @@ "node": ">= 16" } }, + "node_modules/chromium-bidi": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-9.1.0.tgz", + "integrity": "sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3532,7 +3721,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3547,7 +3735,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3557,14 +3744,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/cliui/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==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3579,7 +3764,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3592,7 +3776,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -3637,7 +3820,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3648,8 +3830,7 @@ "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==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -3751,6 +3932,50 @@ "node": ">= 0.10" } }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3770,6 +3995,15 @@ "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", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3825,9 +4059,10 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "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" }, @@ -3934,6 +4169,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3958,6 +4207,12 @@ "dev": true, "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1508733", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", + "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", + "license": "BSD-3-Clause" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -4021,16 +4276,23 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, "dependencies": { "once": "^1.4.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -4214,7 +4476,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4237,6 +4498,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.28.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.28.0.tgz", @@ -4420,6 +4702,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -4448,7 +4743,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "engines": { "node": ">=4.0" } @@ -4466,7 +4760,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4479,6 +4772,15 @@ "node": ">= 0.6" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -4681,6 +4983,41 @@ "node": ">=0.10.0" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4692,6 +5029,12 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4752,6 +5095,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -5042,7 +5394,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5123,6 +5474,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", @@ -5350,6 +5715,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "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/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5374,7 +5765,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -5424,6 +5814,15 @@ "node": ">= 0.10" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5454,7 +5853,6 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -5618,7 +6016,6 @@ "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==", - "dev": true, "engines": { "node": ">=8" } @@ -6008,7 +6405,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6029,6 +6425,12 @@ "dev": true, "license": "MIT" }, + "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", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6075,6 +6477,12 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -6305,6 +6713,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6353,6 +6767,15 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -6769,6 +7192,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "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", @@ -6779,7 +7234,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -6891,11 +7345,16 @@ "node": ">= 14.16" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -7041,6 +7500,15 @@ "node": ">=6" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7053,17 +7521,43 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "peer": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "dev": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -7077,6 +7571,45 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.23.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.23.0.tgz", + "integrity": "sha512-BVR1Lg8sJGKXY79JARdIssFWK2F6e1j+RyuJP66w4CUmpaXjENicmA3nNpUXA8lcTdDjAndtP+oNdni3T/qQqA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.10", + "chromium-bidi": "9.1.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1508733", + "puppeteer-core": "24.23.0", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.23.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.23.0.tgz", + "integrity": "sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.10", + "chromium-bidi": "9.1.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1508733", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.6", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -7318,7 +7851,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7348,7 +7880,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -7577,7 +8108,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -7901,6 +8431,54 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7991,6 +8569,17 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -8250,6 +8839,45 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/test-exclude": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", @@ -8345,6 +8973,29 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8539,7 +9190,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -8658,11 +9308,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8716,7 +9372,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "devOptional": true }, "node_modules/universalify": { "version": "2.0.1", @@ -9026,6 +9682,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.6.tgz", + "integrity": "sha512-mlGndEOA9yK9YAbvtxaPTqdi/kaCWYYfwrZvGzcmkr/3lWM+tQj53BxtpVd6qbC6+E5OnHXgCcAhre6AkXzxjA==", + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9251,7 +9913,6 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -9289,7 +9950,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -9299,7 +9959,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -9318,7 +9977,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9328,7 +9986,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9338,14 +9995,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/yargs/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==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9360,7 +10015,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9369,6 +10023,16 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 838519a2..e2559ef0 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "express": "^5.1.0", "fast-levenshtein": "^3.0.0", "jose": "^6.0.12", + "puppeteer": "^24.23.0", "ts-results-es": "^5.0.1", "zod": "^3.24.3", "zod-validation-error": "^4.0.1" diff --git a/src/index.ts b/src/index.ts index 5e0bf53a..6edc4548 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ #!/usr/bin/env node import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import dotenv from 'dotenv'; +import express from 'express'; import { getConfig } from './config.js'; import { isLoggingLevel, log, setLogLevel, writeToStderr } from './logging/log.js'; import { Server, serverName, serverVersion } from './server.js'; import { startExpressServer } from './server/express.js'; +import { embedHtml } from './tools/views/embed.html.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; async function startServer(): Promise { @@ -20,6 +22,17 @@ async function startServer(): Promise { server.registerTools(); server.registerRequestHandlers(); + const app = express(); + + app.get('/embed', (req, res) => { + res.set('Content-Type', 'text/html'); + res.send(Buffer.from(embedHtml)); + }); + + app.listen(config.httpPort, () => { + log.info(server, `Embed server running on port ${config.httpPort}`); + }); + const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 192a562b..88211d66 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -16,6 +16,7 @@ import { import RestApi from './sdks/tableau/restApi.js'; import { Server } from './server.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; +import { getJwtAdditionalPayload, getJwtSubClaim } from './utils/getJwt.js'; import { isAxiosError } from './utils/isAxiosError.js'; type JwtScopes = @@ -196,12 +197,3 @@ function logResponse( log.info(server, messageObj, { logger: 'rest-api', requestId }); } - -function getJwtSubClaim(config: Config): string { - return config.jwtSubClaim; -} - -function getJwtAdditionalPayload(config: Config): Record { - const json = config.jwtAdditionalPayload; - return JSON.parse(json || '{}'); -} diff --git a/src/server/express.ts b/src/server/express.ts index 97a53703..5448b6ea 100644 --- a/src/server/express.ts +++ b/src/server/express.ts @@ -9,6 +9,7 @@ import https from 'https'; import { Config } from '../config.js'; import { setLogLevel } from '../logging/log.js'; import { Server } from '../server.js'; +import { embedHtml } from '../tools/views/embed.html.js'; export async function startExpressServer({ basePath, @@ -43,6 +44,10 @@ export async function startExpressServer({ app.post(path, createMcpServer); app.get(path, methodNotAllowed); app.delete(path, methodNotAllowed); + app.get('/embed', (req, res) => { + res.set('Content-Type', 'text/html'); + res.send(Buffer.from(embedHtml)); + }); const useSsl = !!(config.sslKey && config.sslCert); if (!useSsl) { diff --git a/src/tools/toolName.ts b/src/tools/toolName.ts index aeb8a0a9..7689d077 100644 --- a/src/tools/toolName.ts +++ b/src/tools/toolName.ts @@ -14,6 +14,7 @@ export const toolNames = [ 'list-pulse-metric-subscriptions', 'generate-pulse-metric-value-insight-bundle', 'search-content', + 'embed-workbook', ] as const; export type ToolName = (typeof toolNames)[number]; @@ -23,6 +24,7 @@ export const toolGroupNames = [ 'view', 'pulse', 'content-exploration', + 'embedding', ] as const; export type ToolGroupName = (typeof toolGroupNames)[number]; @@ -39,6 +41,7 @@ export const toolGroups = { 'generate-pulse-metric-value-insight-bundle', ], 'content-exploration': ['search-content'], + embedding: ['embed-workbook'], } as const satisfies Record>; export function isToolName(value: unknown): value is ToolName { diff --git a/src/tools/views/embed.html.ts b/src/tools/views/embed.html.ts new file mode 100644 index 00000000..28ed1fc4 --- /dev/null +++ b/src/tools/views/embed.html.ts @@ -0,0 +1,54 @@ +export const embedHtml = String.raw` + + + + + + + + + + + + +`; diff --git a/src/tools/views/getViewImage.test.ts b/src/tools/views/getViewImage.test.ts index 3f491591..c5e77b80 100644 --- a/src/tools/views/getViewImage.test.ts +++ b/src/tools/views/getViewImage.test.ts @@ -40,7 +40,7 @@ describe('getViewImageTool', () => { it('should successfully get view image', async () => { mocks.mockQueryViewImage.mockResolvedValue(mockPngData); - const result = await getToolResult({ viewId: '4d18c547-bbb1-4187-ae5a-7f78b35adf2d' }); + const result = await getToolResult({ url: 'https://example.com' }); expect(result.isError).toBe(false); expect(result.content).toHaveLength(1); expect(result.content[0]).toMatchObject({ @@ -60,13 +60,13 @@ describe('getViewImageTool', () => { it('should handle API errors gracefully', async () => { const errorMessage = 'API Error'; mocks.mockQueryViewImage.mockRejectedValue(new Error(errorMessage)); - const result = await getToolResult({ viewId: '4d18c547-bbb1-4187-ae5a-7f78b35adf2d' }); + const result = await getToolResult({ url: 'https://example.com' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain(errorMessage); }); }); -async function getToolResult(params: { viewId: string }): Promise { +async function getToolResult(params: { url: string }): Promise { const getViewImageTool = getGetViewImageTool(new Server()); return await getViewImageTool.callback(params, { signal: new AbortController().signal, diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 41fa1b0e..09343c86 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -1,15 +1,14 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../config.js'; -import { useRestApi } from '../../restApiInstance.js'; import { Server } from '../../server.js'; -import { convertPngDataToToolResult } from '../convertPngDataToToolResult.js'; +import { getJwt, getJwtAdditionalPayload, getJwtSubClaim } from '../../utils/getJwt.js'; import { Tool } from '../tool.js'; +import { createRenderer, RendererOptions } from './renderer.js'; const paramsSchema = { - viewId: z.string(), + url: z.string(), width: z.number().gt(0).optional(), height: z.number().gt(0).optional(), }; @@ -25,32 +24,60 @@ export const getGetViewImageTool = (server: Server): Tool = readOnlyHint: true, openWorldHint: false, }, - callback: async ({ viewId, width, height }, { requestId }): Promise => { + callback: async ({ url, width, height }, { requestId }): Promise => { const config = getConfig(); return await getViewImageTool.logAndExecute({ requestId, - args: { viewId }, + args: { url }, callback: async () => { - return new Ok( - await useRestApi({ - config, - requestId, - server, - jwtScopes: ['tableau:views:download'], - callback: async (restApi) => { - return await restApi.viewsMethods.queryViewImage({ - viewId, - siteId: restApi.siteId, - width, - height, - resolution: 'high', - }); + // TODO: Validate URL is a valid Tableau view URL + + const rendererOptions: RendererOptions = { + width: width || 800, + height: height || 800, + url, + }; + + const token = await getJwt({ + username: getJwtSubClaim(config), + connectedApp: { + clientId: config.connectedAppClientId, + secretId: config.connectedAppSecretId, + secretValue: config.connectedAppSecretValue, + }, + scopes: new Set([ + 'tableau:views:embed', + 'tableau:views:embed_authoring', + 'tableau:insights:embed', + ]), + additionalPayload: getJwtAdditionalPayload(config), + }); + + // TODO: https + const embedUrl = `http://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; + + const renderer = await createRenderer(); + const screenshot = await renderer.screenshot(server, embedUrl, rendererOptions); + await renderer.close(); + return screenshot; + }, + getSuccessResult: (screenshot: Uint8Array) => { + const base64Data = Buffer.from(screenshot).toString('base64'); + return { + isError: false, + content: [ + { + type: 'image', + data: base64Data, + mimeType: 'image/png', }, - }), - ); + ], + }; + }, + getErrorText: (error) => { + return error; }, - getSuccessResult: convertPngDataToToolResult, }); }, }); diff --git a/src/tools/views/renderer.ts b/src/tools/views/renderer.ts new file mode 100644 index 00000000..bcb646d4 --- /dev/null +++ b/src/tools/views/renderer.ts @@ -0,0 +1,110 @@ +import puppeteer, { Browser, Page, ScreenshotOptions } from 'puppeteer'; +import { Err, Ok, Result } from 'ts-results-es'; + +import { Server } from '../../server.js'; +import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; + +export type RendererOptions = { + url: string; + width: number; + height: number; +}; + +export class Renderer { + private browser: Browser; + + constructor(browser: Browser) { + this.browser = browser; + } + + private async _setupPageAndContext(options: RendererOptions): Promise { + const context = await this.browser.createBrowserContext(); + const page = await context.newPage(); + + await page.setViewport({ + width: options.width, + height: options.height, + }); + + return page; + } + + private async _waitForPageLoad(page: Page, url: string): Promise { + try { + await page.goto(url, { waitUntil: 'networkidle2' }); + } catch (e) { + throw `Navigation failed: ${e}`; + } + + try { + await page.waitForFunction(isPageLoadedAndStable, { timeout: 10000 }); + } catch (e) { + throw `Page failed to load: ${e}`; + } + } + private async _finalizePage(page: Page): Promise { + await page.emulateMediaType('screen'); + } + + private async _createPage(server: Server, url: string, options: RendererOptions): Promise { + const page = await this._setupPageAndContext(options); + await this._waitForPageLoad(page, url); + await this._finalizePage(page); + return page; + } + + async screenshot( + server: Server, + url: string, + options: RendererOptions, + ): Promise> { + let page: Page | null = null; + let screenshot: Uint8Array | null = null; + try { + page = await this._createPage(server, url, options); + + const screenshotOptions: ScreenshotOptions = { + type: 'png', + fullPage: true, + omitBackground: false, + }; + + screenshot = await page.screenshot(screenshotOptions); + } catch (e) { + return Err(getExceptionMessage(e)); + } finally { + if (page) { + try { + const context = page.browserContext(); + await page.close(); + await context.close(); + } catch { + // ignore + } + } + } + return Ok(screenshot); + } + + async close(): Promise { + await this.browser.close(); + } +} + +export async function createRenderer(): Promise { + const browser = await puppeteer.launch({ + headless: true, + }); + + return new Renderer(browser); +} + +function isPageLoadedAndStable(): boolean { + const navigationTiming = performance?.getEntriesByType?.('navigation')[0]; + + return ( + navigationTiming?.duration > 0 && + document?.readyState === 'complete' && + !!document.querySelector('div[id="success"]') + ); +} diff --git a/src/utils/getJwt.ts b/src/utils/getJwt.ts index 1460d4e0..965556d6 100644 --- a/src/utils/getJwt.ts +++ b/src/utils/getJwt.ts @@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto'; import { JWTHeaderParameters, JWTPayload, SignJWT } from 'jose'; +import { Config } from '../config.js'; + export async function getJwt({ username, connectedApp, @@ -40,3 +42,12 @@ export async function getJwt({ return token; } + +export function getJwtSubClaim(config: Config): string { + return config.jwtSubClaim; +} + +export function getJwtAdditionalPayload(config: Config): Record { + const json = config.jwtAdditionalPayload; + return JSON.parse(json || '{}'); +} From e64601e3686bafa8f582005f48b94a1ae49d46f8 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Thu, 2 Oct 2025 11:43:25 -0700 Subject: [PATCH 02/13] Add error handling --- src/config.ts | 3 + src/tools/views/embed.html.ts | 34 ++++++- src/tools/views/getViewImage.ts | 109 +++++++++++++++++----- src/tools/views/renderer.ts | 157 ++++++++++++++++++++++++-------- types/process-env.d.ts | 1 + 5 files changed, 240 insertions(+), 64 deletions(-) diff --git a/src/config.ts b/src/config.ts index c03cb114..fbbd18cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,6 +31,7 @@ export class Config { maxResultLimit: number | null; disableQueryDatasourceFilterValidation: boolean; disableMetadataApiRequests: boolean; + useHeadedBrowser: boolean; constructor() { const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env); @@ -58,6 +59,7 @@ export class Config { MAX_RESULT_LIMIT: maxResultLimit, DISABLE_QUERY_DATASOURCE_FILTER_VALIDATION: disableQueryDatasourceFilterValidation, DISABLE_METADATA_API_REQUESTS: disableMetadataApiRequests, + USE_HEADED_BROWSER: useHeadedBrowser, } = cleansedVars; const defaultPort = 3927; @@ -76,6 +78,7 @@ export class Config { this.disableLogMasking = disableLogMasking === 'true'; this.disableQueryDatasourceFilterValidation = disableQueryDatasourceFilterValidation === 'true'; this.disableMetadataApiRequests = disableMetadataApiRequests === 'true'; + this.useHeadedBrowser = useHeadedBrowser === 'true'; const maxResultLimitNumber = maxResultLimit ? parseInt(maxResultLimit) : NaN; this.maxResultLimit = diff --git a/src/tools/views/embed.html.ts b/src/tools/views/embed.html.ts index 28ed1fc4..a31c4b01 100644 --- a/src/tools/views/embed.html.ts +++ b/src/tools/views/embed.html.ts @@ -22,6 +22,30 @@ export const embedHtml = String.raw` document.body.prepend(div); } + function showError(message) { + const div = document.createElement('div'); + div.id = 'error'; + div.textContent = message; + div.style.display = 'none'; + document.body.prepend(div); + } + + function getExceptionMessage(error) { + if (typeof error === 'string') { + return error; + } + + if (error instanceof Error) { + return error.message; + } + + try { + return JSON.stringify(error) ?? 'undefined'; + } catch { + return ${'`${error}`'}; + } +} + const urlParams = new URLSearchParams(window.location.hash.substring(1)); const url = urlParams.get('url'); const token = urlParams.get('token'); @@ -36,16 +60,22 @@ export const embedHtml = String.raw` try { await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject('firstinteractive event did not fire within 10 seconds'), 10000); + const timeout = setTimeout(() => reject('firstinteractive event did not fire within 30 seconds'), 30000); viz.addEventListener('firstinteractive', () => { showSuccess('Viz is interactive!'); clearTimeout(timeout); resolve(); }); + + viz.addEventListener('vizloaderror', (e) => { + const detail = JSON.parse(e.detail.message); + clearTimeout(timeout); + reject(JSON.stringify({ status: detail.statusCode, errorCodes:JSON.parse(detail.errorMessage).result.errors.map(({ code }) => code) })); + }); }); } catch (e) { - console.log(e); + showError(getExceptionMessage(e)); } })(); diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 09343c86..413ebe87 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -1,11 +1,17 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Err, Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../config.js'; import { Server } from '../../server.js'; +import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; import { getJwt, getJwtAdditionalPayload, getJwtSubClaim } from '../../utils/getJwt.js'; import { Tool } from '../tool.js'; -import { createRenderer, RendererOptions } from './renderer.js'; +import { Renderer, RendererError, RendererOptions } from './renderer.js'; + +type GetViewImageError = + | RendererError + | { type: 'invalid-url' | 'embedding-api-not-found'; url: string; error: unknown }; const paramsSchema = { url: z.string(), @@ -27,11 +33,40 @@ export const getGetViewImageTool = (server: Server): Tool = callback: async ({ url, width, height }, { requestId }): Promise => { const config = getConfig(); - return await getViewImageTool.logAndExecute({ + return await getViewImageTool.logAndExecute({ requestId, args: { url }, callback: async () => { - // TODO: Validate URL is a valid Tableau view URL + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch (error) { + return Err({ + type: 'invalid-url', + url, + error, + }); + } + + const embeddingApiUrl = `${parsedUrl.origin}/javascripts/api/tableau.embedding.3.latest.js`; + try { + const response = await fetch(embeddingApiUrl); + if (!response.ok) { + return Err({ + type: 'embedding-api-not-found', + url: embeddingApiUrl, + error: new Error( + `Failed to fetch embedding API JavaScript module: ${response.status} ${response.statusText}`, + ), + }); + } + } catch (error) { + return Err({ + type: 'embedding-api-not-found', + url: embeddingApiUrl, + error, + }); + } const rendererOptions: RendererOptions = { width: width || 800, @@ -39,30 +74,38 @@ export const getGetViewImageTool = (server: Server): Tool = url, }; - const token = await getJwt({ - username: getJwtSubClaim(config), - connectedApp: { - clientId: config.connectedAppClientId, - secretId: config.connectedAppSecretId, - secretValue: config.connectedAppSecretValue, - }, - scopes: new Set([ - 'tableau:views:embed', - 'tableau:views:embed_authoring', - 'tableau:insights:embed', - ]), - additionalPayload: getJwtAdditionalPayload(config), - }); + const token = + parsedUrl.host === 'public.tableau.com' + ? '' + : await getJwt({ + username: getJwtSubClaim(config), + connectedApp: { + clientId: config.connectedAppClientId, + secretId: config.connectedAppSecretId, + secretValue: config.connectedAppSecretValue, + }, + scopes: new Set([ + 'tableau:views:embed', + 'tableau:views:embed_authoring', + 'tableau:insights:embed', + ]), + additionalPayload: getJwtAdditionalPayload(config), + }); // TODO: https const embedUrl = `http://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; - const renderer = await createRenderer(); - const screenshot = await renderer.screenshot(server, embedUrl, rendererOptions); + const renderer = await Renderer.create({ headless: !config.useHeadedBrowser }); + const result = await renderer.screenshot(server, embedUrl, rendererOptions); + if (result.isErr()) { + return result; + } + + const screenshot = result.value; await renderer.close(); - return screenshot; + return Ok(screenshot); }, - getSuccessResult: (screenshot: Uint8Array) => { + getSuccessResult: (screenshot: Uint8Array): CallToolResult => { const base64Data = Buffer.from(screenshot).toString('base64'); return { isError: false, @@ -75,8 +118,28 @@ export const getGetViewImageTool = (server: Server): Tool = ], }; }, - getErrorText: (error) => { - return error; + getErrorText: (error: GetViewImageError) => { + return JSON.stringify({ + reason: (() => { + switch (error.type) { + case 'invalid-url': + return `The URL is invalid: ${error.url}`; + case 'embedding-api-not-found': + return `The Embedding API JavaScript module was not found at ${error.url}.`; + case 'screenshot-failed': + return 'Failed to take screenshot of the view.'; + case 'navigation-failed': + return 'Failed to navigate to the view.'; + case 'page-failed-to-load': + return 'Failed to load the view.'; + case 'browser-context-creation-failed': + return 'Failed to create browser context.'; + case 'page-creation-failed': + return 'Failed to create page.'; + } + })(), + exception: getExceptionMessage(error.error), + }); }, }); }, diff --git a/src/tools/views/renderer.ts b/src/tools/views/renderer.ts index bcb646d4..c38c3c0e 100644 --- a/src/tools/views/renderer.ts +++ b/src/tools/views/renderer.ts @@ -1,8 +1,7 @@ -import puppeteer, { Browser, Page, ScreenshotOptions } from 'puppeteer'; +import puppeteer, { Browser, BrowserContext, Page, ScreenshotOptions } from 'puppeteer'; import { Err, Ok, Result } from 'ts-results-es'; import { Server } from '../../server.js'; -import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; export type RendererOptions = { url: string; @@ -10,58 +9,130 @@ export type RendererOptions = { height: number; }; +export type RendererError = { + type: + | 'navigation-failed' + | 'page-failed-to-load' + | 'browser-context-creation-failed' + | 'page-creation-failed' + | 'screenshot-failed'; + error: unknown; +}; + export class Renderer { private browser: Browser; - constructor(browser: Browser) { + private constructor(browser: Browser) { this.browser = browser; } - private async _setupPageAndContext(options: RendererOptions): Promise { - const context = await this.browser.createBrowserContext(); - const page = await context.newPage(); - - await page.setViewport({ - width: options.width, - height: options.height, + static async create({ headless = true }: { headless?: boolean } = {}): Promise { + const browser = await puppeteer.launch({ + headless, }); - return page; + return new Renderer(browser); + } + + private async _getBrowserContext(): Promise> { + try { + const context = await this.browser.createBrowserContext(); + return Ok(context); + } catch (error) { + return Err(error); + } + } + + private async _getPage( + context: BrowserContext, + options: RendererOptions, + ): Promise> { + try { + const page = await context.newPage(); + await page.setViewport({ + width: options.width, + height: options.height, + }); + + return Ok(page); + } catch (error) { + return Err(error); + } } - private async _waitForPageLoad(page: Page, url: string): Promise { + private async _navigate(page: Page, url: string): Promise> { try { await page.goto(url, { waitUntil: 'networkidle2' }); - } catch (e) { - throw `Navigation failed: ${e}`; + } catch (error) { + return Err(error); } + return Ok.EMPTY; + } + + private async _waitForPageLoad( + page: Page, + ): Promise> { try { - await page.waitForFunction(isPageLoadedAndStable, { timeout: 10000 }); - } catch (e) { - throw `Page failed to load: ${e}`; + const handle = await page.waitForFunction(isPageLoadedAndStable, { timeout: 10000 }); + const result = await handle.jsonValue(); + if (result && result.state === 'error') { + return Err({ type: 'viz-load-error', error: JSON.parse(result.message) }); + } + } catch (error) { + return Err({ type: 'unknown', error }); } + + return Ok.EMPTY; } + private async _finalizePage(page: Page): Promise { await page.emulateMediaType('screen'); } - private async _createPage(server: Server, url: string, options: RendererOptions): Promise { - const page = await this._setupPageAndContext(options); - await this._waitForPageLoad(page, url); - await this._finalizePage(page); - return page; + private async _createPage( + server: Server, + url: string, + options: RendererOptions, + ): Promise> { + const context = await this._getBrowserContext(); + if (context.isErr()) { + return Err({ type: 'browser-context-creation-failed', error: context.error }); + } + + const pageResult = await this._getPage(context.value, options); + if (pageResult.isErr()) { + return Err({ type: 'page-creation-failed', error: pageResult.error }); + } + + const navigateResult = await this._navigate(pageResult.value, url); + if (navigateResult.isErr()) { + return Err({ type: 'navigation-failed', error: navigateResult.error }); + } + + const pageLoadResult = await this._waitForPageLoad(pageResult.value); + if (pageLoadResult.isErr()) { + return Err({ type: 'page-failed-to-load', error: pageLoadResult.error }); + } + + await this._finalizePage(pageResult.value); + return Ok(pageResult.value); } async screenshot( server: Server, url: string, options: RendererOptions, - ): Promise> { + ): Promise> { let page: Page | null = null; let screenshot: Uint8Array | null = null; try { - page = await this._createPage(server, url, options); + const pageResult = await this._createPage(server, url, options); + if (pageResult.isErr()) { + return pageResult; + } + + page = pageResult.value; const screenshotOptions: ScreenshotOptions = { type: 'png', @@ -70,8 +141,8 @@ export class Renderer { }; screenshot = await page.screenshot(screenshotOptions); - } catch (e) { - return Err(getExceptionMessage(e)); + } catch (error) { + return Err({ type: 'screenshot-failed', error }); } finally { if (page) { try { @@ -91,20 +162,28 @@ export class Renderer { } } -export async function createRenderer(): Promise { - const browser = await puppeteer.launch({ - headless: true, - }); +function isPageLoadedAndStable(): + | false + | { state: 'loaded' } + | { state: 'error'; message: string } { + const navigationTiming = performance?.getEntriesByType?.('navigation')[0]; - return new Renderer(browser); -} + if (!navigationTiming?.duration) { + return false; + } -function isPageLoadedAndStable(): boolean { - const navigationTiming = performance?.getEntriesByType?.('navigation')[0]; + if (![undefined, 'complete'].includes(document?.readyState)) { + return false; + } + + if (document.querySelector('div[id="success"]')) { + return { state: 'loaded' }; + } + + const errorDiv = document.querySelector('div[id="error"]'); + if (errorDiv) { + return { state: 'error', message: errorDiv.textContent }; + } - return ( - navigationTiming?.duration > 0 && - document?.readyState === 'complete' && - !!document.querySelector('div[id="success"]') - ); + return false; } diff --git a/types/process-env.d.ts b/types/process-env.d.ts index 21c7d0de..14672bde 100644 --- a/types/process-env.d.ts +++ b/types/process-env.d.ts @@ -22,6 +22,7 @@ export interface ProcessEnvEx { MAX_RESULT_LIMIT: string | undefined; DISABLE_QUERY_DATASOURCE_FILTER_VALIDATION: string | undefined; DISABLE_METADATA_API_REQUESTS: string | undefined; + USE_HEADED_BROWSER: string | undefined; } declare global { From be7177cbf416ccf6833291cad1204c328c3c5738 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Thu, 2 Oct 2025 12:41:55 -0700 Subject: [PATCH 03/13] Remove scripts --- src/tools/views/embed.html.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tools/views/embed.html.ts b/src/tools/views/embed.html.ts index a31c4b01..e329ae25 100644 --- a/src/tools/views/embed.html.ts +++ b/src/tools/views/embed.html.ts @@ -1,8 +1,6 @@ export const embedHtml = String.raw` - - @@ -40,7 +45,7 @@ try { return JSON.stringify(error) ?? 'undefined'; } catch { - return ${'`${error}`'}; + return `${error}`; } } @@ -50,7 +55,7 @@ const parsedUrl = new URL(url); (async () => { - const { TableauEventType } = await import(${'`${parsedUrl.origin}'}/javascripts/api/tableau.embedding.3.latest.js${'`'}); + const { TableauEventType } = await import(`${parsedUrl.origin}/javascripts/api/tableau.embedding.3.latest.js`); viz.token = token; viz.src = parsedUrl.toString(); @@ -79,4 +84,4 @@ -`; + \ No newline at end of file diff --git a/src/server/ui/views/routes.ts b/src/server/ui/views/routes.ts new file mode 100644 index 00000000..0e9217d8 --- /dev/null +++ b/src/server/ui/views/routes.ts @@ -0,0 +1,11 @@ +import express from 'express'; +import path from 'path'; + +import { getDirname } from '../../../utils/getDirname'; + +export function setupUiRoutes(app: express.Application): void { + app.get('/embed', (_, res) => { + res.set('Content-Type', 'text/html'); + res.sendFile(path.join(getDirname(), 'server/ui/views/embed/index.html')); + }); +} diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index 66771584..daea469a 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -120,8 +120,8 @@ export const getGetViewDataTool = (server: Server): Tool => ]), }); - // TODO: https - const embedUrl = `http://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; + const protocol = config.sslCert ? 'https' : 'http'; + const embedUrl = `${protocol}://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; const result = await BrowserController.create({ headless: !config.useHeadedBrowser }) .then((b) => b.createNewPage({ width: 800, height: 600 })) diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 02bd3bd7..201fa136 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -123,8 +123,8 @@ export const getGetViewImageTool = (server: Server): Tool = ]), }); - // TODO: https - const embedUrl = `http://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; + const protocol = config.sslCert ? 'https' : 'http'; + const embedUrl = `${protocol}://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; const result = await BrowserController.create({ headless: !config.useHeadedBrowser }) .then((b) => b.createNewPage(rendererOptions)) From 478fe43fa817f3fe63d7f8ac2b73296fdb986d41 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Fri, 9 Jan 2026 16:36:38 -0800 Subject: [PATCH 08/13] Add BrowserController.use --- src/server/ui/views/embed/index.html | 2 +- src/tools/views/browserController.ts | 33 +++++++---- src/tools/views/getViewData.ts | 89 +++++++++++++++------------- src/tools/views/getViewImage.ts | 19 +++--- tsconfig.json | 1 + 5 files changed, 83 insertions(+), 61 deletions(-) diff --git a/src/server/ui/views/embed/index.html b/src/server/ui/views/embed/index.html index 30f0a129..a8e1495d 100644 --- a/src/server/ui/views/embed/index.html +++ b/src/server/ui/views/embed/index.html @@ -63,7 +63,7 @@ try { await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject('firstinteractive event did not fire within 30 seconds'), 30000); + const timeout = setTimeout(() => reject(JSON.stringify({ status: 'error', error: 'firstinteractive event did not fire within 30 seconds' })), 30000); viz.addEventListener(TableauEventType.FirstInteractive, () => { showSuccess('Viz is interactive!'); diff --git a/src/tools/views/browserController.ts b/src/tools/views/browserController.ts index 35d632d2..88d519e3 100644 --- a/src/tools/views/browserController.ts +++ b/src/tools/views/browserController.ts @@ -1,8 +1,8 @@ import { randomUUID } from 'crypto'; import fs from 'fs'; import path from 'path'; -import { Browser, BrowserContext, CDPSession, Page } from 'puppeteer'; -import puppeteer, { ScreenshotOptions } from 'puppeteer-core'; +import puppeteer, { Browser, BrowserContext, CDPSession, Page } from 'puppeteer'; +import { ScreenshotOptions } from 'puppeteer-core'; import { Err, Ok, Result } from 'ts-results-es'; import { getDirname } from '../../utils/getDirname'; @@ -47,8 +47,20 @@ export class BrowserController { private constructor() {} - static async create({ headless = true }: { headless?: boolean } = {}): Promise< - Pick + static async use( + options: { headless?: boolean } = {}, + callback: (controller: Pick) => Promise, + ): Promise { + const browserController = await BrowserController.create(options); + try { + return await callback(browserController); + } finally { + browserController.close(); + } + } + + private static async create({ headless = true }: { headless?: boolean } = {}): Promise< + Pick > { return Promise.resolve(new BrowserController()) .then((renderer) => renderer._createBrowser({ headless })) @@ -269,15 +281,14 @@ export class BrowserController { return Err(this._error); } - try { - await this._page?.close(); - await this._browserContext?.close(); - } catch { - // ignore - } - return Ok(this); } + + close(): void { + this._page?.close().catch(() => {}); + this._browserContext?.close().catch(() => {}); + this._browser?.close().catch(() => {}); + } } function isPageLoadedAndStable(): diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index daea469a..05e8da7c 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -123,54 +123,59 @@ export const getGetViewDataTool = (server: Server): Tool => const protocol = config.sslCert ? 'https' : 'http'; const embedUrl = `${protocol}://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; - const result = await BrowserController.create({ headless: !config.useHeadedBrowser }) - .then((b) => b.createNewPage({ width: 800, height: 600 })) - .then((b) => b.enableDownloads()) - .then((b) => b.navigate(embedUrl)) - .then((b) => b.waitForPageLoad()) - .then((b) => b.takeScreenshot()) - .then((b) => b.getResult()); - - if (result.isErr()) { - return result; - } + return await BrowserController.use( + { headless: !config.useHeadedBrowser }, + async (controller) => { + const result = await controller + .createNewPage({ width: 800, height: 600 }) + .then((b) => b.enableDownloads()) + .then((b) => b.navigate(embedUrl)) + .then((b) => b.waitForPageLoad()) + .then((b) => b.takeScreenshot()) + .then((b) => b.getResult()); + + if (result.isErr()) { + return result; + } - const browserController = result.value; - await browserController.page.evaluate( - async ({ sheetName, SheetType }) => { - const viz = document.getElementById('viz') as TableauViz; - const activeSheet = viz.workbook.activeSheet; - if (activeSheet.sheetType === SheetType.Worksheet) { - await viz.exportDataAsync(activeSheet.name); - } else if (activeSheet.sheetType === SheetType.Dashboard) { - if (sheetName) { - await viz.exportDataAsync(sheetName); - } else { - for (const worksheet of (activeSheet as Dashboard).worksheets) { - await viz.exportDataAsync(worksheet.name); - } - } - } else { - const containedSheet = (activeSheet as Story).activeStoryPoint.containedSheet; - if (containedSheet && containedSheet.sheetType === SheetType.Worksheet) { - await viz.exportDataAsync(containedSheet.name); - } else if (containedSheet && containedSheet.sheetType === SheetType.Dashboard) { - if (sheetName) { - await viz.exportDataAsync(sheetName); + const browserController = result.value; + await browserController.page.evaluate( + async ({ sheetName, SheetType }) => { + const viz = document.getElementById('viz') as TableauViz; + const activeSheet = viz.workbook.activeSheet; + if (activeSheet.sheetType === SheetType.Worksheet) { + await viz.exportDataAsync(activeSheet.name); + } else if (activeSheet.sheetType === SheetType.Dashboard) { + if (sheetName) { + await viz.exportDataAsync(sheetName); + } else { + for (const worksheet of (activeSheet as Dashboard).worksheets) { + await viz.exportDataAsync(worksheet.name); + } + } } else { - for (const worksheet of (containedSheet as Dashboard).worksheets) { - await viz.exportDataAsync(worksheet.name); + const containedSheet = (activeSheet as Story).activeStoryPoint.containedSheet; + if (containedSheet && containedSheet.sheetType === SheetType.Worksheet) { + await viz.exportDataAsync(containedSheet.name); + } else if (containedSheet && containedSheet.sheetType === SheetType.Dashboard) { + if (sheetName) { + await viz.exportDataAsync(sheetName); + } else { + for (const worksheet of (containedSheet as Dashboard).worksheets) { + await viz.exportDataAsync(worksheet.name); + } + } } } - } - } + }, + { sheetName, SheetType }, + ); + + await browserController.waitForDownloads(); + const fileContents = await browserController.getAndDeleteDownloads(); + return Ok(fileContents); }, - { sheetName, SheetType }, ); - - await browserController.waitForDownloads(); - const fileContents = await browserController.getAndDeleteDownloads(); - return Ok(fileContents); }, constrainSuccessResult: (viewData) => { return { diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 201fa136..8f6d8cec 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -126,13 +126,18 @@ export const getGetViewImageTool = (server: Server): Tool = const protocol = config.sslCert ? 'https' : 'http'; const embedUrl = `${protocol}://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; - const result = await BrowserController.create({ headless: !config.useHeadedBrowser }) - .then((b) => b.createNewPage(rendererOptions)) - .then((b) => b.enableDownloads()) - .then((b) => b.navigate(embedUrl)) - .then((b) => b.waitForPageLoad()) - .then((b) => b.takeScreenshot()) - .then((b) => b.getResult()); + const result = await BrowserController.use( + { headless: !config.useHeadedBrowser }, + (browserController) => { + return browserController + .createNewPage(rendererOptions) + .then((b) => b.enableDownloads()) + .then((b) => b.navigate(embedUrl)) + .then((b) => b.waitForPageLoad()) + .then((b) => b.takeScreenshot()) + .then((b) => b.getResult()); + }, + ); if (result.isErr()) { return result; diff --git a/tsconfig.json b/tsconfig.json index 9d05ac95..925f8729 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2022", + "lib": ["ES2022", "DOM"], "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./build", From 94184323bb2202f35a486a20b9c8cd0ba0bdfed6 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 12 Jan 2026 12:14:49 -0800 Subject: [PATCH 09/13] Write auth cookie manually --- src/sdks/tableau/restApi.ts | 2 +- src/tools/views/browserController.ts | 6 +- src/tools/views/getViewData.ts | 104 +++++++++++++++++++-------- 3 files changed, 81 insertions(+), 31 deletions(-) diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index 152baceb..e952c06a 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -68,7 +68,7 @@ export class RestApi { this._responseInterceptor = options.responseInterceptor; } - private get creds(): Credentials { + get creds(): Credentials { if (!this._creds) { throw new Error('No credentials found. Authenticate by calling signIn() first.'); } diff --git a/src/tools/views/browserController.ts b/src/tools/views/browserController.ts index 88d519e3..f31e1f75 100644 --- a/src/tools/views/browserController.ts +++ b/src/tools/views/browserController.ts @@ -67,7 +67,7 @@ export class BrowserController { .then((renderer) => renderer._createBrowserContext()); } - private get browser(): Browser { + get browser(): Browser { if (!this._browser) { throw new Error('Browser not created'); } @@ -191,7 +191,9 @@ export class BrowserController { return this; } - async navigate(url: string): Promise> { + async navigate( + url: string, + ): Promise> { if (this._error) { return this; } diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index 05e8da7c..f8f0af00 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -4,6 +4,7 @@ import { Err, Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../config.js'; +import { useRestApi } from '../../restApiInstance.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; @@ -37,7 +38,10 @@ export const getGetViewDataTool = (server: Server): Tool => readOnlyHint: true, openWorldHint: false, }, - callback: async ({ url, sheetName }, { requestId, authInfo }): Promise => { + callback: async ( + { url, sheetName }, + { requestId, authInfo, signal }, + ): Promise => { const config = getConfig(); return await getViewDataTool.logAndExecute< @@ -48,6 +52,7 @@ export const getGetViewDataTool = (server: Server): Tool => authInfo, args: { url, sheetName }, callback: async () => { + const tableauAuthInfo = getTableauAuthInfo(authInfo); // const isViewAllowedResult = await resourceAccessChecker.isViewAllowed({ // viewId, // restApiArgs: { config, requestId, server }, @@ -91,34 +96,37 @@ export const getGetViewDataTool = (server: Server): Tool => }); } - const token = - parsedUrl.host === 'public.tableau.com' - ? '' - : await getJwt({ - username: getJwtUsername(config.jwtUsername, [ - { - pattern: '{OAUTH_USERNAME}', - replacement: getTableauAuthInfo(authInfo)?.username ?? '', + let token = ''; + if (config.auth === 'oauth') { + token = + parsedUrl.host === 'public.tableau.com' + ? '' + : await getJwt({ + username: getJwtUsername(config.jwtUsername, [ + { + pattern: '{OAUTH_USERNAME}', + replacement: getTableauAuthInfo(authInfo)?.username ?? '', + }, + ]), + config: { + type: 'connected-app', + clientId: config.connectedAppClientId, + secretId: config.connectedAppSecretId, + secretValue: config.connectedAppSecretValue, }, - ]), - config: { - type: 'connected-app', - clientId: config.connectedAppClientId, - secretId: config.connectedAppSecretId, - secretValue: config.connectedAppSecretValue, - }, - scopes: new Set([ - 'tableau:views:embed', - 'tableau:views:embed_authoring', - 'tableau:insights:embed', - ]), - additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, [ - { - pattern: '{OAUTH_USERNAME}', - replacement: getTableauAuthInfo(authInfo)?.username ?? '', - }, - ]), - }); + scopes: new Set([ + 'tableau:views:embed', + 'tableau:views:embed_authoring', + 'tableau:insights:embed', + ]), + additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, [ + { + pattern: '{OAUTH_USERNAME}', + replacement: getTableauAuthInfo(authInfo)?.username ?? '', + }, + ]), + }); + } const protocol = config.sslCert ? 'https' : 'http'; const embedUrl = `${protocol}://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; @@ -130,6 +138,46 @@ export const getGetViewDataTool = (server: Server): Tool => .createNewPage({ width: 800, height: 600 }) .then((b) => b.enableDownloads()) .then((b) => b.navigate(embedUrl)) + .then(async (b) => { + let accessToken = ''; + let domain = ''; + + switch (config.auth) { + case 'direct-trust': + case 'uat': + return b; + case 'pat': + accessToken = await useRestApi({ + config, + requestId, + server, + jwtScopes: [], + signal, + callback: async (restApi) => restApi.creds.token, + }); + domain = new URL(config.server).hostname; + break; + case 'oauth': + accessToken = tableauAuthInfo?.accessToken ?? ''; + domain = tableauAuthInfo?.server ?? ''; + break; + } + + const tableauFrame = b.page.frames()[1]; + await tableauFrame.evaluate(() => { + document.cookie = [ + `workgroup_session_id=${accessToken}`, + `domain=${domain}`, + 'path=/', + 'secure', + 'samesite=none', + 'partitioned', + ].join('; '); + }); + + await b.page.reload(); + return b; + }) .then((b) => b.waitForPageLoad()) .then((b) => b.takeScreenshot()) .then((b) => b.getResult()); From 42bbb499c8410e0ccc7454daac75c85e56d833dc Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 12 Jan 2026 14:09:44 -0800 Subject: [PATCH 10/13] Clean up --- src/restApiInstance.ts | 33 +++++---- src/tools/resourceAccessChecker.ts | 14 +--- src/tools/tool.ts | 10 ++- src/tools/views/browserController.ts | 58 ++++++++++++++- src/tools/views/getViewData.ts | 106 ++++++++------------------- src/tools/views/getViewImage.ts | 31 +------- src/utils/getJwt.ts | 26 ------- src/utils/getTableauAccessTokens.ts | 82 +++++++++++++++++++++ 8 files changed, 201 insertions(+), 159 deletions(-) create mode 100644 src/utils/getTableauAccessTokens.ts diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 6863e54a..4d4979ad 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -18,7 +18,7 @@ import { Server, userAgent } from './server.js'; import { TableauAuthInfo } from './server/oauth/schemas.js'; import { isAxiosError } from './utils/axios.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; -import { getJwtAdditionalPayload, getJwtUsername } from './utils/getJwt.js'; +import { getJwtAdditionalPayload, getJwtUsername } from './utils/getTableauAccessTokens.js'; import invariant from './utils/invariant.js'; type JwtScopes = @@ -31,6 +31,13 @@ type JwtScopes = | 'tableau:views:download' | 'tableau:insight_brief:create'; +export type RestApiArgs = { + config: Config; + requestId: RequestId; + server: Server; + signal: AbortSignal; +}; + const getNewRestApiInstanceAsync = async ( config: Config, requestId: RequestId, @@ -82,33 +89,25 @@ const getNewRestApiInstanceAsync = async ( await restApi.signIn({ type: 'direct-trust', siteName: config.siteName, - username: getJwtUsername(config.jwtUsername, [ - { pattern: '{OAUTH_USERNAME}', replacement: authInfo?.username ?? '' }, - ]), + username: getJwtUsername(config.jwtUsername, authInfo), clientId: config.connectedAppClientId, secretId: config.connectedAppSecretId, secretValue: config.connectedAppSecretValue, scopes: jwtScopes, - additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, [ - { pattern: '{OAUTH_USERNAME}', replacement: authInfo?.username ?? '' }, - ]), + additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, authInfo), }); } else if (config.auth === 'uat') { await restApi.signIn({ type: 'uat', siteName: config.siteName, - username: getJwtUsername(config.jwtUsername, [ - { pattern: '{OAUTH_USERNAME}', replacement: authInfo?.username ?? '' }, - ]), + username: getJwtUsername(config.jwtUsername, authInfo), tenantId: config.uatTenantId, issuer: config.uatIssuer, usernameClaimName: config.uatUsernameClaimName, privateKey: config.uatPrivateKey, keyId: config.uatKeyId, scopes: jwtScopes, - additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, [ - { pattern: '{OAUTH_USERNAME}', replacement: authInfo?.username ?? '' }, - ]), + additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, authInfo), }); } else { if (!authInfo?.accessToken || !authInfo?.userId) { @@ -129,6 +128,7 @@ export const useRestApi = async ({ jwtScopes, signal, authInfo, + options, }: { config: Config; requestId: RequestId; @@ -137,7 +137,12 @@ export const useRestApi = async ({ signal: AbortSignal; callback: (restApi: RestApi) => Promise; authInfo?: TableauAuthInfo; + options?: Partial<{ + bypassSignOut: boolean; + }>; }): Promise => { + const { bypassSignOut = false } = options ?? {}; + const restApi = await getNewRestApiInstanceAsync( config, requestId, @@ -149,7 +154,7 @@ export const useRestApi = async ({ try { return await callback(restApi); } finally { - if (config.auth !== 'oauth') { + if (config.auth !== 'oauth' && !bypassSignOut) { // Tableau REST sessions for 'pat' and 'direct-trust' are intentionally ephemeral. // Sessions for 'oauth' are not. Signing out would invalidate the session, // preventing the access token from being reused for subsequent requests. diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts index f94d7086..3a72c90a 100644 --- a/src/tools/resourceAccessChecker.ts +++ b/src/tools/resourceAccessChecker.ts @@ -1,22 +1,12 @@ -import { RequestId } from '@modelcontextprotocol/sdk/types.js'; - -import { BoundedContext, Config, getConfig } from '../config.js'; -import { useRestApi } from '../restApiInstance.js'; +import { BoundedContext, getConfig } from '../config.js'; +import { RestApiArgs, useRestApi } from '../restApiInstance.js'; import { Workbook } from '../sdks/tableau/types/workbook.js'; -import { Server } from '../server.js'; import { getExceptionMessage } from '../utils/getExceptionMessage.js'; type AllowedResult = | { allowed: true; content?: T } | { allowed: false; message: string }; -export type RestApiArgs = { - config: Config; - requestId: RequestId; - server: Server; - signal: AbortSignal; -}; - class ResourceAccessChecker { private _allowedProjectIds: Set | null | undefined; private _allowedDatasourceIds: Set | null | undefined; diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 3984eb9d..675a2d1d 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -59,6 +59,9 @@ export type ToolParams = { callback: TypeOrProvider>; }; +// Any cleanup actions to be performed after tool is executed +export type CleanupActions = Array<() => Promise>; + /** * The parameters the logAndExecute method * @@ -77,7 +80,7 @@ type LogAndExecuteParams args: Args extends ZodRawShape ? z.objectOutputType : undefined; // A function that contains the business logic of the tool to be logged and executed - callback: () => Promise>; + callback: (context: { cleanupActions: CleanupActions }) => Promise>; // A function that can transform a successful result of the callback into a CallToolResult getSuccessResult?: (result: T) => CallToolResult; @@ -181,8 +184,9 @@ export class Tool { } } + const cleanupActions: CleanupActions = []; try { - const result = await callback(); + const result = await callback({ cleanupActions }); if (result.isOk()) { const constrainedResult = await constrainSuccessResult(result.value); @@ -228,6 +232,8 @@ export class Tool { } } catch (error) { return getErrorResult(requestId, error); + } finally { + await Promise.allSettled(cleanupActions.map((cleanupAction) => cleanupAction())); } } } diff --git a/src/tools/views/browserController.ts b/src/tools/views/browserController.ts index f31e1f75..b332a20b 100644 --- a/src/tools/views/browserController.ts +++ b/src/tools/views/browserController.ts @@ -6,6 +6,7 @@ import { ScreenshotOptions } from 'puppeteer-core'; import { Err, Ok, Result } from 'ts-results-es'; import { getDirname } from '../../utils/getDirname'; +import { getExceptionMessage } from '../../utils/getExceptionMessage'; export type BrowserOptions = { width?: number; @@ -19,6 +20,7 @@ const browserControllerErrors = [ 'enable-downloads-failed', 'download-failed', 'navigation-failed', + 'tableau-frame-not-found', 'page-failed-to-load', 'viz-load-error', 'screenshot-failed', @@ -193,7 +195,7 @@ export class BrowserController { async navigate( url: string, - ): Promise> { + ): Promise> { if (this._error) { return this; } @@ -207,6 +209,51 @@ export class BrowserController { return this; } + async setWorkgroupSessionId({ + workgroupSessionId, + domain, + }: { + workgroupSessionId: string; + domain: string; + }): Promise> { + if (this._error) { + return this; + } + + const tableauFrame = this.page.frames().find((frame) => frame !== this.page.mainFrame()); + if (!tableauFrame) { + this._error = { + type: 'tableau-frame-not-found', + error: new Error( + `Could not find the Tableau iframe from the page's frames: ${this.page + .frames() + .map((frame) => frame.url()) + .join(', ')}`, + ), + }; + + return this; + } + + await tableauFrame.evaluate( + (workgroupSessionId, domain) => { + document.cookie = [ + `workgroup_session_id=${workgroupSessionId}`, + `domain=${domain}`, + 'path=/', + 'secure', + 'samesite=none', + 'partitioned', + ].join('; '); + }, + workgroupSessionId, + domain, + ); + + await this.page.reload(); + return this; + } + async waitForPageLoad(): Promise> { if (this._error) { return this; @@ -319,8 +366,11 @@ function isPageLoadedAndStable(): return false; } -export function getBrowserControllerErrorMessage(error: BrowserControllerErrorType): string { - switch (error) { +export function getBrowserControllerErrorMessage( + type: BrowserControllerErrorType, + error?: unknown, +): string { + switch (type) { case 'browser-creation-failed': return 'Failed to create browser.'; case 'browser-context-creation-failed': @@ -333,6 +383,8 @@ export function getBrowserControllerErrorMessage(error: BrowserControllerErrorTy return 'Failed to download files.'; case 'navigation-failed': return 'Failed to navigate to the page.'; + case 'tableau-frame-not-found': + return getExceptionMessage(error); case 'page-failed-to-load': return 'Failed to load the page.'; case 'viz-load-error': diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index f8f0af00..9984239b 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -4,11 +4,9 @@ import { Err, Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../config.js'; -import { useRestApi } from '../../restApiInstance.js'; import { Server } from '../../server.js'; -import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; -import { getJwt, getJwtAdditionalPayload, getJwtUsername } from '../../utils/getJwt.js'; +import { getEmbeddingJwt, getWorkgroupSessionId } from '../../utils/getTableauAccessTokens.js'; import { Tool } from '../tool.js'; import { BrowserController, @@ -19,7 +17,11 @@ import { type GetViewDataError = | BrowserControllerError - | { type: 'invalid-url' | 'embedding-api-not-found'; url: string; error: unknown }; + | { + type: 'invalid-url' | 'embedding-api-not-found' | 'tableau-frame-not-found'; + url: string; + error: unknown; + }; const paramsSchema = { url: z.string(), @@ -51,8 +53,7 @@ export const getGetViewDataTool = (server: Server): Tool => requestId, authInfo, args: { url, sheetName }, - callback: async () => { - const tableauAuthInfo = getTableauAuthInfo(authInfo); + callback: async ({ cleanupActions }) => { // const isViewAllowedResult = await resourceAccessChecker.isViewAllowed({ // viewId, // restApiArgs: { config, requestId, server }, @@ -96,37 +97,10 @@ export const getGetViewDataTool = (server: Server): Tool => }); } - let token = ''; - if (config.auth === 'oauth') { - token = - parsedUrl.host === 'public.tableau.com' - ? '' - : await getJwt({ - username: getJwtUsername(config.jwtUsername, [ - { - pattern: '{OAUTH_USERNAME}', - replacement: getTableauAuthInfo(authInfo)?.username ?? '', - }, - ]), - config: { - type: 'connected-app', - clientId: config.connectedAppClientId, - secretId: config.connectedAppSecretId, - secretValue: config.connectedAppSecretValue, - }, - scopes: new Set([ - 'tableau:views:embed', - 'tableau:views:embed_authoring', - 'tableau:insights:embed', - ]), - additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, [ - { - pattern: '{OAUTH_USERNAME}', - replacement: getTableauAuthInfo(authInfo)?.username ?? '', - }, - ]), - }); - } + const token = + config.auth !== 'oauth' || parsedUrl.host === 'public.tableau.com' + ? '' + : await getEmbeddingJwt({ config, authInfo }); const protocol = config.sslCert ? 'https' : 'http'; const embedUrl = `${protocol}://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; @@ -139,44 +113,27 @@ export const getGetViewDataTool = (server: Server): Tool => .then((b) => b.enableDownloads()) .then((b) => b.navigate(embedUrl)) .then(async (b) => { - let accessToken = ''; - let domain = ''; + if (parsedUrl.host === 'public.tableau.com') { + // No auth for Public + return b; + } - switch (config.auth) { - case 'direct-trust': - case 'uat': - return b; - case 'pat': - accessToken = await useRestApi({ - config, - requestId, - server, - jwtScopes: [], - signal, - callback: async (restApi) => restApi.creds.token, - }); - domain = new URL(config.server).hostname; - break; - case 'oauth': - accessToken = tableauAuthInfo?.accessToken ?? ''; - domain = tableauAuthInfo?.server ?? ''; - break; + if (config.auth === 'direct-trust' || config.auth === 'uat') { + // For Direct Trust and UAT, the JWT will be provided to the /embed endpoint. + // The Embedding API will use the JWT to authenticate. + return b; } - const tableauFrame = b.page.frames()[1]; - await tableauFrame.evaluate(() => { - document.cookie = [ - `workgroup_session_id=${accessToken}`, - `domain=${domain}`, - 'path=/', - 'secure', - 'samesite=none', - 'partitioned', - ].join('; '); - }); + // For PAT and OAuth, we need to set the workgroup_session_id cookie ourselves. + const { workgroupSessionId, domain } = await getWorkgroupSessionId( + config.auth, + config, + authInfo, + { config, requestId, server, signal }, + cleanupActions, + ); - await b.page.reload(); - return b; + return await b.setWorkgroupSessionId({ workgroupSessionId, domain }); }) .then((b) => b.waitForPageLoad()) .then((b) => b.takeScreenshot()) @@ -188,7 +145,7 @@ export const getGetViewDataTool = (server: Server): Tool => const browserController = result.value; await browserController.page.evaluate( - async ({ sheetName, SheetType }) => { + async (sheetName, SheetType) => { const viz = document.getElementById('viz') as TableauViz; const activeSheet = viz.workbook.activeSheet; if (activeSheet.sheetType === SheetType.Worksheet) { @@ -216,7 +173,8 @@ export const getGetViewDataTool = (server: Server): Tool => } } }, - { sheetName, SheetType }, + sheetName, + SheetType, ); await browserController.waitForDownloads(); @@ -246,7 +204,7 @@ export const getGetViewDataTool = (server: Server): Tool => function getErrorMessage(error: GetViewDataError): string { if (isBrowserControllerErrorType(error.type)) { - return getBrowserControllerErrorMessage(error.type); + return getBrowserControllerErrorMessage(error.type, error.error); } switch (error.type) { diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 8f6d8cec..8ac6fa29 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -4,9 +4,8 @@ import { z } from 'zod'; import { getConfig } from '../../config.js'; import { Server } from '../../server.js'; -import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; -import { getJwt, getJwtAdditionalPayload, getJwtUsername } from '../../utils/getJwt.js'; +import { getEmbeddingJwt } from '../../utils/getTableauAccessTokens.js'; import { Tool } from '../tool.js'; import { BrowserController, @@ -95,33 +94,9 @@ export const getGetViewImageTool = (server: Server): Tool = }; const token = - parsedUrl.host === 'public.tableau.com' + config.auth !== 'oauth' || parsedUrl.host === 'public.tableau.com' ? '' - : await getJwt({ - username: getJwtUsername(config.jwtUsername, [ - { - pattern: '{OAUTH_USERNAME}', - replacement: getTableauAuthInfo(authInfo)?.username ?? '', - }, - ]), - config: { - type: 'connected-app', - clientId: config.connectedAppClientId, - secretId: config.connectedAppSecretId, - secretValue: config.connectedAppSecretValue, - }, - scopes: new Set([ - 'tableau:views:embed', - 'tableau:views:embed_authoring', - 'tableau:insights:embed', - ]), - additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, [ - { - pattern: '{OAUTH_USERNAME}', - replacement: getTableauAuthInfo(authInfo)?.username ?? '', - }, - ]), - }); + : await getEmbeddingJwt({ config, authInfo }); const protocol = config.sslCert ? 'https' : 'http'; const embedUrl = `${protocol}://localhost:${config.httpPort}/embed#?url=${url}&token=${token}`; diff --git a/src/utils/getJwt.ts b/src/utils/getJwt.ts index 8f322159..02e108aa 100644 --- a/src/utils/getJwt.ts +++ b/src/utils/getJwt.ts @@ -69,29 +69,3 @@ export async function getJwt({ return await new SignJWT(payload).setProtectedHeader(header).sign(privateKey); } } - -export function getJwtUsername( - username: string, - replacers?: Array<{ pattern: string; replacement: string }>, -): string { - if (replacers) { - for (const replacer of replacers) { - username = username.replace(replacer.pattern, replacer.replacement); - } - } - - return username; -} - -export function getJwtAdditionalPayload( - payload: string, - replacers?: Array<{ pattern: string; replacement: string }>, -): Record { - if (replacers) { - for (const replacer of replacers) { - payload = payload.replace(replacer.pattern, replacer.replacement); - } - } - - return JSON.parse(payload || '{}'); -} diff --git a/src/utils/getTableauAccessTokens.ts b/src/utils/getTableauAccessTokens.ts new file mode 100644 index 00000000..e99dfd26 --- /dev/null +++ b/src/utils/getTableauAccessTokens.ts @@ -0,0 +1,82 @@ +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; + +import { Config } from '../config'; +import { RestApiArgs, useRestApi } from '../restApiInstance'; +import { getTableauAuthInfo } from '../server/oauth/getTableauAuthInfo'; +import { TableauAuthInfo } from '../server/oauth/schemas'; +import { CleanupActions } from '../tools/tool'; +import { getJwt } from './getJwt'; + +export async function getWorkgroupSessionId( + auth: 'oauth' | 'pat', + config: Config, + authInfo: AuthInfo | undefined, + restApiArgs: RestApiArgs, + cleanupActions: CleanupActions, +): Promise<{ workgroupSessionId: string; domain: string }> { + switch (auth) { + case 'oauth': { + const tableauAuthInfo = getTableauAuthInfo(authInfo); + return { + workgroupSessionId: tableauAuthInfo?.accessToken ?? '', + domain: tableauAuthInfo?.server ?? '', + }; + } + case 'pat': { + const workgroupSessionId = await useRestApi({ + config, + requestId: restApiArgs.requestId, + server: restApiArgs.server, + jwtScopes: [], + signal: restApiArgs.signal, + options: { bypassSignOut: true }, + callback: async (restApi) => { + cleanupActions.push(restApi.signOut); + return restApi.creds.token; + }, + }); + + const domain = new URL(config.server).hostname; + return { workgroupSessionId, domain }; + } + } +} + +export async function getEmbeddingJwt({ + config, + authInfo, +}: { + config: Config; + authInfo: AuthInfo | undefined; +}): Promise { + return await getJwt({ + username: getJwtUsername(config.jwtUsername, getTableauAuthInfo(authInfo)), + config: { + type: 'connected-app', + clientId: config.connectedAppClientId, + secretId: config.connectedAppSecretId, + secretValue: config.connectedAppSecretValue, + }, + scopes: new Set([ + 'tableau:views:embed', + 'tableau:views:embed_authoring', + 'tableau:insights:embed', + ]), + additionalPayload: getJwtAdditionalPayload( + config.jwtAdditionalPayload, + getTableauAuthInfo(authInfo), + ), + }); +} + +export function getJwtUsername(username: string, authInfo: TableauAuthInfo | undefined): string { + return username.replace('{OAUTH_USERNAME}', authInfo?.username ?? ''); +} + +export function getJwtAdditionalPayload( + payload: string, + authInfo: TableauAuthInfo | undefined, +): Record { + payload = payload.replace('{OAUTH_USERNAME}', authInfo?.username ?? ''); + return JSON.parse(payload || '{}'); +} From 533ccad7280a795a30beb1588e55ba73e6c3701e Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 12 Jan 2026 15:08:51 -0800 Subject: [PATCH 11/13] Fix token condition --- src/tools/views/getViewData.ts | 4 +++- src/tools/views/getViewImage.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index 9984239b..0e5edeb5 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -98,7 +98,9 @@ export const getGetViewDataTool = (server: Server): Tool => } const token = - config.auth !== 'oauth' || parsedUrl.host === 'public.tableau.com' + config.auth === 'pat' || + config.auth === 'oauth' || + parsedUrl.host === 'public.tableau.com' ? '' : await getEmbeddingJwt({ config, authInfo }); diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 8ac6fa29..4192d3a7 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -94,7 +94,9 @@ export const getGetViewImageTool = (server: Server): Tool = }; const token = - config.auth !== 'oauth' || parsedUrl.host === 'public.tableau.com' + config.auth === 'pat' || + config.auth === 'oauth' || + parsedUrl.host === 'public.tableau.com' ? '' : await getEmbeddingJwt({ config, authInfo }); From f18160b6e62f67cd80bf57213ccde35ac00d5a48 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 12 Jan 2026 16:43:50 -0800 Subject: [PATCH 12/13] Add tool scoping --- src/tools/resourceAccessChecker.ts | 129 ++++++++++++++++++++++----- src/tools/views/browserController.ts | 10 +++ src/tools/views/getViewData.ts | 53 ++++++----- src/tools/views/getViewImage.ts | 64 ++++++++----- 4 files changed, 192 insertions(+), 64 deletions(-) diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts index 3a72c90a..b46d9412 100644 --- a/src/tools/resourceAccessChecker.ts +++ b/src/tools/resourceAccessChecker.ts @@ -1,5 +1,6 @@ import { BoundedContext, getConfig } from '../config.js'; import { RestApiArgs, useRestApi } from '../restApiInstance.js'; +import { View } from '../sdks/tableau/types/view.js'; import { Workbook } from '../sdks/tableau/types/workbook.js'; import { getExceptionMessage } from '../utils/getExceptionMessage.js'; @@ -108,6 +109,7 @@ class ResourceAccessChecker { restApiArgs: RestApiArgs; }): Promise { const result = await this._isViewAllowed({ + type: 'view-id', viewId, restApiArgs, }); @@ -120,6 +122,26 @@ class ResourceAccessChecker { return result; } + async isViewAllowedByUrl({ + url, + restApiArgs, + }: { + url: URL; + restApiArgs: RestApiArgs; + }): Promise { + const { result, viewId } = await this._isViewAllowedByUrl({ + url, + restApiArgs, + }); + + if (viewId && !this.allowedProjectIds) { + // If project filtering is enabled, we cannot cache the result since the workbook containing the view may be moved between projects. + this._cachedViewIds.set(viewId, result); + } + + return result; + } + private async _isDatasourceAllowed({ datasourceLuid, restApiArgs: { config, requestId, server, signal }, @@ -249,13 +271,17 @@ class ResourceAccessChecker { return { allowed: true, content: workbook }; } - private async _isViewAllowed({ - viewId, - restApiArgs: { config, requestId, server, signal }, - }: { - viewId: string; - restApiArgs: RestApiArgs; - }): Promise { + private async _isViewAllowed( + input: ({ type: 'view'; view: View } | { type: 'view-id'; viewId: string }) & { + restApiArgs: RestApiArgs; + }, + ): Promise { + const { + type, + restApiArgs: { config, requestId, server, signal }, + } = input; + + const viewId = type === 'view' ? input.view.id : input.viewId; const cachedResult = this._cachedViewIds.get(viewId); if (cachedResult) { return cachedResult; @@ -266,19 +292,22 @@ class ResourceAccessChecker { if (this.allowedWorkbookIds) { try { - const view = await useRestApi({ - config, - requestId, - server, - jwtScopes: ['tableau:content:read'], - signal, - callback: async (restApi) => { - return await restApi.viewsMethods.getView({ - siteId: restApi.siteId, - viewId, - }); - }, - }); + const view = + type === 'view' + ? input.view + : await useRestApi({ + config, + requestId, + server, + jwtScopes: ['tableau:content:read'], + signal, + callback: async (restApi) => { + return await restApi.viewsMethods.getView({ + siteId: restApi.siteId, + viewId, + }); + }, + }); viewWorkbookId = view.workbook?.id ?? ''; viewProjectId = view.project?.id ?? ''; @@ -306,6 +335,7 @@ class ResourceAccessChecker { if (this.allowedProjectIds) { try { + viewProjectId = type === 'view' ? (input.view.project?.id ?? '') : ''; viewProjectId = viewProjectId || (await useRestApi({ @@ -347,6 +377,65 @@ class ResourceAccessChecker { return { allowed: true }; } + + private async _isViewAllowedByUrl({ + url, + restApiArgs: { config, requestId, server, signal }, + }: { + url: URL; + restApiArgs: RestApiArgs; + }): Promise<{ result: AllowedResult; viewId?: string }> { + if (!this.allowedProjectIds && !this.allowedWorkbookIds) { + return { result: { allowed: true } }; + } + + const pathParts = url.toString().includes('/t/') + ? url.pathname.split('/') + : url.hash.split('?')[0].split('/'); + + const viewsIndex = pathParts.indexOf('views'); + if (viewsIndex === -1) { + return { result: { allowed: false, message: 'Could not identify view in URL' } }; + } + + const workbookContentUrl = pathParts[viewsIndex + 1]; + const sheetContentUrl = pathParts[viewsIndex + 2]; + if (!workbookContentUrl || !sheetContentUrl) { + return { + result: { allowed: false, message: 'Could not identify workbook and sheet name in URL' }, + }; + } + + const view: View | undefined = await useRestApi({ + config, + requestId, + server, + jwtScopes: ['tableau:content:read'], + signal, + callback: async (restApi) => { + const { views } = await restApi.viewsMethods.queryViewsForSite({ + siteId: restApi.siteId, + filter: `contentUrl:eq:${[workbookContentUrl, 'sheets', sheetContentUrl].join('/')}`, + }); + + return views[0]; + }, + }); + + if (!view) { + return { + result: { allowed: false, message: 'Could not find the view for the given workbook URL' }, + }; + } + + const result = await this._isViewAllowed({ + type: 'view', + view, + restApiArgs: { config, requestId, server, signal }, + }); + + return { result, viewId: view.id }; + } } let resourceAccessChecker = ResourceAccessChecker.create(); diff --git a/src/tools/views/browserController.ts b/src/tools/views/browserController.ts index b332a20b..6a160370 100644 --- a/src/tools/views/browserController.ts +++ b/src/tools/views/browserController.ts @@ -33,6 +33,16 @@ export function isBrowserControllerErrorType(value: unknown): value is BrowserCo return browserControllerErrors.find((error) => error === value) !== undefined; } +export function isBrowserControllerError(value: unknown): value is BrowserControllerError { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + isBrowserControllerErrorType(value.type) && + 'error' in value + ); +} + export type BrowserControllerError = { type: BrowserControllerErrorType; error: unknown; diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index 0e5edeb5..f2f3f01e 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -7,20 +7,30 @@ import { getConfig } from '../../config.js'; import { Server } from '../../server.js'; import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; import { getEmbeddingJwt, getWorkgroupSessionId } from '../../utils/getTableauAccessTokens.js'; +import { parseUrl } from '../../utils/parseUrl.js'; +import { resourceAccessChecker } from '../resourceAccessChecker.js'; import { Tool } from '../tool.js'; import { BrowserController, BrowserControllerError, getBrowserControllerErrorMessage, - isBrowserControllerErrorType, + isBrowserControllerError, } from './browserController.js'; type GetViewDataError = | BrowserControllerError | { - type: 'invalid-url' | 'embedding-api-not-found' | 'tableau-frame-not-found'; + type: 'embedding-api-not-found'; url: string; error: unknown; + } + | { + type: 'invalid-url'; + url: string; + } + | { + type: 'view-not-allowed'; + message: string; }; const paramsSchema = { @@ -54,26 +64,23 @@ export const getGetViewDataTool = (server: Server): Tool => authInfo, args: { url, sheetName }, callback: async ({ cleanupActions }) => { - // const isViewAllowedResult = await resourceAccessChecker.isViewAllowed({ - // viewId, - // restApiArgs: { config, requestId, server }, - // }); - - // if (!isViewAllowedResult.allowed) { - // return new Err({ - // type: 'view-not-allowed', - // message: isViewAllowedResult.message, - // }); - // } - - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch (error) { + const parsedUrl = parseUrl(url); + if (!parsedUrl) { return Err({ type: 'invalid-url', url, - error, + }); + } + + const isViewAllowedResult = await resourceAccessChecker.isViewAllowedByUrl({ + url: parsedUrl, + restApiArgs: { config, requestId, server, signal }, + }); + + if (!isViewAllowedResult.allowed) { + return new Err({ + type: 'view-not-allowed', + message: isViewAllowedResult.message, }); } @@ -194,7 +201,9 @@ export const getGetViewDataTool = (server: Server): Tool => getErrorText: (error: GetViewDataError) => { return JSON.stringify({ reason: getErrorMessage(error), - exception: getExceptionMessage(error.error), + exception: isBrowserControllerError(error) + ? getExceptionMessage(error.error) + : undefined, }); }, }); @@ -205,7 +214,7 @@ export const getGetViewDataTool = (server: Server): Tool => }; function getErrorMessage(error: GetViewDataError): string { - if (isBrowserControllerErrorType(error.type)) { + if (isBrowserControllerError(error)) { return getBrowserControllerErrorMessage(error.type, error.error); } @@ -214,5 +223,7 @@ function getErrorMessage(error: GetViewDataError): string { return `The URL is invalid: ${error.url}`; case 'embedding-api-not-found': return `The Embedding API JavaScript module was not found at ${error.url}.`; + case 'view-not-allowed': + return error.message; } } diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 4192d3a7..ab9464ab 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -6,18 +6,32 @@ import { getConfig } from '../../config.js'; import { Server } from '../../server.js'; import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; import { getEmbeddingJwt } from '../../utils/getTableauAccessTokens.js'; +import { parseUrl } from '../../utils/parseUrl.js'; +import { resourceAccessChecker } from '../resourceAccessChecker.js'; import { Tool } from '../tool.js'; import { BrowserController, BrowserControllerError, BrowserOptions, getBrowserControllerErrorMessage, - isBrowserControllerErrorType, + isBrowserControllerError, } from './browserController.js'; type GetViewImageError = | BrowserControllerError - | { type: 'invalid-url' | 'embedding-api-not-found'; url: string; error: unknown }; + | { + type: 'embedding-api-not-found'; + url: string; + error: unknown; + } + | { + type: 'invalid-url'; + url: string; + } + | { + type: 'view-not-allowed'; + message: string; + }; const paramsSchema = { url: z.string(), @@ -37,7 +51,10 @@ export const getGetViewImageTool = (server: Server): Tool = readOnlyHint: true, openWorldHint: false, }, - callback: async ({ url, width, height }, { requestId, authInfo }): Promise => { + callback: async ( + { url, width, height }, + { requestId, authInfo, signal }, + ): Promise => { const config = getConfig(); return await getViewImageTool.logAndExecute({ @@ -45,26 +62,23 @@ export const getGetViewImageTool = (server: Server): Tool = authInfo, args: { url }, callback: async () => { - // const isViewAllowedResult = await resourceAccessChecker.isViewAllowed({ - // viewId, - // restApiArgs: { config, requestId, server }, - // }); - - // if (!isViewAllowedResult.allowed) { - // return new Err({ - // type: 'view-not-allowed', - // message: isViewAllowedResult.message, - // }); - // } - - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch (error) { + const parsedUrl = parseUrl(url); + if (!parsedUrl) { return Err({ type: 'invalid-url', url, - error, + }); + } + + const isViewAllowedResult = await resourceAccessChecker.isViewAllowedByUrl({ + url: parsedUrl, + restApiArgs: { config, requestId, server, signal }, + }); + + if (!isViewAllowedResult.allowed) { + return new Err({ + type: 'view-not-allowed', + message: isViewAllowedResult.message, }); } @@ -145,7 +159,9 @@ export const getGetViewImageTool = (server: Server): Tool = getErrorText: (error: GetViewImageError) => { return JSON.stringify({ reason: getErrorMessage(error), - exception: getExceptionMessage(error.error), + exception: isBrowserControllerError(error) + ? getExceptionMessage(error.error) + : undefined, }); }, }); @@ -156,8 +172,8 @@ export const getGetViewImageTool = (server: Server): Tool = }; function getErrorMessage(error: GetViewImageError): string { - if (isBrowserControllerErrorType(error.type)) { - return getBrowserControllerErrorMessage(error.type); + if (isBrowserControllerError(error)) { + return getBrowserControllerErrorMessage(error.type, error.error); } switch (error.type) { @@ -165,5 +181,7 @@ function getErrorMessage(error: GetViewImageError): string { return `The URL is invalid: ${error.url}`; case 'embedding-api-not-found': return `The Embedding API JavaScript module was not found at ${error.url}.`; + case 'view-not-allowed': + return error.message; } } From c491c34c5b5008fa9e8ae397219b6cf8a6a73338 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 12 Jan 2026 17:09:10 -0800 Subject: [PATCH 13/13] Move error handling to view --- src/server/ui/views/embed/index.html | 10 +++++- src/tools/views/getViewData.ts | 20 ------------ src/tools/views/getViewImage.ts | 47 +++++++++++++++------------- 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/server/ui/views/embed/index.html b/src/server/ui/views/embed/index.html index a8e1495d..4633d56e 100644 --- a/src/server/ui/views/embed/index.html +++ b/src/server/ui/views/embed/index.html @@ -55,7 +55,15 @@ const parsedUrl = new URL(url); (async () => { - const { TableauEventType } = await import(`${parsedUrl.origin}/javascripts/api/tableau.embedding.3.latest.js`); + let module; + try { + module = await import(`${parsedUrl.origin}/javascripts/api/tableau.embedding.3.latest.js`); + } catch (e) { + showError(getExceptionMessage(e)); + return; + } + + const { TableauEventType } = module; viz.token = token; viz.src = parsedUrl.toString(); diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index f2f3f01e..f7b789af 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -84,26 +84,6 @@ export const getGetViewDataTool = (server: Server): Tool => }); } - const embeddingApiUrl = `${parsedUrl.origin}/javascripts/api/tableau.embedding.3.latest.js`; - try { - const response = await fetch(embeddingApiUrl); - if (!response.ok) { - return Err({ - type: 'embedding-api-not-found', - url: embeddingApiUrl, - error: new Error( - `Failed to fetch embedding API JavaScript module: ${response.status} ${response.statusText}`, - ), - }); - } - } catch (error) { - return Err({ - type: 'embedding-api-not-found', - url: embeddingApiUrl, - error, - }); - } - const token = config.auth === 'pat' || config.auth === 'oauth' || diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index ab9464ab..973b6220 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { getConfig } from '../../config.js'; import { Server } from '../../server.js'; import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; -import { getEmbeddingJwt } from '../../utils/getTableauAccessTokens.js'; +import { getEmbeddingJwt, getWorkgroupSessionId } from '../../utils/getTableauAccessTokens.js'; import { parseUrl } from '../../utils/parseUrl.js'; import { resourceAccessChecker } from '../resourceAccessChecker.js'; import { Tool } from '../tool.js'; @@ -61,7 +61,7 @@ export const getGetViewImageTool = (server: Server): Tool = requestId, authInfo, args: { url }, - callback: async () => { + callback: async ({ cleanupActions }) => { const parsedUrl = parseUrl(url); if (!parsedUrl) { return Err({ @@ -82,26 +82,6 @@ export const getGetViewImageTool = (server: Server): Tool = }); } - const embeddingApiUrl = `${parsedUrl.origin}/javascripts/api/tableau.embedding.3.latest.js`; - try { - const response = await fetch(embeddingApiUrl); - if (!response.ok) { - return Err({ - type: 'embedding-api-not-found', - url: embeddingApiUrl, - error: new Error( - `Failed to fetch embedding API JavaScript module: ${response.status} ${response.statusText}`, - ), - }); - } - } catch (error) { - return Err({ - type: 'embedding-api-not-found', - url: embeddingApiUrl, - error, - }); - } - const rendererOptions: BrowserOptions = { width: width || 800, height: height || 800, @@ -124,6 +104,29 @@ export const getGetViewImageTool = (server: Server): Tool = .createNewPage(rendererOptions) .then((b) => b.enableDownloads()) .then((b) => b.navigate(embedUrl)) + .then(async (b) => { + if (parsedUrl.host === 'public.tableau.com') { + // No auth for Public + return b; + } + + if (config.auth === 'direct-trust' || config.auth === 'uat') { + // For Direct Trust and UAT, the JWT will be provided to the /embed endpoint. + // The Embedding API will use the JWT to authenticate. + return b; + } + + // For PAT and OAuth, we need to set the workgroup_session_id cookie ourselves. + const { workgroupSessionId, domain } = await getWorkgroupSessionId( + config.auth, + config, + authInfo, + { config, requestId, server, signal }, + cleanupActions, + ); + + return await b.setWorkgroupSessionId({ workgroupSessionId, domain }); + }) .then((b) => b.waitForPageLoad()) .then((b) => b.takeScreenshot()) .then((b) => b.getResult());