From 82a76e59ce4f5f089fedb72c6d373814814b9281 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 21:28:51 +0545 Subject: [PATCH 1/4] feat(OUT-4067): migrate to assembly-js node sdk Replace deprecated copilot-node-sdk with @assembly-js/node-sdk. - Scope CopilotAPI by workspace key (workspaceId/apiKey) with no request token, so long invoice syncs don't fail on token expiry - Create the SDK client lazily and reuse it per instance - Decode the request token via getAssemblyTokenPayload; retry and return null on failure so auth maps it to a typed error - Drop the dead constructor try/catch and the redundant token decode in the home page (use user.portalId) - Require deliveryTargets/email fields on notifications to match the SDK Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- pnpm-lock.yaml | 222 +----------------- src/app/(home)/page.tsx | 3 +- .../lib/RetryFailedSyncs.service.ts | 2 +- src/lib/copilot/CopilotAPI.ts | 85 ++++--- src/lib/copilot/models/User.model.ts | 22 +- src/lib/copilot/services/base.service.ts | 2 +- src/lib/copilot/types.ts | 34 ++- src/lib/copilot/utils.ts | 25 ++ 9 files changed, 100 insertions(+), 297 deletions(-) diff --git a/package.json b/package.json index 9bccf69..17a01b2 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,12 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@assembly-js/node-sdk": "^4.2.2", "@sentry/nextjs": "^10.29.0", "@types/deep-equal": "^1.0.4", "@types/html-to-text": "^9.0.4", "bottleneck": "^2.19.5", "copilot-design-system": "^2.2.3", - "copilot-node-sdk": "^3.16.0", "dayjs": "^1.11.19", "deep-equal": "^2.2.3", "dotenv": "^17.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e581a96..d9f128f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@assembly-js/node-sdk': + specifier: ^4.2.2 + version: 4.2.2 '@sentry/nextjs': specifier: ^10.29.0 version: 10.29.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@16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.103.0(esbuild@0.25.9)) @@ -27,9 +30,6 @@ importers: copilot-design-system: specifier: ^2.2.3 version: 2.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(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.2.6(react@19.2.6))(react@19.2.6) dayjs: specifier: ^1.11.19 version: 1.11.19 @@ -170,6 +170,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'} @@ -969,108 +973,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@16.2.6': resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/swc-darwin-arm64@14.2.32': - resolution: {integrity: sha512-osHXveM70zC+ilfuFa/2W6a1XQxJTvEhzEycnjUaVE8kpUS09lDpiDDX2YLdyFCzoUbvbo5r0X1Kp4MllIOShw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-arm64@16.2.6': resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} 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@16.2.6': resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} 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@16.2.6': resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} 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@16.2.6': resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} 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@16.2.6': resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} 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@16.2.6': resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} 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@16.2.6': resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} 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@16.2.6': resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} @@ -1868,15 +1815,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==} @@ -2398,10 +2339,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'} @@ -2534,9 +2471,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==} - core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} @@ -3271,9 +3205,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-fetch@3.0.0: - resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -3663,25 +3594,6 @@ packages: peerDependencies: next: '>=9' - 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@16.2.6: resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} @@ -4188,10 +4100,6 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} @@ -4229,19 +4137,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - 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'} @@ -4567,9 +4462,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==} @@ -4693,6 +4585,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 @@ -5420,58 +5314,29 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@14.2.32': {} - '@next/env@16.2.6': {} - '@next/swc-darwin-arm64@14.2.32': - optional: true - '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-x64@14.2.32': - optional: true - '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@14.2.32': - optional: true - '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-musl@14.2.32': - optional: true - '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-x64-gnu@14.2.32': - optional: true - '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-musl@14.2.32': - optional: true - '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@14.2.32': - optional: true - '@next/swc-win32-arm64-msvc@16.2.6': 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@16.2.6': optional: true @@ -6284,17 +6149,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 @@ -6881,10 +6739,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: @@ -7022,20 +6876,6 @@ snapshots: - supports-color - typescript - copilot-node-sdk@3.16.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - isomorphic-fetch: 3.0.0 - next: 14.2.32(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - '@opentelemetry/api' - - '@playwright/test' - - babel-plugin-macros - - encoding - - react - - react-dom - - sass - core-js@3.49.0: {} core-util-is@1.0.3: {} @@ -7719,13 +7559,6 @@ snapshots: isexe@2.0.0: {} - isomorphic-fetch@3.0.0: - dependencies: - node-fetch: 2.7.0 - whatwg-fetch: 3.6.20 - transitivePeerDependencies: - - encoding - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -8049,32 +7882,6 @@ snapshots: core-js: 3.49.0 next: 16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - next@14.2.32(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@next/env': 14.2.32 - '@swc/helpers': 0.5.5 - busboy: 1.6.0 - caniuse-lite: 1.0.30001759 - graceful-fs: 4.2.11 - postcss: 8.4.31 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-jsx: 5.1.1(@babel/core@7.28.5)(react@19.2.6) - 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@16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@next/env': 16.2.6 @@ -8668,8 +8475,6 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - streamsearch@1.1.0: {} - streamx@2.28.0: dependencies: events-universal: 1.0.1 @@ -8717,13 +8522,6 @@ snapshots: strip-bom@3.0.0: {} - styled-jsx@5.1.1(@babel/core@7.28.5)(react@19.2.6): - dependencies: - client-only: 0.0.1 - react: 19.2.6 - optionalDependencies: - '@babel/core': 7.28.5 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.6): dependencies: client-only: 0.0.1 @@ -9040,8 +8838,6 @@ snapshots: - esbuild - uglify-js - whatwg-fetch@3.6.20: {} - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index 8dc0b44..4b5d077 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -197,7 +197,8 @@ const Home = async ({ searchParams }: PageProps) => { const authService = new AuthService(user) - const copilot = new CopilotAPI(user.token) + // user.portalId is the workspaceId decoded during User.authenticate. + const copilot = new CopilotAPI(user.portalId) const [rawConnection, workspace] = await Promise.all([ authService.authorizeXeroForCopilotWorkspace(true), copilot.getWorkspace(), diff --git a/src/features/failed-syncs/lib/RetryFailedSyncs.service.ts b/src/features/failed-syncs/lib/RetryFailedSyncs.service.ts index 1550828..5328859 100644 --- a/src/features/failed-syncs/lib/RetryFailedSyncs.service.ts +++ b/src/features/failed-syncs/lib/RetryFailedSyncs.service.ts @@ -52,7 +52,7 @@ class RetryFailedSyncsService { continue } - const products = await new CopilotAPI(token).getProductsMapById([productId]) + const products = await new CopilotAPI(user.portalId).getProductsMapById([productId]) const product = products[productId] if (!product) { logger.warn( diff --git a/src/lib/copilot/CopilotAPI.ts b/src/lib/copilot/CopilotAPI.ts index 2011d5c..3811034 100644 --- a/src/lib/copilot/CopilotAPI.ts +++ b/src/lib/copilot/CopilotAPI.ts @@ -1,8 +1,7 @@ import 'server-only' +import { assemblyApi, type AssemblyAPI as SDK } from '@assembly-js/node-sdk' import { type InvoiceCreatedEvent, InvoiceCreatedEventSchema } from '@invoice-sync/types' -import type { CopilotAPI as SDK } from 'copilot-node-sdk' -import { copilotApi } from 'copilot-node-sdk' import z from 'zod' import env from '@/config/server.env' import { MAX_FETCH_COPILOT_RESOURCES } from '@/constants/limits' @@ -28,8 +27,6 @@ import { type NotificationCreatedResponse, NotificationCreatedResponseSchema, type NotificationRequestBody, - type Token, - TokenSchema, type WorkspaceResponse, WorkspaceResponseSchema, } from '@/lib/copilot/types' @@ -37,37 +34,24 @@ import { withRetry } from '@/lib/copilot/withRetry' import logger from '@/lib/logger' export class CopilotAPI { - readonly copilot: SDK + private sdkPromise?: Promise - constructor( - private readonly token: string, - readonly customApiKey?: string, - ) { - this.copilot = copilotApi({ - apiKey: customApiKey ?? env.COPILOT_API_KEY, - token, - }) + constructor(private readonly workspaceId: string) {} + + // Made on first use, reused after. Scoped by workspace key with no request + // token: the token expires mid-run and breaks long syncs; the key does not. + private get sdk(): Promise { + this.sdkPromise ??= assemblyApi({ apiKey: `${this.workspaceId}/${env.COPILOT_API_KEY}` }) + return this.sdkPromise } // 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()) + const sdk = await this.sdk + return WorkspaceResponseSchema.parse(await sdk.retrieveWorkspace()) } async _createClient( @@ -75,44 +59,52 @@ export class CopilotAPI { sendInvite: boolean = false, ): Promise { logger.info('CopilotAPI#_createClient', requestBody, sendInvite) - return ClientResponseSchema.parse(await this.copilot.createClient({ sendInvite, requestBody })) + const sdk = await this.sdk + return ClientResponseSchema.parse(await sdk.createClient({ sendInvite, requestBody })) } async _getClient(id: string): Promise { logger.info('CopilotAPI#_getClient', id) - return ClientResponseSchema.parse(await this.copilot.retrieveClient({ id })) + const sdk = await this.sdk + 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)) + const sdk = await this.sdk + return ClientsResponseSchema.parse(await sdk.listClients(args)) } async _updateClient(id: string, requestBody: ClientRequest): Promise { logger.info('CopilotAPI#_updateClient', id) - return ClientResponseSchema.parse(await this.copilot.updateClient({ id, requestBody })) + const sdk = await this.sdk + return ClientResponseSchema.parse(await sdk.updateClient({ id, requestBody })) } async _deleteClient(id: string) { logger.info('CopilotAPI#_deleteClient', id) - return await this.copilot.deleteClient({ id }) + const sdk = await this.sdk + return await sdk.deleteClient({ id }) } async _createCompany(requestBody: CompanyCreateRequest) { logger.info('CopilotAPI#_createCompany', requestBody) - return CompanyResponseSchema.parse(await this.copilot.createCompany({ requestBody })) + const sdk = await this.sdk + return CompanyResponseSchema.parse(await sdk.createCompany({ requestBody })) } async _getCompany(id: string): Promise { logger.info('CopilotAPI#_getCompany', id) - return CompanyResponseSchema.parse(await this.copilot.retrieveCompany({ id })) + const sdk = await this.sdk + 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)) + const sdk = await this.sdk + return CompaniesResponseSchema.parse(await sdk.listCompanies(args)) } async _getCompanyClients(companyId: string): Promise { @@ -122,19 +114,22 @@ export class CopilotAPI { async _getInternalUsers(args: CopilotListArgs = {}): Promise { logger.info('CopilotAPI#_getInternalUsers', args) - return InternalUsersResponseSchema.parse(await this.copilot.listInternalUsers(args)) + const sdk = await this.sdk + return InternalUsersResponseSchema.parse(await sdk.listInternalUsers(args)) } async _getInternalUser(id: string): Promise { logger.info('CopilotAPI#_getInternalUser', id) - return InternalUserSchema.parse(await this.copilot.retrieveInternalUser({ id })) + const sdk = await this.sdk + return InternalUserSchema.parse(await sdk.retrieveInternalUser({ id })) } async _createNotification( requestBody: NotificationRequestBody, ): Promise { logger.info('CopilotAPI#_createNotification', requestBody) - const notification = await this.copilot.createNotification({ requestBody }) + const sdk = await this.sdk + const notification = await sdk.createNotification({ requestBody }) return NotificationCreatedResponseSchema.parse(notification) } @@ -146,7 +141,8 @@ export class CopilotAPI { productIds: string[] | 'all', args: CopilotListArgs = { limit: MAX_FETCH_COPILOT_RESOURCES }, ): Promise> { - const allProductsResponse = await this.copilot.listProducts(args) + const sdk = await this.sdk + const allProductsResponse = await sdk.listProducts(args) if (!allProductsResponse.data) return {} const allProducts = z.array(CopilotProductSchema).parse(allProductsResponse.data) @@ -165,9 +161,10 @@ export class CopilotAPI { */ async _getPrices( priceIds: string[] | 'all', - args = { limit: '10_000' }, + args = { limit: 10_000 }, ): Promise> { - const allPricesResponse = await this.copilot.listPrices(args) + const sdk = await this.sdk + const allPricesResponse = await sdk.listPrices(args) if (!allPricesResponse.data) return {} const allPrices = z.array(CopilotPriceSchema).parse(allPricesResponse.data) @@ -186,9 +183,8 @@ export class CopilotAPI { */ async _getInvoice(copilotInvoiceId: string): Promise { logger.info('CopilotAPI#_getInvoice', copilotInvoiceId) - return InvoiceCreatedEventSchema.parse( - await this.copilot.retrieveInvoice({ id: copilotInvoiceId }), - ) + const sdk = await this.sdk + return InvoiceCreatedEventSchema.parse(await sdk.retrieveInvoice({ id: copilotInvoiceId })) } private wrapWithRetry( @@ -198,7 +194,6 @@ 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) diff --git a/src/lib/copilot/models/User.model.ts b/src/lib/copilot/models/User.model.ts index 70451d0..85b8d6a 100644 --- a/src/lib/copilot/models/User.model.ts +++ b/src/lib/copilot/models/User.model.ts @@ -1,9 +1,9 @@ import { z } from 'zod' import { CopilotAPI } from '@/lib/copilot/CopilotAPI' -import CopilotConnectionError from '@/lib/copilot/errors/CopilotConnectionError' import CopilotInvalidTokenError from '@/lib/copilot/errors/CopilotInvalidTokenError' import CopilotNoTokenError from '@/lib/copilot/errors/CopilotNoTokenError' import type { Token } from '@/lib/copilot/types' +import { getAssemblyTokenPayload } from '@/lib/copilot/utils' import logger from '@/lib/logger' class User { @@ -18,7 +18,7 @@ class User { ) { this.internalUserId = tokenPayload.internalUserId this.portalId = tokenPayload.workspaceId - this.copilot = copilot || new CopilotAPI(token) + this.copilot = copilot || new CopilotAPI(tokenPayload.workspaceId) } /** @@ -26,8 +26,7 @@ class User { * @param token * @returns User instance modeled from the token payload * @throws CopilotNoTokenError when no token is provided - * @throws CopilotInvalidTokenError when the token is invalid - * @throws CopilotConnectionError when unable to connect to Copilot API + * @throws CopilotInvalidTokenError when the token is invalid or cannot be decoded */ static async authenticate(token?: unknown): Promise { if (!token) { @@ -41,23 +40,12 @@ class User { throw new CopilotInvalidTokenError() } - 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 CopilotInvalidTokenError('Unable to authorize Copilot with provided token') - } - 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 CopilotInvalidTokenError('Unable to decode Copilot token payload') } - return new User(tokenParsed.data, tokenPayload, copilot) + 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 0e77822..c5f48fa 100644 --- a/src/lib/copilot/services/base.service.ts +++ b/src/lib/copilot/services/base.service.ts @@ -9,7 +9,7 @@ class BaseService { db: DB constructor(protected readonly user: User) { - this.copilot = new CopilotAPI(user.token) + this.copilot = new CopilotAPI(user.portalId) this.db = db } diff --git a/src/lib/copilot/types.ts b/src/lib/copilot/types.ts index f45f66c..0a784d6 100644 --- a/src/lib/copilot/types.ts +++ b/src/lib/copilot/types.ts @@ -124,24 +124,22 @@ export const NotificationRequestBodySchema = z.object({ 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(), + deliveryTargets: z.object({ + inProduct: z + .object({ + title: z.string(), + body: z.string().optional(), + }) + .optional(), + email: z + .object({ + subject: z.string(), + header: z.string(), + title: z.string(), + body: z.string().optional(), + }) + .optional(), + }), }) export type NotificationRequestBody = z.infer diff --git a/src/lib/copilot/utils.ts b/src/lib/copilot/utils.ts index df67a30..a078bb9 100644 --- a/src/lib/copilot/utils.ts +++ b/src/lib/copilot/utils.ts @@ -1,3 +1,8 @@ +import { assemblyApi } from '@assembly-js/node-sdk' +import env from '@/config/server.env' +import { type Token, TokenSchema } from '@/lib/copilot/types' +import { withRetry } from '@/lib/copilot/withRetry' +import logger from '@/lib/logger' import type { ClientResponse } from './types' export const buildClientName = (client: { givenName: string; familyName: string }) => @@ -15,3 +20,23 @@ export const getEarliestActiveClient = (clients: ClientResponse[]): ClientRespon : earliest, undefined, ) + +const decodeTokenPayload = async (token: string): Promise => { + // Decoding needs the request token, so scope the SDK by token here. + const sdk = await assemblyApi({ apiKey: env.COPILOT_API_KEY, token }) + if (!sdk.getTokenPayload) { + throw new Error('getAssemblyTokenPayload | SDK cannot decode token') + } + return TokenSchema.parse(await sdk.getTokenPayload()) +} + +// Decodes a request token. Returns null on failure so callers map it to a +// typed auth error. +export async function getAssemblyTokenPayload(token: string): Promise { + try { + return await withRetry(decodeTokenPayload, [token]) + } catch (err) { + logger.error('getAssemblyTokenPayload | failed to decode token payload', err) + return null + } +} From 4bb056dc3f63cd4791ff2549c9ea492040026209 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 21:29:03 +0545 Subject: [PATCH 2/4] test(OUT-4067): guard cross-portal isolation for the sdk migration - Mock the Assembly SDK to decode tokens (or fall back to the seeded portal), keeping its broken ESM build out of the test graph - Assert each portal builds a CopilotAPI scoped to its own workspaceId - Assert failed-sync retries hit each portal's own Xero tenant, never another portal's Co-Authored-By: Claude Opus 4.8 --- test/helpers/mocks.ts | 5 - test/helpers/tokenMock.ts | 27 +++++ test/integration/crossPortalIsolation.test.ts | 102 ++++++++++++++++++ test/integration/setup.ts | 13 ++- 4 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 test/helpers/tokenMock.ts create mode 100644 test/integration/crossPortalIsolation.test.ts diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 6e91015..cdff9dd 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -4,7 +4,6 @@ import { TEST_COMPANY, TEST_EXPENSE_ACCOUNT, TEST_INVOICE, - TEST_PORTAL, TEST_PRODUCT, TEST_SALES_ACCOUNT, TEST_XERO_BANK_TXN, @@ -31,10 +30,6 @@ type XeroAPIOverrides = MockMethodOverrides // Mocked CopilotAPI. Override any method per test via `overrides`. export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) { return { - getTokenPayload: vi.fn().mockResolvedValue({ - workspaceId: TEST_PORTAL.id, - internalUserId: TEST_PORTAL.internalUserId, - }), getClient: vi.fn().mockResolvedValue({ id: TEST_CLIENT.id, givenName: TEST_CLIENT.givenName, diff --git a/test/helpers/tokenMock.ts b/test/helpers/tokenMock.ts new file mode 100644 index 0000000..44be854 --- /dev/null +++ b/test/helpers/tokenMock.ts @@ -0,0 +1,27 @@ +import crypto from 'node:crypto' +import { TEST_PORTAL } from '@test/helpers/constants' +import env from '@/config/server.env' + +// Payload the Assembly SDK mock returns. Decodes a real encoded token to its +// own workspaceId (like the SDK); falls back to TEST_PORTAL for stub tokens. +export function decodeTokenPayloadOrDefault(token?: string) { + if (token) { + try { + const key = crypto.createHmac('sha256', env.COPILOT_API_KEY).digest('hex').slice(0, 32) + const buf = Buffer.from(token, 'hex') + const decipher = crypto.createDecipheriv( + 'aes-128-cbc', + Buffer.from(key, 'hex'), + buf.subarray(0, 16), + ) + const json = Buffer.concat([decipher.update(buf.subarray(16)), decipher.final()]).toString( + 'utf-8', + ) + const parsed = JSON.parse(json) as { workspaceId?: unknown } + if (typeof parsed.workspaceId === 'string') return parsed + } catch { + // Not a real encoded token — fall back to the seeded default below. + } + } + return { workspaceId: TEST_PORTAL.id, internalUserId: TEST_PORTAL.internalUserId } +} diff --git a/test/integration/crossPortalIsolation.test.ts b/test/integration/crossPortalIsolation.test.ts new file mode 100644 index 0000000..8a22e32 --- /dev/null +++ b/test/integration/crossPortalIsolation.test.ts @@ -0,0 +1,102 @@ +import RetryFailedSyncsService from '@failed-syncs/lib/RetryFailedSyncs.service' +import { ValidWebhookEvent } from '@invoice-sync/types' +import { TEST_PORTAL } from '@test/helpers/constants' +import { seedConnectedPortal } from '@test/helpers/seed' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { describe, expect, it, vi } from 'vitest' +import env from '@/config/server.env' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedItems } from '@/db/schema/syncedItems.schema' +import { CopilotAPI } from '@/lib/copilot/CopilotAPI' +import User from '@/lib/copilot/models/User.model' +import { encodePayload } from '@/utils/crypto' + +// A second portal to prove per-portal isolation against the seeded TEST_PORTAL. +const PORTAL_A = TEST_PORTAL +const PORTAL_B = { + id: 'test-portal-00000002', + tenantId: '77777777-7777-4777-8777-777777777777', + internalUserId: '88888888-8888-4888-8888-888888888888', +} + +describe('cross-portal isolation', () => { + const apis = setupWebhookTest() + + it('builds each portal a CopilotAPI scoped to its own token, never a shared workspaceId', async () => { + const tokenA = encodePayload(env.COPILOT_API_KEY, { workspaceId: PORTAL_A.id }) + const tokenB = encodePayload(env.COPILOT_API_KEY, { workspaceId: PORTAL_B.id }) + + const userA = await User.authenticate(tokenA) + const userB = await User.authenticate(tokenB) + + // Each user's portal comes from its own token. + expect(userA.portalId).toBe(PORTAL_A.id) + expect(userB.portalId).toBe(PORTAL_B.id) + expect(userA.portalId).not.toBe(userB.portalId) + + // The Copilot client for each was constructed with that portal's workspaceId. + const workspaceIds = vi.mocked(CopilotAPI).mock.calls.map((call) => call[0]) + expect(workspaceIds).toContain(PORTAL_A.id) + expect(workspaceIds).toContain(PORTAL_B.id) + }) + + it('retries each portal against its own Xero tenant, never another portal’s', async () => { + // Two connected portals, each with sync enabled. + await seedConnectedPortal() + await seedConnectedPortal({ + connection: { + portalId: PORTAL_B.id, + tenantId: PORTAL_B.tenantId, + initiatedBy: PORTAL_B.internalUserId, + }, + settings: { portalId: PORTAL_B.id, tenantId: PORTAL_B.tenantId }, + }) + + const productA = { id: 'product-a', name: 'Product A', description: 'Belongs to portal A' } + const productB = { id: 'product-b', name: 'Product B', description: 'Belongs to portal B' } + + // One product.created failed sync per portal. + await db.insert(failedSyncs).values([ + { + portalId: PORTAL_A.id, + tenantId: PORTAL_A.tenantId, + type: ValidWebhookEvent.ProductCreated, + token: encodePayload(env.COPILOT_API_KEY, { workspaceId: PORTAL_A.id }), + resourceId: productA.id, + attempts: 0, + payload: productA, + }, + { + portalId: PORTAL_B.id, + tenantId: PORTAL_B.tenantId, + type: ValidWebhookEvent.ProductCreated, + token: encodePayload(env.COPILOT_API_KEY, { workspaceId: PORTAL_B.id }), + resourceId: productB.id, + attempts: 0, + payload: productB, + }, + ]) + + await new RetryFailedSyncsService().retryFailedSyncs() + + // Each portal's Xero item was created against that portal's tenant. + const createCalls = apis.xero.createItems.mock.calls + const callA = createCalls.find(([, items]) => items[0]?.name === productA.name) + const callB = createCalls.find(([, items]) => items[0]?.name === productB.name) + + expect(callA?.[0]).toBe(PORTAL_A.tenantId) + expect(callB?.[0]).toBe(PORTAL_B.tenantId) + // The core guard: no portal's product reached the other's Xero tenant. + expect(callA?.[0]).not.toBe(PORTAL_B.tenantId) + expect(callB?.[0]).not.toBe(PORTAL_A.tenantId) + + // And the DB mapping landed under the right portal + tenant. + const items = await db.select().from(syncedItems) + expect(items.find((row) => row.portalId === PORTAL_A.id)?.tenantId).toBe(PORTAL_A.tenantId) + expect(items.find((row) => row.portalId === PORTAL_B.id)?.tenantId).toBe(PORTAL_B.tenantId) + + // Both dispatched successfully, so no failed_syncs remain. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) diff --git a/test/integration/setup.ts b/test/integration/setup.ts index bc61a8f..00efc4f 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -1,3 +1,4 @@ +import { decodeTokenPayloadOrDefault } from '@test/helpers/tokenMock' import { vi } from 'vitest' /** @@ -7,8 +8,8 @@ import { vi } from 'vitest' * (see test/helpers/mocks.ts). * * Why explicit factories: importing the real CopilotAPI/XeroAPI modules pulls in - * external SDKs (copilot-node-sdk, xero-node) that run side effects and read env - * at import time. The factory keeps the real modules out of the graph entirely. + * external SDKs (@assembly-js/node-sdk, xero-node) that run side effects and read + * env at import time. The factory keeps the real modules out of the graph entirely. * * Why pin mocks on globalThis: this setupFile can be evaluated more than once per * run under `pool: 'forks' + fileParallelism: false + isolate: false`. Pinning the @@ -37,6 +38,14 @@ vi.mock('@/lib/xero/XeroAPI', () => ({ default: mocks.XeroAPI, })) +// Mock the Assembly SDK so tests skip the network and never import its broken +// ESM build. getTokenPayload decodes the token (see tokenMock). +vi.mock('@assembly-js/node-sdk', () => ({ + assemblyApi: vi.fn(async ({ token }: { token?: string }) => ({ + getTokenPayload: vi.fn(async () => decodeTokenPayloadOrDefault(token)), + })), +})) + // withRetry.ts calls `scope.addEventProcessor(...)` inside Sentry.withScope, so // the mock must cover every method the withScope callback touches. vi.mock('@sentry/nextjs', () => ({ From 727bbbb4b9a5abb4b9c7b1438b52ff4b85a69d6a Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 24 Aug 2026 13:56:40 +0545 Subject: [PATCH 3/4] fix(OUT-4067): require keyless env mode for assembly sdk auth The workspace-scoped keyless auth path (new CopilotAPI(portalId)) only authorizes when ASSEMBLY_ENV/COPILOT_ENV is a keyless mode; otherwise the SDK throws at the first API call. Validate it at boot so a misconfigured runtime fails fast with a clear message instead of 500ing every request. Co-Authored-By: Claude Opus 4.8 --- .env.test | 2 ++ src/config/server.env.ts | 46 ++++++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.env.test b/.env.test index 8480a5b..e9a2c60 100644 --- a/.env.test +++ b/.env.test @@ -8,6 +8,8 @@ # Copilot COPILOT_API_KEY=test-copilot-api-key +# Assembly SDK authorizes with the workspace-scoped key only in these env modes. +COPILOT_ENV=local # Xero OAuth app XERO_CLIENT_ID=test-xero-client-id diff --git a/src/config/server.env.ts b/src/config/server.env.ts index ccfbeb0..0e0e1f6 100644 --- a/src/config/server.env.ts +++ b/src/config/server.env.ts @@ -2,22 +2,40 @@ import 'server-only' import { z } from 'zod' -const ServerEnvSchema = z.object({ - COPILOT_API_KEY: z.string().min(1), - DATABASE_URL: z.url(), - XERO_CLIENT_ID: z.string().min(1), - XERO_CLIENT_SECRET: z.string().min(1), - XERO_CALLBACK_URL: z.url(), - XERO_SCOPES: z.string().min(1), - VERCEL_ENV: z.string().optional(), +// Env modes where the SDK authorizes with the workspace-scoped key (no token). +// Mirrors the allow-list in @assembly-js/node-sdk/dist/api/init.js (assemblyApi); +// re-check it when bumping the SDK, as it is not exported. +const ASSEMBLY_KEYLESS_ENV_MODES = ['local', '__SECRET_STAGING__'] - // Flags - FLAG_DISABLE_NOTIFICATION_EMAILS: z.coerce.boolean().default(false), - FLAG_ENABLE_DELETE_SYNC: z.coerce.boolean().default(false), +const ServerEnvSchema = z + .object({ + COPILOT_API_KEY: z.string().min(1), + DATABASE_URL: z.url(), + XERO_CLIENT_ID: z.string().min(1), + XERO_CLIENT_SECRET: z.string().min(1), + XERO_CALLBACK_URL: z.url(), + XERO_SCOPES: z.string().min(1), + VERCEL_ENV: z.string().optional(), + ASSEMBLY_ENV: z.string().optional(), + COPILOT_ENV: z.string().optional(), - // CRON - CRON_SECRET: z.string().optional(), -}) + // Flags + FLAG_DISABLE_NOTIFICATION_EMAILS: z.coerce.boolean().default(false), + FLAG_ENABLE_DELETE_SYNC: z.coerce.boolean().default(false), + + // CRON + CRON_SECRET: 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 one of: ${ASSEMBLY_KEYLESS_ENV_MODES.join(', ')}. Set it in every runtime (Vercel).`, + }) + } + }) const env = ServerEnvSchema.parse(process.env) export default env From 010c1cad9e950a67b400984fdcdd93116c80d176 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 24 Aug 2026 14:24:13 +0545 Subject: [PATCH 4/4] refactor(OUT-4067): fold env-mode precedence into a single enum Merge ASSEMBLY_ENV/COPILOT_ENV once at parse time (matching the SDK's getEnvMode) and validate the single value with z.enum, replacing the cross-field superRefine. Co-Authored-By: Claude Opus 4.8 --- src/config/server.env.ts | 54 +++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/config/server.env.ts b/src/config/server.env.ts index 0e0e1f6..4fd78c8 100644 --- a/src/config/server.env.ts +++ b/src/config/server.env.ts @@ -5,37 +5,33 @@ import { z } from 'zod' // Env modes where the SDK authorizes with the workspace-scoped key (no token). // Mirrors the allow-list in @assembly-js/node-sdk/dist/api/init.js (assemblyApi); // re-check it when bumping the SDK, as it is not exported. -const ASSEMBLY_KEYLESS_ENV_MODES = ['local', '__SECRET_STAGING__'] +const ASSEMBLY_KEYLESS_ENV_MODES = ['local', '__SECRET_STAGING__'] as const -const ServerEnvSchema = z - .object({ - COPILOT_API_KEY: z.string().min(1), - DATABASE_URL: z.url(), - XERO_CLIENT_ID: z.string().min(1), - XERO_CLIENT_SECRET: z.string().min(1), - XERO_CALLBACK_URL: z.url(), - XERO_SCOPES: z.string().min(1), - VERCEL_ENV: z.string().optional(), - ASSEMBLY_ENV: z.string().optional(), - COPILOT_ENV: z.string().optional(), +const ServerEnvSchema = z.object({ + COPILOT_API_KEY: z.string().min(1), + DATABASE_URL: z.url(), + XERO_CLIENT_ID: z.string().min(1), + XERO_CLIENT_SECRET: z.string().min(1), + XERO_CALLBACK_URL: z.url(), + XERO_SCOPES: z.string().min(1), + VERCEL_ENV: z.string().optional(), + // Effective SDK env mode; merged from ASSEMBLY_ENV/COPILOT_ENV at parse below. + ASSEMBLY_ENV: z.enum(ASSEMBLY_KEYLESS_ENV_MODES, { + error: `ASSEMBLY_ENV (or COPILOT_ENV) must be one of: ${ASSEMBLY_KEYLESS_ENV_MODES.join(', ')}. Set it in every runtime (Vercel).`, + }), - // Flags - FLAG_DISABLE_NOTIFICATION_EMAILS: z.coerce.boolean().default(false), - FLAG_ENABLE_DELETE_SYNC: z.coerce.boolean().default(false), + // Flags + FLAG_DISABLE_NOTIFICATION_EMAILS: z.coerce.boolean().default(false), + FLAG_ENABLE_DELETE_SYNC: z.coerce.boolean().default(false), - // CRON - CRON_SECRET: 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 one of: ${ASSEMBLY_KEYLESS_ENV_MODES.join(', ')}. Set it in every runtime (Vercel).`, - }) - } - }) + // CRON + CRON_SECRET: z.string().optional(), +}) -const env = ServerEnvSchema.parse(process.env) +// Collapse the precedence once here, matching the SDK's getEnvMode +// (ASSEMBLY_ENV ?? COPILOT_ENV), so our check and the SDK never disagree. +const env = ServerEnvSchema.parse({ + ...process.env, + ASSEMBLY_ENV: process.env.ASSEMBLY_ENV ?? process.env.COPILOT_ENV, +}) export default env