From 7f9fc75779b093de214aacdeec19bcd1974dedce Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 20:12:22 +0545 Subject: [PATCH 1/3] feat(OUT-4067): migrate copilot-node-sdk to @assembly-js/node-sdk The deprecated copilot-node-sdk is replaced by @assembly-js/node-sdk. The new SDK's session tokens expire after 5 minutes, which breaks long-running Trigger.dev file-sync tasks. Authenticate with a workspace-scoped API key (workspaceId/apiKey) and no token so credentials never expire mid-sync. CopilotAPI now takes a workspaceId (callers pass user.portalId), decodes the token payload via a token-scoped SDK only at the auth boundary, and drops the SDK methods that are no longer used. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- pnpm-lock.yaml | 227 +----------------- src/features/sync/lib/Sync.service.ts | 10 +- .../helper/resync-failed-files.helper.ts | 2 +- src/lib/copilot/CopilotAPI.ts | 183 +++----------- src/lib/copilot/models/User.model.ts | 25 +- src/lib/copilot/services/base.service.ts | 2 +- src/lib/copilot/types.ts | 54 ----- src/lib/copilot/utils.ts | 15 ++ src/trigger/processFileSync.ts | 2 +- src/utils/workspace.ts | 8 +- 11 files changed, 80 insertions(+), 450 deletions(-) diff --git a/package.json b/package.json index 840ad15..2383118 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,13 @@ "supabase:dev": "supabase start --ignore-health-check" }, "dependencies": { + "@assembly-js/node-sdk": "^4.2.2", "@sentry/nextjs": "^10", "@trigger.dev/sdk": "4.0.6", "bottleneck": "^2.19.5", "camelcase-keys": "^10.0.1", "clsx": "^2.1.1", "copilot-design-system": "^2.2.6", - "copilot-node-sdk": "^3.16.0", "dayjs": "^1.11.19", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74b1ccc..3ddb472 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@assembly-js/node-sdk': + specifier: ^4.2.2 + version: 4.2.2 '@sentry/nextjs': specifier: ^10 version: 10.25.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.5.18(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(webpack@5.103.0(esbuild@0.25.9)) @@ -26,9 +29,6 @@ importers: copilot-design-system: specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typescript@5.9.2) - copilot-node-sdk: - specifier: ^3.16.0 - version: 3.16.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) dayjs: specifier: ^1.11.19 version: 1.11.19 @@ -193,6 +193,10 @@ packages: '@apm-js-collab/tracing-hooks@0.3.1': resolution: {integrity: sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==} + '@assembly-js/node-sdk@4.2.2': + resolution: {integrity: sha512-zK7T9DuENmP4D7qyt9Xpblvo0d3WcBvh3rc3WQWimOoyvEOGyX/3IZ+oiCiTjmpqNisqYYa3MkZkbm1Ilr6fOA==} + engines: {node: '>=18'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -999,108 +1003,51 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@14.2.32': - resolution: {integrity: sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==} - '@next/env@15.5.18': resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} - '@next/swc-darwin-arm64@14.2.32': - resolution: {integrity: sha512-osHXveM70zC+ilfuFa/2W6a1XQxJTvEhzEycnjUaVE8kpUS09lDpiDDX2YLdyFCzoUbvbo5r0X1Kp4MllIOShw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-arm64@15.5.18': resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@14.2.32': - resolution: {integrity: sha512-P9NpCAJuOiaHHpqtrCNncjqtSBi1f6QUdHK/+dNabBIXB2RUFWL19TY1Hkhu74OvyNQEYEzzMJCMQk5agjw1Qg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-darwin-x64@15.5.18': resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@14.2.32': - resolution: {integrity: sha512-v7JaO0oXXt6d+cFjrrKqYnR2ubrD+JYP7nQVRZgeo5uNE5hkCpWnHmXm9vy3g6foMO8SPwL0P3MPw1c+BjbAzA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-gnu@15.5.18': resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@14.2.32': - resolution: {integrity: sha512-tA6sIKShXtSJBTH88i0DRd6I9n3ZTirmwpwAqH5zdJoQF7/wlJXR8DkPmKwYl5mFWhEKr5IIa3LfpMW9RRwKmQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-musl@15.5.18': resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@14.2.32': - resolution: {integrity: sha512-7S1GY4TdnlGVIdeXXKQdDkfDysoIVFMD0lJuVVMeb3eoVjrknQ0JNN7wFlhCvea0hEk0Sd4D1hedVChDKfV2jw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-gnu@15.5.18': resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@14.2.32': - resolution: {integrity: sha512-OHHC81P4tirVa6Awk6eCQ6RBfWl8HpFsZtfEkMpJ5GjPsJ3nhPe6wKAJUZ/piC8sszUkAgv3fLflgzPStIwfWg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-musl@15.5.18': resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@14.2.32': - resolution: {integrity: sha512-rORQjXsAFeX6TLYJrCG5yoIDj+NKq31Rqwn8Wpn/bkPNy5rTHvOXkW8mLFonItS7QC6M+1JIIcLe+vOCTOYpvg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-arm64-msvc@15.5.18': resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@14.2.32': - resolution: {integrity: sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@next/swc-win32-x64-msvc@14.2.32': - resolution: {integrity: sha512-2N0lSoU4GjfLSO50wvKpMQgKd4HdI2UHEhQPPPnlgfBJlOgJxkjpkYBqzk08f1gItBB6xF/n+ykso2hgxuydsA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@next/swc-win32-x64-msvc@15.5.18': resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} engines: {node: '>= 10'} @@ -2030,15 +1977,9 @@ packages: '@supabase/supabase-js@2.56.1': resolution: {integrity: sha512-cb/kS0d6G/qbcmUFItkqVrQbxQHWXzfRZuoiSDv/QiU6RbGNTn73XjjvmbBCZ4MMHs+5teihjhpEVluqbXISEg==} - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/helpers@0.5.5': - resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} - '@tailwindcss/node@4.1.12': resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==} @@ -2552,10 +2493,6 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - byline@5.0.0: resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} engines: {node: '>=0.10.0'} @@ -2576,9 +2513,6 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} - caniuse-lite@1.0.30001737: - resolution: {integrity: sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==} - caniuse-lite@1.0.30001756: resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==} @@ -2709,9 +2643,6 @@ packages: react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 - copilot-node-sdk@3.16.0: - resolution: {integrity: sha512-wqqeJuW+uge39nC1/+cqMWSSgCCNThtCR+JykPYFB2PTj0d0tfROCXofwtqZZccUUzk/xXXkJF53QfweVxmMLg==} - copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -3431,9 +3362,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-fetch@3.0.0: - resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} - jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -3835,25 +3763,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next@14.2.32: - resolution: {integrity: sha512-fg5g0GZ7/nFc09X8wLe6pNSU8cLWbLRG3TZzPJ1BJvi2s9m7eF991se67wliM9kR5yLHRkyGKU49MMx58s3LJg==} - engines: {node: '>=18.17.0'} - deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - sass: - optional: true - next@15.5.18: resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -4393,10 +4302,6 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} @@ -4447,19 +4352,6 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - styled-jsx@5.1.1: - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -4857,9 +4749,6 @@ packages: webpack-cli: optional: true - whatwg-fetch@3.6.20: - resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4993,6 +4882,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@assembly-js/node-sdk@4.2.2': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -5723,58 +5614,29 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@14.2.32': {} - '@next/env@15.5.18': {} - '@next/swc-darwin-arm64@14.2.32': - optional: true - '@next/swc-darwin-arm64@15.5.18': optional: true - '@next/swc-darwin-x64@14.2.32': - optional: true - '@next/swc-darwin-x64@15.5.18': optional: true - '@next/swc-linux-arm64-gnu@14.2.32': - optional: true - '@next/swc-linux-arm64-gnu@15.5.18': optional: true - '@next/swc-linux-arm64-musl@14.2.32': - optional: true - '@next/swc-linux-arm64-musl@15.5.18': optional: true - '@next/swc-linux-x64-gnu@14.2.32': - optional: true - '@next/swc-linux-x64-gnu@15.5.18': optional: true - '@next/swc-linux-x64-musl@14.2.32': - optional: true - '@next/swc-linux-x64-musl@15.5.18': optional: true - '@next/swc-win32-arm64-msvc@14.2.32': - optional: true - '@next/swc-win32-arm64-msvc@15.5.18': optional: true - '@next/swc-win32-ia32-msvc@14.2.32': - optional: true - - '@next/swc-win32-x64-msvc@14.2.32': - optional: true - '@next/swc-win32-x64-msvc@15.5.18': optional: true @@ -6738,17 +6600,10 @@ snapshots: - bufferutil - utf-8-validate - '@swc/counter@0.1.3': {} - '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@swc/helpers@0.5.5': - dependencies: - '@swc/counter': 0.1.3 - tslib: 2.8.1 - '@tailwindcss/node@4.1.12': dependencies: '@jridgewell/remapping': 2.3.5 @@ -7361,10 +7216,6 @@ snapshots: buildcheck@0.0.7: optional: true - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - byline@5.0.0: {} call-bind-apply-helpers@1.0.2: @@ -7383,8 +7234,6 @@ snapshots: camelcase@8.0.0: {} - caniuse-lite@1.0.30001737: {} - caniuse-lite@1.0.30001756: {} chai@6.2.2: {} @@ -7519,20 +7368,6 @@ snapshots: - supports-color - typescript - copilot-node-sdk@3.16.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7): - dependencies: - isomorphic-fetch: 3.0.0 - next: 14.2.32(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - transitivePeerDependencies: - - '@babel/core' - - '@opentelemetry/api' - - '@playwright/test' - - babel-plugin-macros - - encoding - - react - - react-dom - - sass - copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -8202,13 +8037,6 @@ snapshots: isexe@2.0.0: {} - isomorphic-fetch@3.0.0: - dependencies: - node-fetch: 2.7.0 - whatwg-fetch: 3.6.20 - transitivePeerDependencies: - - encoding - jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -8545,32 +8373,6 @@ snapshots: neo-async@2.6.2: {} - next@14.2.32(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7): - dependencies: - '@next/env': 14.2.32 - '@swc/helpers': 0.5.5 - busboy: 1.6.0 - caniuse-lite: 1.0.30001737 - graceful-fs: 4.2.11 - postcss: 8.4.31 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) - styled-jsx: 5.1.1(@babel/core@7.28.5)(react@19.1.7) - optionalDependencies: - '@next/swc-darwin-arm64': 14.2.32 - '@next/swc-darwin-x64': 14.2.32 - '@next/swc-linux-arm64-gnu': 14.2.32 - '@next/swc-linux-arm64-musl': 14.2.32 - '@next/swc-linux-x64-gnu': 14.2.32 - '@next/swc-linux-x64-musl': 14.2.32 - '@next/swc-win32-arm64-msvc': 14.2.32 - '@next/swc-win32-ia32-msvc': 14.2.32 - '@next/swc-win32-x64-msvc': 14.2.32 - '@opentelemetry/api': 1.9.0 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - next@15.5.18(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7): dependencies: '@next/env': 15.5.18 @@ -9201,8 +9003,6 @@ snapshots: std-env@4.1.0: {} - streamsearch@1.1.0: {} - streamx@2.28.0: dependencies: events-universal: 1.0.1 @@ -9262,13 +9062,6 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.1(@babel/core@7.28.5)(react@19.1.7): - dependencies: - client-only: 0.0.1 - react: 19.1.7 - optionalDependencies: - '@babel/core': 7.28.5 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.1.7): dependencies: client-only: 0.0.1 @@ -9643,8 +9436,6 @@ snapshots: - esbuild - uglify-js - whatwg-fetch@3.6.20: {} - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/src/features/sync/lib/Sync.service.ts b/src/features/sync/lib/Sync.service.ts index 34b0f75..7599f43 100644 --- a/src/features/sync/lib/Sync.service.ts +++ b/src/features/sync/lib/Sync.service.ts @@ -25,7 +25,7 @@ import type { WhereClause, } from '@/features/sync/types' import { copilotBottleneck } from '@/lib/copilot/bottleneck' -import { CopilotAPI, isCopilotApiError } from '@/lib/copilot/CopilotAPI' +import { type CopilotAPI, isCopilotApiError } from '@/lib/copilot/CopilotAPI' import type User from '@/lib/copilot/models/User.model' import type { CopilotFileRetrieve } from '@/lib/copilot/types' import AuthenticatedDropboxService from '@/lib/dropbox/AuthenticatedDropbox.service' @@ -478,7 +478,7 @@ export class SyncService extends AuthenticatedDropboxService { return } - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = this.copilot const fileCreateResponse = await copilotApi.createFile( assemblyCreatePath, assemblyChannelId, @@ -573,7 +573,7 @@ export class SyncService extends AuthenticatedDropboxService { entry, } = params const target = assemblyCreatePath.toLowerCase() - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = this.copilot let nextToken: string | undefined do { @@ -634,7 +634,7 @@ export class SyncService extends AuthenticatedDropboxService { assemblyCreatePath, assemblyPathOverride, } = params - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = this.copilot const fileCreateResponse = await copilotApi.createFile( assemblyPathOverride ?? assemblyCreatePath, @@ -1014,7 +1014,7 @@ export class SyncService extends AuthenticatedDropboxService { } private async deleteAssemblyFileQuietly(assemblyFileId: string) { - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = this.copilot try { await copilotApi.deleteFile(assemblyFileId) } catch (error) { diff --git a/src/features/workers/resync-failed-files/helper/resync-failed-files.helper.ts b/src/features/workers/resync-failed-files/helper/resync-failed-files.helper.ts index 2cad7cc..12b01e4 100644 --- a/src/features/workers/resync-failed-files/helper/resync-failed-files.helper.ts +++ b/src/features/workers/resync-failed-files/helper/resync-failed-files.helper.ts @@ -349,7 +349,7 @@ const initializeSyncDependencies = async ( }) const user = await User.authenticate(token) - const copilotApi = new CopilotAPI(token) + const copilotApi = new CopilotAPI(portalId) const dbxClient = new DropboxClient(refreshToken, rootNamespaceId) const connectionToken = { refreshToken, accountId, rootNamespaceId } const syncService = new SyncService(user, connectionToken) diff --git a/src/lib/copilot/CopilotAPI.ts b/src/lib/copilot/CopilotAPI.ts index 7250f70..5a5626c 100644 --- a/src/lib/copilot/CopilotAPI.ts +++ b/src/lib/copilot/CopilotAPI.ts @@ -1,21 +1,16 @@ import 'server-only' -import type { CopilotAPI as SDK } from 'copilot-node-sdk' -import { copilotApi } from 'copilot-node-sdk' +import { assemblyApi, type AssemblyAPI as SDK } from '@assembly-js/node-sdk' import fetch from 'node-fetch' -import z from 'zod' import env from '@/config/server.env' -import { MAX_FETCH_COPILOT_RESOURCES } from '@/constants/limits' import type { ObjectTypeValue } from '@/db/constants' import { MAX_FILES_LIMIT } from '@/features/sync/constant' import { - type ClientRequest, type ClientResponse, ClientResponseSchema, ClientsResponseSchema, type CompaniesResponse, CompaniesResponseSchema, - type CompanyCreateRequest, type CompanyResponse, CompanyResponseSchema, CopilotFileChannelListSchema, @@ -26,20 +21,7 @@ import { type CopilotFileRetrieve, CopilotFileRetrieveSchema, type CopilotListArgs, - type CopilotPrice, - CopilotPriceSchema, - type CopilotProduct, - CopilotProductSchema, type CreateFileType, - type InternalUser, - InternalUserSchema, - type InternalUsersResponse, - InternalUsersResponseSchema, - type NotificationCreatedResponse, - NotificationCreatedResponseSchema, - type NotificationRequestBody, - type Token, - TokenSchema, type WorkspaceResponse, WorkspaceResponseSchema, } from '@/lib/copilot/types' @@ -47,7 +29,7 @@ import logger from '@/lib/logger' import { withRetry } from '@/lib/withRetry' // Structural shape of the SDK's `ApiError` (declared in -// `copilot-node-sdk/dist/codegen/api/core/ApiError`). The class itself is not +// `@assembly-js/node-sdk/dist/codegen/api/core/ApiError`). The class itself is not // re-exported from the package's public entry point, so we identify it by // shape instead of `instanceof` — that avoids reaching into `node_modules` // internals, which would break on any minor SDK update. We check `url` + @@ -61,6 +43,9 @@ export type CopilotApiError = Error & { body: { message?: string } & Record } +// SDK's file type enum. Our ObjectType has the same string values, so we cast. +type AssemblyCreateFileType = Parameters[0]['requestBody']['type'] + export function isCopilotApiError(error: unknown): error is CopilotApiError { if (!(error instanceof Error)) return false const e = error as { @@ -79,142 +64,47 @@ export function isCopilotApiError(error: unknown): error is CopilotApiError { } export class CopilotAPI { - readonly copilot: SDK + readonly assemblySdk: Promise - constructor( - private readonly token: string, - readonly customApiKey?: string, - ) { - this.copilot = copilotApi({ - apiKey: customApiKey ?? env.COPILOT_API_KEY, - token, - }) + constructor(private readonly workspaceId: string) { + this.assemblySdk = assemblyApi({ apiKey: `${this.workspaceId}/${env.COPILOT_API_KEY}` }) + // Swallow here so an unused instance can't throw an unhandled rejection. + void this.assemblySdk.catch(() => undefined) } // NOTE: Any method prefixed with _ is a API method that doesn't implement retry & delay // NOTE: Any normal API method name implements `withRetry` with default config - // Get Token Payload from copilot request token - async _getTokenPayload(): Promise { - const getTokenPayload = this.copilot.getTokenPayload - if (!getTokenPayload) { - logger.error( - `CopilotAPI#getTokenPayload | Could not parse token payload for token ${this.token}`, - ) - return null - } - - return TokenSchema.parse(await getTokenPayload()) - } - async _getWorkspace(): Promise { logger.info('CopilotAPI#_getWorkspace') - return WorkspaceResponseSchema.parse(await this.copilot.retrieveWorkspace()) - } - - async _createClient( - requestBody: ClientRequest, - sendInvite: boolean = false, - ): Promise { - logger.info('CopilotAPI#_createClient', requestBody, sendInvite) - return ClientResponseSchema.parse(await this.copilot.createClient({ sendInvite, requestBody })) + const sdk = await this.assemblySdk + return WorkspaceResponseSchema.parse(await sdk.retrieveWorkspace()) } async _getClient(id: string): Promise { logger.info('CopilotAPI#_getClient', id) - return ClientResponseSchema.parse(await this.copilot.retrieveClient({ id })) + const sdk = await this.assemblySdk + return ClientResponseSchema.parse(await sdk.retrieveClient({ id })) } async _getClients(args: CopilotListArgs & { companyId?: string } = {}) { logger.info('CopilotAPI#_getClients', args) - return ClientsResponseSchema.parse(await this.copilot.listClients(args)) - } - - async _updateClient(id: string, requestBody: ClientRequest): Promise { - logger.info('CopilotAPI#_updateClient', id) - return ClientResponseSchema.parse(await this.copilot.updateClient({ id, requestBody })) - } - - async _deleteClient(id: string) { - logger.info('CopilotAPI#_deleteClient', id) - return await this.copilot.deleteClient({ id }) - } - - async _createCompany(requestBody: CompanyCreateRequest) { - logger.info('CopilotAPI#_createCompany', requestBody) - return CompanyResponseSchema.parse(await this.copilot.createCompany({ requestBody })) + const sdk = await this.assemblySdk + return ClientsResponseSchema.parse(await sdk.listClients(args)) } async _getCompany(id: string): Promise { logger.info('CopilotAPI#_getCompany', id) - return CompanyResponseSchema.parse(await this.copilot.retrieveCompany({ id })) + const sdk = await this.assemblySdk + return CompanyResponseSchema.parse(await sdk.retrieveCompany({ id })) } async _getCompanies( args: CopilotListArgs & { isPlaceholder?: boolean } = {}, ): Promise { logger.info('CopilotAPI#_getCompanies', args) - return CompaniesResponseSchema.parse(await this.copilot.listCompanies(args)) - } - - async _getCompanyClients(companyId: string): Promise { - logger.info('CopilotAPI#_getCompanyClients', companyId) - return (await this.getClients({ limit: 10000, companyId })).data || [] - } - - async _getInternalUsers(args: CopilotListArgs = {}): Promise { - logger.info('CopilotAPI#_getInternalUsers', args) - return InternalUsersResponseSchema.parse(await this.copilot.listInternalUsers(args)) - } - - async _getInternalUser(id: string): Promise { - logger.info('CopilotAPI#_getInternalUser', id) - return InternalUserSchema.parse(await this.copilot.retrieveInternalUser({ id })) - } - - async _createNotification( - requestBody: NotificationRequestBody, - ): Promise { - logger.info('CopilotAPI#_createNotification', requestBody) - const notification = await this.copilot.createNotification({ requestBody }) - return NotificationCreatedResponseSchema.parse(notification) - } - - /** - * Returns an object with product ID as key and product as value - * @param productIds Products to get details for - */ - async _getProducts( - productIds: string[], - args: CopilotListArgs = { limit: MAX_FETCH_COPILOT_RESOURCES }, - ): Promise> { - const allProductsResponse = await this.copilot.listProducts(args) - const allProducts = z.array(CopilotProductSchema).parse(allProductsResponse.data) - - return allProducts.reduce>((acc, product) => { - if (productIds.includes(product.id)) { - acc[product.id] = product - } - return acc - }, {}) - } - - /** - * Returns an object with price ID as key and price as value - * @param priceIds Prices to get details for - */ - async _getPrices( - priceIds: string[], - args = { limit: '10_000' }, - ): Promise> { - const allPricesResponse = await this.copilot.listPrices(args) - const allPrices = z.array(CopilotPriceSchema).parse(allPricesResponse.data) - return allPrices.reduce>((acc, price) => { - if (priceIds.includes(price.id)) { - acc[price.id] = price - } - return acc - }, {}) + const sdk = await this.assemblySdk + return CompaniesResponseSchema.parse(await sdk.listCompanies(args)) } async _createFile( @@ -224,12 +114,13 @@ export class CopilotAPI { ): Promise { // Names are validated upstream (SyncService); path passes through unchanged. console.info(`CopilotAPI#_createFile. Path: ${path}`) - - const createFileResponse = await this.copilot.createFile({ + const sdk = await this.assemblySdk + const createFileResponse = await sdk.createFile({ fileType, requestBody: { path, - channelId, + channelID: channelId, + type: fileType as unknown as AssemblyCreateFileType, }, }) return CopilotFileCreateSchema.parse(createFileResponse) @@ -251,7 +142,8 @@ export class CopilotAPI { } async _deleteFile(id: string) { - return await this.copilot.deleteFile({ id }) + const sdk = await this.assemblySdk + return await sdk.deleteFile({ id }) } async _listFiles( @@ -259,7 +151,8 @@ export class CopilotAPI { nextToken?: string, customLimit?: number, ): Promise { - const list = await this.copilot.listFiles({ + const sdk = await this.assemblySdk + const list = await sdk.listFiles({ channelId, nextToken, limit: customLimit || MAX_FILES_LIMIT, @@ -268,17 +161,20 @@ export class CopilotAPI { } async _retrieveFile(id: string): Promise { - const file = await this.copilot.retrieveFile({ id }) + const sdk = await this.assemblySdk + const file = await sdk.retrieveFile({ id }) return CopilotFileRetrieveSchema.parse(file) } async _retrieveFileChannel(id: string) { - const fileChannel = await this.copilot.retrieveFileChannel({ id }) + const sdk = await this.assemblySdk + const fileChannel = await sdk.retrieveFileChannel({ id }) return CopilotFileChannelRetrieveSchema.parse(fileChannel) } async _listFileChannels(args: CopilotListArgs & { companyId?: string; clientId?: string } = {}) { - const list = await this.copilot.listFileChannels(args) + const sdk = await this.assemblySdk + const list = await sdk.listFileChannels(args) return CopilotFileChannelListSchema.parse(list.data) } @@ -289,22 +185,11 @@ export class CopilotAPI { } // Methods wrapped with retry - getTokenPayload = this.wrapWithRetry(this._getTokenPayload) getWorkspace = this.wrapWithRetry(this._getWorkspace) - createClient = this.wrapWithRetry(this._createClient) getClient = this.wrapWithRetry(this._getClient) getClients = this.wrapWithRetry(this._getClients) - updateClient = this.wrapWithRetry(this._updateClient) - deleteClient = this.wrapWithRetry(this._deleteClient) - createCompany = this.wrapWithRetry(this._createCompany) getCompany = this.wrapWithRetry(this._getCompany) getCompanies = this.wrapWithRetry(this._getCompanies) - getCompanyClients = this.wrapWithRetry(this._getCompanyClients) - getInternalUsers = this.wrapWithRetry(this._getInternalUsers) - getInternalUser = this.wrapWithRetry(this._getInternalUser) - createNotification = this.wrapWithRetry(this._createNotification) - getProductsById = this.wrapWithRetry(this._getProducts) - getPricesById = this.wrapWithRetry(this._getPrices) createFile = this.wrapWithRetry(this._createFile) uploadFile = this.wrapWithRetry(this._uploadFile) deleteFile = this.wrapWithRetry(this._deleteFile) diff --git a/src/lib/copilot/models/User.model.ts b/src/lib/copilot/models/User.model.ts index 3a32c7d..1d10beb 100644 --- a/src/lib/copilot/models/User.model.ts +++ b/src/lib/copilot/models/User.model.ts @@ -2,8 +2,8 @@ import httpStatus from 'http-status' import { z } from 'zod' import APIError from '@/errors/APIError' import { CopilotAPI } from '@/lib/copilot/CopilotAPI' -import CopilotConnectionError from '@/lib/copilot/errors/CopilotConnectionError' import type { Token } from '@/lib/copilot/types' +import { getAssemblyTokenPayload } from '@/lib/copilot/utils' import logger from '@/lib/logger' class User { @@ -18,14 +18,14 @@ class User { ) { this.internalUserId = tokenPayload.internalUserId this.portalId = tokenPayload.workspaceId - this.copilot = copilot || new CopilotAPI(token) + this.copilot = copilot || new CopilotAPI(tokenPayload.workspaceId) } /** * Authenticates a Copilot user by token * @param token * @returns User instance modeled from the token payload - * @throws CopilotConnectionError when unable to connect to Copilot API + * @throws APIError when the token is missing or cannot be decoded */ static async authenticate(token?: unknown): Promise { logger.info('User#authenticate :: Authenticating user', token) @@ -41,26 +41,13 @@ class User { throw new APIError('Token parse error', httpStatus.UNAUTHORIZED) } - let copilot: CopilotAPI - try { - copilot = new CopilotAPI(tokenParsed.data) - } catch (err) { - if (err instanceof Error && err.message.includes('Unable to authorize Copilot SDK')) { - throw new APIError( - 'Unable to authorize Copilot with provided token', - httpStatus.UNAUTHORIZED, - ) - } - logger.error('User#authenticate :: Error while initializing Copilot client', err) - throw new CopilotConnectionError() - } - - const tokenPayload = await copilot.getTokenPayload() + const tokenPayload = await getAssemblyTokenPayload(tokenParsed.data) if (!tokenPayload) { throw new APIError('Unable to decode Copilot token payload', httpStatus.UNAUTHORIZED) } - return new User(tokenParsed.data, tokenPayload, copilot) + // SDK is built lazily, so auth errors surface on the first API call. + return new User(tokenParsed.data, tokenPayload) } } diff --git a/src/lib/copilot/services/base.service.ts b/src/lib/copilot/services/base.service.ts index 4fdf37f..87187ad 100644 --- a/src/lib/copilot/services/base.service.ts +++ b/src/lib/copilot/services/base.service.ts @@ -4,7 +4,7 @@ import type User from '@/lib/copilot/models/User.model' class BaseService { protected readonly copilot: CopilotAPI constructor(protected readonly user: User) { - this.copilot = new CopilotAPI(user.token) + this.copilot = new CopilotAPI(user.portalId) } } diff --git a/src/lib/copilot/types.ts b/src/lib/copilot/types.ts index a731b8c..577d119 100644 --- a/src/lib/copilot/types.ts +++ b/src/lib/copilot/types.ts @@ -105,60 +105,6 @@ export const InternalUsersResponseSchema = z.object({ }) export type InternalUsersResponse = z.infer -/** - * `senderType` field for notification payload in Copilot API - */ -export const NotificationSenderSchema = z.enum(['internalUser', 'client']) -export type NotificationSender = z.infer - -/** - * Notification RequestBody schema - accepted by SDK#createNotification - */ -export const NotificationRequestBodySchema = z.object({ - senderId: z.string(), - // New notification body schema for copilot to accomodate for multiple companies - senderType: NotificationSenderSchema, - senderCompanyId: z.string().optional(), - recipientInternalUserId: z.string().optional(), - recipientClientId: z.string().optional(), - recipientCompanyId: z.string().optional(), - deliveryTargets: z - .object({ - inProduct: z - .object({ - title: z.string(), - body: z.string().optional(), - }) - .optional(), - email: z - .object({ - subject: z.string().optional(), - header: z.string().optional(), - title: z.string().optional(), - body: z.string().optional(), - }) - .optional(), - }) - .optional(), -}) -export type NotificationRequestBody = z.infer - -export const NotificationCreatedResponseSchema = z.object({ - id: z.string(), - appId: z.uuid().optional(), - createdAt: z.iso.datetime(), - event: z.string().optional(), - object: z.string().optional(), - companyId: z.string().optional(), - recipientInternalUserId: z.string().optional(), - recipientClientId: z.string().optional(), - recipientCompanyId: z.string().optional(), - resourceId: z.string().optional(), - senderId: z.string().optional(), - senderType: z.string().optional(), -}) -export type NotificationCreatedResponse = z.infer - export const CopilotProductSchema = z.object({ id: z.string(), name: z.string(), diff --git a/src/lib/copilot/utils.ts b/src/lib/copilot/utils.ts index 7aa00ce..ab5f25d 100644 --- a/src/lib/copilot/utils.ts +++ b/src/lib/copilot/utils.ts @@ -1,2 +1,17 @@ +import { assemblyApi } from '@assembly-js/node-sdk' +import env from '@/config/server.env' +import { TokenSchema } from '@/lib/copilot/types' + export const buildClientName = (client: { givenName: string; familyName: string }) => `${client.givenName} ${client.familyName}` + +// Decodes a request token into its payload using the token-scoped SDK. +export async function getAssemblyTokenPayload(token: string) { + const sdk = await assemblyApi({ apiKey: env.COPILOT_API_KEY, token }) + if (!sdk.getTokenPayload) { + // Never log the raw token — it is a credential. + console.error('getAssemblyTokenPayload | cannot decode token') + return null + } + return TokenSchema.parse(await sdk.getTokenPayload()) +} diff --git a/src/trigger/processFileSync.ts b/src/trigger/processFileSync.ts index ad7b6bc..3589ad9 100644 --- a/src/trigger/processFileSync.ts +++ b/src/trigger/processFileSync.ts @@ -276,7 +276,7 @@ export const initiateAssemblyToDropboxSync = task({ const dbxAuth = new DropboxAuthClient() // 1. get al the files from the assembly - const copilotApi = new CopilotAPI(payload.user.token) + const copilotApi = new CopilotAPI(payload.user.portalId) let files = await copilotApi.listFiles(payload.assemblyChannelId) while (files.data.length) { diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 0fcfe63..1cd8655 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -1,5 +1,6 @@ import { CopilotAPI } from '@/lib/copilot/CopilotAPI' import type { WorkspaceResponse } from '@/lib/copilot/types' +import { getAssemblyTokenPayload } from '@/lib/copilot/utils' export const getWorkspaceLabel = ( workspace: WorkspaceResponse, @@ -14,6 +15,11 @@ export const getWorkspaceLabel = ( } export async function getWorkspace(token: string): Promise { - const copilot = new CopilotAPI(token) + const tokenPayload = await getAssemblyTokenPayload(token) + if (!tokenPayload) { + throw new Error('Unable to decode Copilot token payload') + } + + const copilot = new CopilotAPI(tokenPayload.workspaceId) return await copilot.getWorkspace() } From 94e878e5eb2fba8c79fa0afe8a3986809dd39350 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 20:12:38 +0545 Subject: [PATCH 2/3] feat(OUT-4067): require COPILOT_ENV for keyless Assembly SDK auth The workspace-scoped API key only authorizes when COPILOT_ENV (or ASSEMBLY_ENV) is "local" or "__SECRET_STAGING__". Validate it at boot so a misconfigured runtime fails fast instead of on the first API call. Co-Authored-By: Claude Opus 4.8 --- src/config/server.env.ts | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/config/server.env.ts b/src/config/server.env.ts index 9db5940..6f947c5 100644 --- a/src/config/server.env.ts +++ b/src/config/server.env.ts @@ -3,17 +3,34 @@ import 'server-only' import { z } from 'zod' -const ServerEnvSchema = z.object({ - COPILOT_API_KEY: z.string().min(1), - DATABASE_URL: z.url(), - DROPBOX_APP_KEY: z.string().min(1), - DROPBOX_APP_SECRET: z.string().min(1), - DROPBOX_REDIRECT_URI: z.url(), - DROPBOX_SCOPES: z.string().min(1), - DROPBOX_API_URL: z.url(), - TRIGGER_MACHINE: TriggerMachineSchema, - WEBHOOK_CATCHUP_CRON: z.string().min(1), -}) +// Env modes where the SDK authorizes with the workspace-scoped key (no token). +const ASSEMBLY_KEYLESS_ENV_MODES = ['local', '__SECRET_STAGING__'] + +const ServerEnvSchema = z + .object({ + COPILOT_API_KEY: z.string().min(1), + DATABASE_URL: z.url(), + DROPBOX_APP_KEY: z.string().min(1), + DROPBOX_APP_SECRET: z.string().min(1), + DROPBOX_REDIRECT_URI: z.url(), + DROPBOX_SCOPES: z.string().min(1), + DROPBOX_API_URL: z.url(), + TRIGGER_MACHINE: TriggerMachineSchema, + WEBHOOK_CATCHUP_CRON: z.string().min(1), + ASSEMBLY_ENV: z.string().optional(), + COPILOT_ENV: z.string().optional(), + }) + .superRefine((val, ctx) => { + const envMode = val.ASSEMBLY_ENV ?? val.COPILOT_ENV + if (!envMode || !ASSEMBLY_KEYLESS_ENV_MODES.includes(envMode)) { + ctx.addIssue({ + code: 'custom', + path: ['COPILOT_ENV'], + message: + 'COPILOT_ENV (or ASSEMBLY_ENV) must be "local". Set it in every runtime (Vercel, Trigger.dev).', + }) + } + }) const env = ServerEnvSchema.parse(process.env) export default env From 41eec8b79e65db5971b0da963538064b617474a6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 20:12:49 +0545 Subject: [PATCH 3/3] test(OUT-4067): update tests and msw harness for @assembly-js/node-sdk Inline the new SDK in the integration config, point the mock host at the SDK's api.assembly.com base URL, fix the createFile mock's channelID casing, and update the workspace tests for the decode-then-construct flow. Co-Authored-By: Claude Opus 4.8 --- src/utils/__tests__/workspace.test.md | 73 +++++++++++++++++++ src/utils/__tests__/workspace.test.ts | 93 ++++++++++++++++++++++++ test/msw/hosts.ts | 3 +- test/msw/msw-harness.integration.test.ts | 10 +-- test/msw/write.integration.test.ts | 8 +- test/msw/write.ts | 5 +- vitest.integration.config.ts | 9 +-- 7 files changed, 183 insertions(+), 18 deletions(-) create mode 100644 src/utils/__tests__/workspace.test.md create mode 100644 src/utils/__tests__/workspace.test.ts diff --git a/src/utils/__tests__/workspace.test.md b/src/utils/__tests__/workspace.test.md new file mode 100644 index 0000000..ea0187b --- /dev/null +++ b/src/utils/__tests__/workspace.test.md @@ -0,0 +1,73 @@ +# `workspace.test.ts` — Test Documentation + +Tests for `src/utils/workspace.ts`, which provides two exports: a pure label-lookup helper and an async function to fetch workspace data from the Copilot API. + +--- + +## `getWorkspaceLabel` + +A synchronous function that resolves a human-readable label from a `WorkspaceResponse` object. + +```mermaid +flowchart LR + A["getWorkspaceLabel(workspace, key)"] --> B{"labels?"} + B -->|Yes| C{"key in labels?"} + B -->|No| D[Return default] + C -->|Yes| E["Return labels[key].toLowerCase()"] + C -->|No| D +``` + +### Lowercasing (4 test cases) + +Each of the four label keys is lowercased before being returned: + +| Key | Input value | Output | +|---|---|---| +| `individualTerm` | `"Patient"` | `"patient"` | +| `individualTermPlural` | `"Patients"` | `"patients"` | +| `groupTerm` | `"Org"` | `"org"` | +| `groupTermPlural` | `"Orgs"` | `"orgs"` | + +### Fallback defaults (4 test cases) + +When a label is absent (either `labels` is `undefined` or the specific key is `undefined`), the function falls back to a hardcoded default. Both scenarios exercise the same code path (`??` / `||` chain), so a single parameterized test covers them: + +| Key | Default | +|---|---| +| `individualTerm` | `"client"` | +| `individualTermPlural` | `"clients"` | +| `groupTerm` | `"company"` | +| `groupTermPlural` | `"companies"` | + +### Idempotency (1 test case) + +Input that is already lowercase passes through unchanged — ensures the function doesn't double-lowercase or corrupt the value. + +--- + +## `getWorkspace` + +An async function that decodes the request token into its payload, instantiates `CopilotAPI` with the resolved `workspaceId`, and calls `getWorkspace()`. + +```mermaid +flowchart LR + A[getWorkspace
token] --> B[getAssemblyTokenPayload
token] + B --> C{payload?} + C -->|No| E[throw APIError] + C -->|Yes| D[new CopilotAPI
workspaceId] + D --> F[.getWorkspace] + F --> G[WorkspaceResponse] +``` + +### `CopilotAPI` and `getAssemblyTokenPayload` are mocked + +- `@/lib/copilot/CopilotAPI` is replaced with a `vi.fn()` that returns a stub instance whose `getWorkspace` resolves with a known `WorkspaceResponse` (all four labels: `Patient`/`Patients`/`Org`/`Orgs`). +- `@/lib/copilot/utils` is mocked so `getAssemblyTokenPayload` resolves to `{ workspaceId: 'ws_1' }`. This keeps the real Assembly SDK out of the test — importing it under vitest crashes on an unsupported ESM directory import. + +### Tests + +1. **Returns the result from `copilot.getWorkspace()`** — asserts the returned object has the expected shape (`id`, `brandName`, `portalUrl`). +2. **Constructs `CopilotAPI` with the decoded `workspaceId`, not the raw token** — verifies the token is passed to `getAssemblyTokenPayload` and the resulting `workspaceId` (`ws_1`) is passed to the `CopilotAPI` constructor. +3. **Throws when the token payload cannot be decoded** — when `getAssemblyTokenPayload` returns `null`, `getWorkspace` rejects with `Unable to decode Copilot token payload`. + +Together these confirm `getWorkspace` decodes the token, guards the null case, and delegates construction and data fetching with no extra transformation. diff --git a/src/utils/__tests__/workspace.test.ts b/src/utils/__tests__/workspace.test.ts new file mode 100644 index 0000000..c55a7db --- /dev/null +++ b/src/utils/__tests__/workspace.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CopilotAPI } from '@/lib/copilot/CopilotAPI' +import type { WorkspaceResponse } from '@/lib/copilot/types' +import { getAssemblyTokenPayload } from '@/lib/copilot/utils' +import { getWorkspace, getWorkspaceLabel } from '@/utils/workspace' + +vi.mock('@/lib/copilot/CopilotAPI', () => { + const workspace: WorkspaceResponse = { + id: 'ws_1', + brandName: 'Test', + portalUrl: 'https://example.com', + labels: { + individualTerm: 'Patient', + individualTermPlural: 'Patients', + groupTerm: 'Org', + groupTermPlural: 'Orgs', + }, + } + + return { + CopilotAPI: vi.fn(function Mock() { + return { getWorkspace: vi.fn().mockResolvedValue(workspace) } + }), + } +}) + +// Mock the decode so the test never loads the real Assembly SDK. +vi.mock('@/lib/copilot/utils', () => ({ + getAssemblyTokenPayload: vi.fn().mockResolvedValue({ workspaceId: 'ws_1' }), +})) + +function makeWorkspace(overrides?: Partial): WorkspaceResponse { + return { id: 'ws_1', portalUrl: '', ...overrides } +} + +describe('getWorkspaceLabel', () => { + const labels = { + individualTerm: 'Patient', + individualTermPlural: 'Patients', + groupTerm: 'Org', + groupTermPlural: 'Orgs', + } as const + + it.each([ + { key: 'individualTerm', expected: 'patient' }, + { key: 'individualTermPlural', expected: 'patients' }, + { key: 'groupTerm', expected: 'org' }, + { key: 'groupTermPlural', expected: 'orgs' }, + ] as const)('lowercases $key', ({ key, expected }) => { + expect(getWorkspaceLabel(makeWorkspace({ labels }), key)).toBe(expected) + }) + + it.each([ + { key: 'individualTerm', expected: 'client' }, + { key: 'individualTermPlural', expected: 'clients' }, + { key: 'groupTerm', expected: 'company' }, + { key: 'groupTermPlural', expected: 'companies' }, + ] as const)('falls back to "$expected" when $key is missing', ({ key, expected }) => { + expect(getWorkspaceLabel(makeWorkspace(), key)).toBe(expected) + }) + + it('passes already-lowercase input through unchanged', () => { + expect( + getWorkspaceLabel(makeWorkspace({ labels: { groupTerm: 'company' } }), 'groupTerm'), + ).toBe('company') + }) +}) + +describe('getWorkspace', () => { + beforeEach(() => { + vi.mocked(getAssemblyTokenPayload).mockResolvedValue({ workspaceId: 'ws_1' }) + }) + + it('returns the result from copilot.getWorkspace()', async () => { + const result = await getWorkspace('token-123') + expect(result.id).toBe('ws_1') + expect(result.brandName).toBe('Test') + expect(result.portalUrl).toBe('https://example.com') + }) + + it('constructs CopilotAPI with the decoded workspaceId, not the raw token', async () => { + await getWorkspace('token-xyz') + expect(getAssemblyTokenPayload).toHaveBeenCalledWith('token-xyz') + expect(CopilotAPI).toHaveBeenCalledWith('ws_1') + }) + + it('throws when the token payload cannot be decoded', async () => { + vi.mocked(getAssemblyTokenPayload).mockResolvedValueOnce(null) + await expect(getWorkspace('bad-token')).rejects.toThrow( + 'Unable to decode Copilot token payload', + ) + }) +}) diff --git a/test/msw/hosts.ts b/test/msw/hosts.ts index 8780a7a..0035880 100644 --- a/test/msw/hosts.ts +++ b/test/msw/hosts.ts @@ -1,4 +1,5 @@ // Dependency-free so other files can import it without an import cycle via server.ts. export const DROPBOX_RPC_HOST = 'https://api.dropboxapi.com' export const DROPBOX_CONTENT_HOST = 'https://content.dropboxapi.com' -export const COPILOT_HOST = 'https://api.copilot.app' +// Matches the Assembly SDK's default base URL when COPILOT_ENV is 'local'. +export const COPILOT_HOST = 'https://api.assembly.com' diff --git a/test/msw/msw-harness.integration.test.ts b/test/msw/msw-harness.integration.test.ts index 7d09cc2..6d6eefe 100644 --- a/test/msw/msw-harness.integration.test.ts +++ b/test/msw/msw-harness.integration.test.ts @@ -1,4 +1,4 @@ -import { copilotApi } from 'copilot-node-sdk' +import { assemblyApi } from '@assembly-js/node-sdk' import { DropboxResponseError } from 'dropbox' import { HttpResponse } from 'msw' import { describe, expect, it } from 'vitest' @@ -31,7 +31,7 @@ describe('MSW harness — base handlers + hosts', () => { }) it('serves an empty Copilot file page to the real SDK (undici fetch)', async () => { - const page = await new CopilotAPI('token')._listFiles('ch_1') + const page = await new CopilotAPI('ws_1')._listFiles('ch_1') expect(page.data).toEqual([]) }) @@ -56,7 +56,7 @@ describe('MSW harness — base handlers + hosts', () => { describe('MSW harness — Copilot error shapes', () => { it('reproduces isCopilotApiError 400 "Folder already exists"', async () => { mockCopilot('/v1/files', () => copilotFolderExists()) - const client = copilotApi({ apiKey: 'k', token: 't' }) + const client = await assemblyApi({ apiKey: 'k', token: 't' }) try { await client.listFiles({ channelId: 'ch' }) expect.unreachable('listFiles should have thrown') @@ -71,7 +71,7 @@ describe('MSW harness — Copilot error shapes', () => { it('reproduces isCopilotApiError 404', async () => { mockCopilot('/v1/files', () => copilotNotFound()) - const client = copilotApi({ apiKey: 'k', token: 't' }) + const client = await assemblyApi({ apiKey: 'k', token: 't' }) try { await client.listFiles({ channelId: 'ch' }) expect.unreachable('listFiles should have thrown') @@ -136,7 +136,7 @@ describe('MSW harness — pagination', () => { path: `/f${i}`, })) server.use(paginateCopilotListFiles(items, { pageSize: 100 })) - const api = new CopilotAPI('token') + const api = new CopilotAPI('ws_1') const all: unknown[] = [] let nextToken: string | undefined do { diff --git a/test/msw/write.integration.test.ts b/test/msw/write.integration.test.ts index c466bfd..618bf6a 100644 --- a/test/msw/write.integration.test.ts +++ b/test/msw/write.integration.test.ts @@ -79,13 +79,13 @@ describe('Dropbox content handlers', () => { describe('Copilot create-file handler', () => { it('createFile(folder) returns folder metadata (no upload URL needed)', async () => { mockCopilotCreateFile() - const res = await new CopilotAPI('token').createFile('/folder', 'ch-1', ObjectType.FOLDER) + const res = await new CopilotAPI('ws_1').createFile('/folder', 'ch-1', ObjectType.FOLDER) expect(res.object).toBe(ObjectType.FOLDER) }) it('createFile(file) returns an uploadUrl that accepts a PUT', async () => { mockCopilotCreateFile() - const api = new CopilotAPI('token') + const api = new CopilotAPI('ws_1') const created = await api.createFile('/a.txt', 'ch-1', ObjectType.FILE) expect(created.uploadUrl).toBeTruthy() const put = await api.uploadFile(created.uploadUrl as string, '5', null) @@ -103,14 +103,14 @@ describe('delta handlers (webhook flow)', () => { it('retrieveFile returns the file for a known id and 404s an unknown one', async () => { const file = copilotFileFactory.build() mockCopilotRetrieveFile({ [file.id]: file }) - const api = new CopilotAPI('token') + const api = new CopilotAPI('ws_1') expect((await api.retrieveFile(file.id)).id).toBe(file.id) await expect(api.retrieveFile('missing-id')).rejects.toMatchObject({ status: 404 }) }) it('deleteFile hits the Copilot DELETE handler and captures the id', async () => { const { deletedIds } = mockCopilotDeleteFile() - await expect(new CopilotAPI('token').deleteFile('file-id')).resolves.toBeDefined() + await expect(new CopilotAPI('ws_1').deleteFile('file-id')).resolves.toBeDefined() expect(deletedIds).toEqual(['file-id']) }) diff --git a/test/msw/write.ts b/test/msw/write.ts index 3177100..f756867 100644 --- a/test/msw/write.ts +++ b/test/msw/write.ts @@ -120,12 +120,13 @@ export function mockCopilotCreateFile(opts: { uploadUrl?: string } = {}): void { mockCopilot( '/v1/files/:fileType', async ({ request, params }) => { - const { path, channelId } = (await request.json()) as { path: string; channelId: string } + // The SDK sends `channelID` (capital ID); the response echoes `channelId`. + const { path, channelID } = (await request.json()) as { path: string; channelID: string } const fileType = params.fileType as string return HttpResponse.json({ // Real Copilot returns a UUID; callers persist it into a uuid() column. id: randomUUID(), - channelId, + channelId: channelID, name: nameOf(path), object: fileType, path, diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts index 2b1b388..831e03c 100644 --- a/vitest.integration.config.ts +++ b/vitest.integration.config.ts @@ -19,12 +19,9 @@ export default defineConfig({ globalSetup: ['./test/integration/global-setup.ts'], setupFiles: ['./test/integration/setup.ts'], server: { - // `copilot-node-sdk` is a pure-ESM package whose dist does an internal - // directory import (`../codegen/api`, resolved to its `index.js` only by - // bundler-style resolution). Node's native ESM loader can't resolve that - // when Vite externalizes the package, so force it through Vite's own - // resolver instead. - deps: { inline: ['copilot-node-sdk'] }, + // `@assembly-js/node-sdk` uses an internal directory import that Node's + // ESM loader can't resolve, so force it through Vite's resolver. + deps: { inline: ['@assembly-js/node-sdk'] }, }, // One container, one shared DB — run files serially so truncate-between-tests // isolation is safe.