diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7f914038..68382201 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,6 +42,16 @@ jobs: run: npm run test working-directory: ./keeperapi + - name: SDK - Install + run: npm ci + working-directory: ./KeeperSdk + env: + NPM_TOKEN: "" + + - name: SDK - Check Formatting + run: npm run format:check + working-directory: ./KeeperSdk + - name: Examples (node) - Installation run: npm run link-local working-directory: ./examples/print-vault-node diff --git a/KeeperSdk/README.md b/KeeperSdk/README.md index 62fa48b8..bb49b99d 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -13,17 +13,21 @@ npm install @keeper-security/keeper-sdk-javascript ## Quickstart ```typescript -import { KeeperVault, ConsoleAuthUI, FileConfigLoader } from '@keeper-security/keeper-sdk-javascript' +import { + KeeperVault, + ConsoleAuthUI, + FileConfigLoader, +} from "@keeper-security/keeper-sdk-javascript"; const vault = new KeeperVault({ - authUI: new ConsoleAuthUI(), - configLoader: new FileConfigLoader('./keeper-config.json'), -}) + authUI: new ConsoleAuthUI(), + configLoader: new FileConfigLoader("./keeper-config.json"), +}); -await vault.login() -await vault.syncDown() +await vault.login(); +await vault.syncDown(); -console.log(`Loaded ${vault.records.size} records`) +console.log(`Loaded ${vault.records.size} records`); ``` ## Supported functionality diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index c1ee8b4a..1fabc359 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -1,623 +1,627 @@ { - "name": "@keeper-security/keeper-sdk-javascript", - "version": "1.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@keeper-security/keeper-sdk-javascript", - "version": "1.1.0", - "license": "ISC", - "dependencies": { - "@keeper-security/keeperapi": "17.2.7", - "ts-node": "^10.7.0", - "typescript": "^4.6.3" - }, - "devDependencies": { - "@types/node": "^25.6.0", - "prettier": "^3.8.1" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@keeper-security/keeperapi": { - "version": "17.2.7", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.2.7.tgz", - "integrity": "sha512-VRCRn6Y2sqxpjQHSSEOgo4qiJpx8XExvEgq9oqhBH5XX2Z8GTs4sZ2vyYN5Kaa3WibGSfXvezwdzATo5vZwVEA==", - "license": "ISC", - "dependencies": { - "@noble/post-quantum": "^0.5.2", - "asmcrypto.js": "^2.3.2", - "faye-websocket": "^0.11.3", - "form-data": "^4.0.4", - "node-rsa": "^1.0.8" - } - }, - "node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/post-quantum": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz", - "integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~2.0.0", - "@noble/hashes": "~2.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "license": "MIT" - }, - "node_modules/asmcrypto.js": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", - "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", - "license": "MIT" - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "license": "MIT" - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-rsa": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", - "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", - "license": "MIT", - "dependencies": { - "asn1": "^0.2.4" - } - }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "license": "MIT" - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } + "name": "@keeper-security/keeper-sdk-javascript", + "version": "1.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@keeper-security/keeper-sdk-javascript", + "version": "1.1.1", + "license": "ISC", + "dependencies": { + "@keeper-security/keeperapi": "18.0.6", + "asmcrypto.js": "^2.3.2", + "ts-node": "^10.7.0", + "typescript": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "prettier": "^3.8.1" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@keeper-security/keeperapi": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.6.tgz", + "integrity": "sha512-7HE9mBBnXJ3K5obWQ77J2arjlCVNpUnwSW+2oSVJKCZdQD2BkqEthigB45VK0uSQ1LxPB7R8vnos7lMpvMsVHA==", + "license": "ISC", + "dependencies": { + "@noble/post-quantum": "^0.5.2", + "asmcrypto.js": "^2.3.2", + "faye-websocket": "^0.11.3", + "form-data": "^4.0.4", + "node-rsa": "^1.0.8" + }, + "engines": { + "node": ">=24.13.1" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz", + "integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~2.0.0", + "@noble/hashes": "~2.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, + "node_modules/asmcrypto.js": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", + "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-rsa": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", + "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", + "license": "MIT", + "dependencies": { + "asn1": "^0.2.4" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true } + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "license": "MIT" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } } + } } diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index a6e66e17..76831123 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -1,32 +1,36 @@ { - "name": "@keeper-security/keeper-sdk-javascript", - "version": "1.1.0", - "description": "High-level wrapper for Keeper Security JavaScript SDK", - "repository": { - "type": "git", - "url": "git+https://github.com/Keeper-Security/keeper-sdk-javascript.git", - "directory": "KeeperSdk" - }, - "license": "ISC", - "main": "src/index.ts", - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "link-local": "npm link ../keeperapi", - "format": "prettier --write .", - "format:check": "prettier --check .", - "test": "jest", - "types": "tsc --watch", - "types:ci": "tsc", - "prepublishOnly": "npm run build" - }, - "dependencies": { - "@keeper-security/keeperapi": "17.2.7", - "ts-node": "^10.7.0", - "typescript": "^4.6.3" - }, - "devDependencies": { - "@types/node": "^25.6.0", - "prettier": "^3.8.1" - } + "name": "@keeper-security/keeper-sdk-javascript", + "version": "1.1.1", + "description": "High-level wrapper for Keeper Security JavaScript SDK", + "repository": { + "type": "git", + "url": "git+https://github.com/Keeper-Security/keeper-sdk-javascript.git", + "directory": "KeeperSdk" + }, + "license": "ISC", + "main": "dist/index.js", + "browser": "dist/browser.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist node_modules", + "rebuild": "npm run clean && npm install && npm run link-local && npm run build", + "link-local": "npm link ../keeperapi", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "jest", + "types": "tsc --watch", + "types:ci": "tsc", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@keeper-security/keeperapi": "18.0.6", + "ts-node": "^10.7.0", + "asmcrypto.js": "^2.3.2", + "typescript": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "prettier": "^3.8.1" + } } diff --git a/KeeperSdk/src/account/whoamiInfo.ts b/KeeperSdk/src/account/whoamiInfo.ts new file mode 100644 index 00000000..a0dc0210 --- /dev/null +++ b/KeeperSdk/src/account/whoamiInfo.ts @@ -0,0 +1,122 @@ +import type { AccountSummary } from "@keeper-security/keeperapi"; +import type { VaultSummary } from "../vault/KeeperVault"; +import { KEEPER_PUBLIC_HOSTS } from "../utils/constants"; + +export type WhoamiInfo = { + user: string; + server: string; + dataCenter: string; + admin: boolean; + accountType: string; + renewalDate: string; + storageCapacity: string; + storageUsage: string; + storageRenewalDate: string; + breachWatch: boolean; + reportingAndAlerts: boolean; + recordsCount?: number; + sharedFoldersCount?: number; + teamsCount?: number; +}; + +export type BuildWhoamiInfoInput = { + username: string; + host: string; + accountSummary: AccountSummary.IAccountSummaryElements; + vaultSummary?: VaultSummary; +}; + +export function normalizeServerHost(host: string): string { + return host + .toLowerCase() + .trim() + .replace(/^(qa|dev|dev2|local)\./, ""); +} + +export function resolveDataCenter(host: string): string { + const normalized = normalizeServerHost(host); + for (const [dataCenter, publicHost] of Object.entries(KEEPER_PUBLIC_HOSTS)) { + if (normalized === publicHost || normalized.endsWith(`.${publicHost}`)) { + return dataCenter; + } + } + return "US"; +} + +export function buildWhoamiInfo(input: BuildWhoamiInfoInput): WhoamiInfo { + const license = + input.accountSummary.license ?? input.accountSummary.personalLicense ?? {}; + const server = normalizeServerHost(input.host); + + const info: WhoamiInfo = { + user: input.username, + server, + dataCenter: resolveDataCenter(input.host), + admin: !!input.accountSummary.isEnterpriseAdmin, + accountType: formatAccountType(license), + renewalDate: formatRenewalDate(license), + storageCapacity: formatStorageCapacity(license.bytesTotal), + storageUsage: formatStorageUsage(license.bytesUsed, license.bytesTotal), + storageRenewalDate: formatRenewalDateField( + license.storageExpirationDate, + license.storageExpiration, + ), + breachWatch: isBreachWatchEnabled(license), + reportingAndAlerts: !!license.auditAndReportingEnabled, + }; + + if (input.vaultSummary) { + info.recordsCount = input.vaultSummary.recordCount; + info.sharedFoldersCount = input.vaultSummary.sharedFolderCount; + info.teamsCount = input.vaultSummary.teamCount; + } + + return info; +} + +function formatAccountType(license: AccountSummary.ILicense): string { + const name = (license.productTypeName ?? "").trim(); + if (name) return name; + if (license.accountType != null) return String(license.accountType); + return "Unknown"; +} + +function formatRenewalDate(license: AccountSummary.ILicense): string { + return formatRenewalDateField(license.expirationDate, license.expiration); +} + +function formatRenewalDateField( + dateString?: string | null, + timestamp?: number | null, +): string { + const trimmed = (dateString ?? "").trim(); + if (trimmed) return trimmed; + if (timestamp && timestamp > 0) { + return new Date(timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } + return ""; +} + +function formatStorageCapacity(bytes?: number | null): string { + if (!bytes || bytes <= 0) return "0GB"; + const gb = Math.round(bytes / 1024 ** 3); + return `${gb}GB`; +} + +function formatStorageUsage( + bytesUsed?: number | null, + bytesTotal?: number | null, +): string { + if (!bytesTotal || bytesTotal <= 0) return "0%"; + const pct = Math.round(((bytesUsed ?? 0) / bytesTotal) * 100); + return `${pct}%`; +} + +function isBreachWatchEnabled(license: AccountSummary.ILicense): boolean { + if (license.breachWatchFeatureDisable) return false; + return !!license.breachWatchEnabled; +} diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts new file mode 100644 index 00000000..2180444a --- /dev/null +++ b/KeeperSdk/src/api.ts @@ -0,0 +1,391 @@ +export { SessionManager } from "./auth/SessionManager"; +export type { + KeeperJsonConfig, + ConfigLoader, + ConfigurationUser, + ConfigurationServerConfig, + ConfigurationDeviceConfig, +} from "./auth/SessionManager"; +export { + connectSdkPlatform, + getSdkPlatform, + isSdkPlatformConnected, +} from "./platform"; +export type { SdkPlatform, SdkReadline, SdkRuntime } from "./platform"; + +export { InMemoryStorage } from "./storage/InMemoryStorage"; + +export { + Logger, + ConsoleLogger, + LogLevel, + logger, + setLogger, + getLogger, + resetLogger, + KeeperSdkError, + isKeeperError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + AuthDefaults, + ResultCodes, + KEEPER_PUBLIC_HOSTS, + isBoolean, + isString, + isNonEmptyString, + isNumber, + isObject, + anyIsBoolean, + EMAIL_PATTERN, + EMAIL_LIST_SEPARATOR_PATTERN, + isValidEmail, + DEFAULT_PASSWORD_LENGTH, + PW_SPECIAL_CHARACTERS, + GEN_PASSWORD_ALGORITHMS, + KeeperPasswordGenerator, + generatePasswordFromOptions, + resolveGenPasswordAlgorithm, + generatePassword, + parseGenParametersFromValue, + isGenerateFieldValue, + parseGeneratePasswordFlag, +} from "./utils"; +export type { + ILogger, + Nullable, + Optional, + DeepPartial, + Immutable, + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, +} from "./utils"; + +export { + searchRecords, + formatRecord, + formatRecordFields, + getRecordTitle, + getRecordType, + getRecordFields, + getRecordSummary, + getRecordDescription, + getRecordCategory, + getRecordPassword, + getRecordLogin, + getRecordUrl, + getRecordTotpUrl, + RecordVersion, +} from "./records/RecordUtils"; +export type { RecordSummary } from "./records/RecordUtils"; +export { parseTotpUrl, getTotpCode } from "./records/Totp"; +export type { TotpAlgorithm, TotpParams, TotpCode } from "./records/Totp"; +export { + addRecord, + updateRecord, + deleteRecord, + getRecordHistory, + moveRecord, +} from "./records/RecordOperations"; +export type { + PasswordRecordData, + TypedRecordData, + RecordFieldInput, + NewRecordInput, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + HistoryEntry, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from "./records/RecordOperations"; + +export { + shareRecord, + removeRecordShare, + getRecordShareInfo, + loadUserShareKeys, + encryptKeyForRecipient, + sendShareInviteIfNeeded, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, + buildNsfRecordSharePermission, + buildNsfRecordRevokePermission, +} from "./sharing/Sharing"; +export type { + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, + RecordShareInfo, + RecordUserPermission, + RecordSharedFolderPermission, + UserShareKeys, +} from "./sharing/Sharing"; + +export { KeeperVault } from "./vault/KeeperVault"; +export type { KeeperVaultConfig, VaultSummary } from "./vault/KeeperVault"; + +export { + buildWhoamiInfo, + normalizeServerHost, + resolveDataCenter, +} from "./account/whoamiInfo"; +export type { WhoamiInfo, BuildWhoamiInfoInput } from "./account/whoamiInfo"; + +export type { SessionRestoreInput } from "./auth/sessionRestore"; +export { + toSessionParams, + validateSessionRestoreInput, + sessionRestoreFromJson, + resolveSessionRestorePayload, +} from "./auth/sessionRestore"; + +export { getFolder, findFolder, GetFolderFormat } from "./folders/getFolder"; +export type { + GetFolderOptions, + GetFolderResult, + GetFolderResultFolder, + GetFolderResultSharedFolder, + GetFolderFormatInput, + FoundFolder, +} from "./folders/getFolder"; + +export { + FolderKind, + ParentFolderKind, + FolderObjectType, + FolderResultStatus, + DeleteResolution, + DeleteObjectType, + folderKindFromString, +} from "./folders/folderHelpers"; +export type { FolderKindOrLiteral } from "./folders/folderHelpers"; + +export { + listFolder, + findFolderUidByNameOrUid, + listRootUserFolders, +} from "./folders/listFolder"; +export type { + ListFolderOptions, + ListFolderResult, + ListFolderFolderSimple, + ListFolderRecordSimple, + ListFolderFolderDetail, + ListFolderRecordDetail, +} from "./folders/listFolder"; + +export { + listSharedFolders, + formatSharedFoldersTable, + renderSharedFoldersAsciiTable, +} from "./sharedFolders/listSharedFolders"; +export type { + ListSharedFoldersOptions, + ListSharedFolderRow, + FormattedSharedFoldersTable, +} from "./sharedFolders/listSharedFolders"; + +export { + shareFolder, + ShareFolderAction, + ShareFolderUserResultStatus, +} from "./sharedFolders/shareFolder"; +export type { + ShareFolderActionInput, + ShareFolderInput, + ShareFolderResult, + ShareFolderUserStatus, +} from "./sharedFolders/shareFolder"; + +export { + changeDirectory, + createVaultFolderSession, + tryResolvePath, + resolveSingleFolder, + getWorkingFolderDisplayName, + findParentFolderUid, + splitPathComponents, +} from "./folders/changeDirectory"; +export type { + VaultFolderSession, + ChangeDirectoryResult, + TryResolvePathResult, +} from "./folders/changeDirectory"; + +export { addFolder, mkdir } from "./folders/addFolder"; +export type { + AddFolderInput, + AddFolderResult, + MkdirOptions, +} from "./folders/addFolder"; + +export { + updateFolder, + renameFolder, + updateSharedFolderPermissions, +} from "./folders/updateFolder"; +export type { + UpdateFolderInput, + UpdateFolderResult, + RenameFolderResult, +} from "./folders/updateFolder"; + +export { + deleteFolder, + rmdir, + resolveRmdirPatternsToFolderUids, + buildFolderDeleteObject, +} from "./folders/deleteFolder"; +export type { DeleteFolderResult, RmdirOptions } from "./folders/deleteFolder"; + +export { + buildFolderTree, + renderFolderTreeAscii, + folderTreeAscii, + userPermissionToText, + recordPermissionToText, +} from "./folders/folderTree"; +export type { + FolderTreeBuildOptions, + FolderTreeNode, + FolderTreeResult, +} from "./folders/folderTree"; + +export { FolderManager } from "./folders/FolderManager"; +export type { + AuthProvider, + SharedFolderPermissionsInput, +} from "./folders/FolderManager"; + +export { SharedFolderManager } from "./sharedFolders/SharedFolderManager"; + +export { + listTeams, + formatTeamsTable, + renderTeamsAsciiTable, + formatTeamRestricts, + TeamColumn, + SUPPORTED_TEAM_COLUMNS, + DEFAULT_TEAM_COLUMNS, +} from "./teams/listTeams"; +export type { + ListTeamsOptions, + ListTeamRow, + TeamColumnInput, + FormattedTeamsTable, + FormatTeamsTableOptions, +} from "./teams/listTeams"; + +export { viewTeam, formatTeamView, teamViewTable } from "./teams/viewTeam"; +export type { + TeamView, + TeamRoleInfo, + TeamUserInfo, + FormatTeamViewOptions, + FormattedTeamViewTable, + TeamViewTableRow, +} from "./teams/viewTeam"; + +export { changeTeamRoles, TeamRoleStatus } from "./teams/teamRole"; +export type { + ChangeTeamRolesInput, + TeamRoleResult, + TeamRoleItemResult, +} from "./teams/teamRole"; + +export { + addUsersToTeams, + removeUsersFromTeams, + formatTeamUserResult, + renderTeamUserAsciiTable, + TeamUserStatus, + TeamUserSkipReason, +} from "./users/teamUser"; +export type { + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserItemResult, + TeamUserResult, + FormattedTeamUserTable, +} from "./users/userTypes"; + +export { + listUsers, + formatUsersTable, + renderUsersAsciiTable, + UserColumn, + SUPPORTED_USER_COLUMNS, + DEFAULT_USER_COLUMNS, +} from "./users/listUsers"; +export type { + UserColumnInput, + ListUsersOptions, + ListUserRow, + FormattedUsersTable, + FormatUsersTableOptions, +} from "./users/listUsers"; + +export { viewUser, formatUserView, userViewTable } from "./users/viewUser"; +export type { + UserView, + FormatUserViewOptions, + FormattedUserViewTable, + UserViewTableRow, +} from "./users/userTypes"; + +export { + listRoles, + formatRolesTable, + renderRolesAsciiTable, + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + DEFAULT_ROLE_COLUMNS, + ALL_COLUMNS_WILDCARD, + viewRole, + formatRoleView, + roleViewTable, +} from "./roles"; +export type { + ListRolesOptions, + ListRoleRow, + RoleColumnInput, + FormattedRolesTable, + FormatRolesTableOptions, + RoleView, + RoleTeamInfo, + RoleUserInfo, + RoleManagedNodeInfo, + RoleEnforcementInfo, + FormatRoleViewOptions, + FormattedRoleViewTable, + RoleViewTableRow, +} from "./roles"; + +export { + Auth, + KeeperEnvironment, + syncDown, + Authentication, +} from "@keeper-security/keeperapi"; + +export type { + DRecord, + DRecordMetadata, + DSharedFolder, + DTeam, + DUserFolder, + VaultStorage, + SyncResult, + SyncDownOptions, + ClientConfiguration, + DeviceConfig, + SessionStorage, + AuthUI3, + KeeperError, + LoginError, +} from "@keeper-security/keeperapi"; diff --git a/KeeperSdk/src/auth/ConsoleAuthUI.ts b/KeeperSdk/src/auth/ConsoleAuthUI.ts index 69a53da1..a0a79965 100644 --- a/KeeperSdk/src/auth/ConsoleAuthUI.ts +++ b/KeeperSdk/src/auth/ConsoleAuthUI.ts @@ -1,169 +1,208 @@ -import readline from 'readline/promises' -import { setTimeout as delay } from 'timers/promises' -import type { AuthUI3, DeviceApprovalChannel, TwoFactorChannelData } from '@keeper-security/keeperapi' -import { Authentication } from '@keeper-security/keeperapi' -import { logger, extractErrorMessage, KeeperSdkError, AuthDefaults, ResultCodes } from '../utils' +import type { + AuthUI3, + DeviceApprovalChannel, + TwoFactorChannelData, +} from "@keeper-security/keeperapi"; +import { Authentication } from "@keeper-security/keeperapi"; +import { getSdkPlatform } from "../platform"; +import { + logger, + extractErrorMessage, + KeeperSdkError, + AuthDefaults, + ResultCodes, +} from "../utils"; export class ConsoleAuthUI implements AuthUI3 { - private static readonly DEVICE_VERIFICATION = { - Email: 0, - KeeperPush: 1, - TFA: 2, - AdminApproval: 3, - } as const - - private static channelName(channel: number): string { - switch (channel) { - case ConsoleAuthUI.DEVICE_VERIFICATION.Email: - return 'Email Verification' - case ConsoleAuthUI.DEVICE_VERIFICATION.KeeperPush: - return 'Keeper Push' - case ConsoleAuthUI.DEVICE_VERIFICATION.TFA: - return 'Two-Factor Authentication' - case ConsoleAuthUI.DEVICE_VERIFICATION.AdminApproval: - return 'Admin Approval' - default: - return `Channel ${channel}` - } + private static readonly DEVICE_VERIFICATION = { + Email: 0, + KeeperPush: 1, + TFA: 2, + AdminApproval: 3, + } as const; + + private static channelName(channel: number): string { + switch (channel) { + case ConsoleAuthUI.DEVICE_VERIFICATION.Email: + return "Email Verification"; + case ConsoleAuthUI.DEVICE_VERIFICATION.KeeperPush: + return "Keeper Push"; + case ConsoleAuthUI.DEVICE_VERIFICATION.TFA: + return "Two-Factor Authentication"; + case ConsoleAuthUI.DEVICE_VERIFICATION.AdminApproval: + return "Admin Approval"; + default: + return `Channel ${channel}`; } - - private static twoFactorChannelName(channelType: Authentication.TwoFactorChannelType): string { - switch (channelType) { - case Authentication.TwoFactorChannelType.TWO_FA_CT_TOTP: - return 'Authenticator App (TOTP)' - case Authentication.TwoFactorChannelType.TWO_FA_CT_SMS: - return 'SMS' - case Authentication.TwoFactorChannelType.TWO_FA_CT_DUO: - return 'Duo Security' - case Authentication.TwoFactorChannelType.TWO_FA_CT_RSA: - return 'RSA SecurID' - case Authentication.TwoFactorChannelType.TWO_FA_CT_DNA: - return 'Keeper DNA' - case Authentication.TwoFactorChannelType.TWO_FA_CT_U2F: - return 'U2F Security Key' - case Authentication.TwoFactorChannelType.TWO_FA_CT_WEBAUTHN: - return 'WebAuthn Security Key' - case Authentication.TwoFactorChannelType.TWO_FA_CT_KEEPER: - return 'Keeper' - default: - throw new KeeperSdkError( - `Unsupported 2FA channel type: ${channelType}`, - ResultCodes.UNSUPPORTED_2FA_CHANNEL - ) - } + } + + private static twoFactorChannelName( + channelType: Authentication.TwoFactorChannelType, + ): string { + switch (channelType) { + case Authentication.TwoFactorChannelType.TWO_FA_CT_TOTP: + return "Authenticator App (TOTP)"; + case Authentication.TwoFactorChannelType.TWO_FA_CT_SMS: + return "SMS"; + case Authentication.TwoFactorChannelType.TWO_FA_CT_DUO: + return "Duo Security"; + case Authentication.TwoFactorChannelType.TWO_FA_CT_RSA: + return "RSA SecurID"; + case Authentication.TwoFactorChannelType.TWO_FA_CT_DNA: + return "Keeper DNA"; + case Authentication.TwoFactorChannelType.TWO_FA_CT_U2F: + return "U2F Security Key"; + case Authentication.TwoFactorChannelType.TWO_FA_CT_WEBAUTHN: + return "WebAuthn Security Key"; + case Authentication.TwoFactorChannelType.TWO_FA_CT_KEEPER: + return "Keeper"; + default: + throw new KeeperSdkError( + `Unsupported 2FA channel type: ${channelType}`, + ResultCodes.UNSUPPORTED_2FA_CHANNEL, + ); } - - private static async waitWithCancel(timeoutMs: number, cancel?: Promise): Promise { - if (!cancel) { - await delay(timeoutMs) - return - } - await Promise.race([delay(timeoutMs), cancel]) + } + + private static async waitWithCancel( + timeoutMs: number, + cancel?: Promise, + ): Promise { + const platform = getSdkPlatform(); + if (!cancel) { + await platform.delay(timeoutMs); + return; } - - public async waitForDeviceApproval(channels: DeviceApprovalChannel[], isCloud: boolean): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - try { - logger.info('\n--- Device Approval Required ---') - logger.info('This device needs to be approved before you can log in.') - logger.info('Available verification methods:') - channels.forEach((ch, i) => { - logger.info(` ${i + 1}. ${ConsoleAuthUI.channelName(ch.channel)}`) - }) - - const choice = (await rl.question('\nSelect method (number): ')).trim() - const idx = parseInt(choice, 10) - 1 - - if (isNaN(idx) || idx < 0 || idx >= channels.length) { - logger.warn('Invalid selection, cancelling.') - return false - } - - const selected = channels[idx] - logger.info(`\nSending ${ConsoleAuthUI.channelName(selected.channel)} request...`) - await selected.sendApprovalRequest() - - if (selected.validateCode) { - const code = (await rl.question('Enter verification code: ')).trim() - if (!code) return false - await selected.validateCode(code) - } else { - logger.info('Approval request sent. Waiting for approval on your other device...') - await ConsoleAuthUI.waitWithCancel(AuthDefaults.APPROVAL_TIMEOUT_MS) - } - - return true - } catch (e) { - logger.error('Device approval error:', extractErrorMessage(e)) - return false - } finally { - rl.close() - } + await Promise.race([platform.delay(timeoutMs), cancel]); + } + + public async waitForDeviceApproval( + channels: DeviceApprovalChannel[], + _isCloud: boolean, + ): Promise { + const rl = getSdkPlatform().createReadline( + typeof process !== "undefined" ? process.stdin : undefined, + typeof process !== "undefined" ? process.stdout : undefined, + ); + + try { + logger.info("\n--- Device Approval Required ---"); + logger.info("This device needs to be approved before you can log in."); + logger.info("Available verification methods:"); + channels.forEach((ch, i) => { + logger.info(` ${i + 1}. ${ConsoleAuthUI.channelName(ch.channel)}`); + }); + + const choice = (await rl.question("\nSelect method (number): ")).trim(); + const idx = parseInt(choice, 10) - 1; + + if (isNaN(idx) || idx < 0 || idx >= channels.length) { + logger.warn("Invalid selection, cancelling."); + return false; + } + + const selected = channels[idx]; + logger.info( + `\nSending ${ConsoleAuthUI.channelName(selected.channel)} request...`, + ); + await selected.sendApprovalRequest(); + + if (selected.validateCode) { + const code = (await rl.question("Enter verification code: ")).trim(); + if (!code) return false; + await selected.validateCode(code); + } else { + logger.info( + "Approval request sent. Waiting for approval on your other device...", + ); + await ConsoleAuthUI.waitWithCancel(AuthDefaults.APPROVAL_TIMEOUT_MS); + } + + return true; + } catch (e) { + logger.error("Device approval error:", extractErrorMessage(e)); + return false; + } finally { + rl.close(); } - - public async waitForTwoFactorCode(channels: TwoFactorChannelData[], cancel: Promise): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - try { - logger.info('\n--- Two-Factor Authentication Required ---') - logger.info('Available 2FA methods:') - channels.forEach((ch, i) => { - const name = ConsoleAuthUI.twoFactorChannelName(ch.channel.channelType!) - logger.info(` ${i + 1}. ${name}`) - }) - - const choice = (await rl.question('\nSelect method (number): ')).trim() - const idx = parseInt(choice, 10) - 1 - - if (isNaN(idx) || idx < 0 || idx >= channels.length) { - logger.warn('Invalid selection, cancelling.') - return false - } - - const selected = channels[idx] - const name = ConsoleAuthUI.twoFactorChannelName(selected.channel.channelType!) - - if (selected.availablePushes && selected.availablePushes.length > 0) { - const pushChoice = (await rl.question(`Send push notification for ${name}? (y/n): `)).trim() - if (pushChoice.toLowerCase() === 'y' && selected.sendPush) { - selected.sendPush(selected.availablePushes[0]) - logger.info('Push sent. Waiting for approval...') - await ConsoleAuthUI.waitWithCancel(AuthDefaults.APPROVAL_TIMEOUT_MS, cancel) - return true - } - } - - const code = (await rl.question(`Enter ${name} code: `)).trim() - if (!code) return false - - selected.sendCode(code) - await ConsoleAuthUI.waitWithCancel(AuthDefaults.CODE_VALIDATION_DELAY_MS, cancel) - return true - } catch (e) { - logger.error('2FA error:', extractErrorMessage(e)) - return false - } finally { - rl.close() + } + + public async waitForTwoFactorCode( + channels: TwoFactorChannelData[], + cancel: Promise, + ): Promise { + const rl = getSdkPlatform().createReadline( + typeof process !== "undefined" ? process.stdin : undefined, + typeof process !== "undefined" ? process.stdout : undefined, + ); + + try { + logger.info("\n--- Two-Factor Authentication Required ---"); + logger.info("Available 2FA methods:"); + channels.forEach((ch, i) => { + const name = ConsoleAuthUI.twoFactorChannelName( + ch.channel.channelType!, + ); + logger.info(` ${i + 1}. ${name}`); + }); + + const choice = (await rl.question("\nSelect method (number): ")).trim(); + const idx = parseInt(choice, 10) - 1; + + if (isNaN(idx) || idx < 0 || idx >= channels.length) { + logger.warn("Invalid selection, cancelling."); + return false; + } + + const selected = channels[idx]; + const name = ConsoleAuthUI.twoFactorChannelName( + selected.channel.channelType!, + ); + + if (selected.availablePushes && selected.availablePushes.length > 0) { + const pushChoice = ( + await rl.question(`Send push notification for ${name}? (y/n): `) + ).trim(); + if (pushChoice.toLowerCase() === "y" && selected.sendPush) { + selected.sendPush(selected.availablePushes[0]); + logger.info("Push sent. Waiting for approval..."); + await ConsoleAuthUI.waitWithCancel( + AuthDefaults.APPROVAL_TIMEOUT_MS, + cancel, + ); + return true; } + } + + const code = (await rl.question(`Enter ${name} code: `)).trim(); + if (!code) return false; + + selected.sendCode(code); + await ConsoleAuthUI.waitWithCancel( + AuthDefaults.CODE_VALIDATION_DELAY_MS, + cancel, + ); + return true; + } catch (e) { + logger.error("2FA error:", extractErrorMessage(e)); + return false; + } finally { + rl.close(); } - - public async getPassword(isAlternate: boolean): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - try { - const label = isAlternate ? 'alternate master password' : 'master password' - return (await rl.question(`Enter your ${label}: `)).trim() - } finally { - rl.close() - } + } + + public async getPassword(isAlternate: boolean): Promise { + const rl = getSdkPlatform().createReadline( + typeof process !== "undefined" ? process.stdin : undefined, + typeof process !== "undefined" ? process.stdout : undefined, + ); + try { + const label = isAlternate + ? "alternate master password" + : "master password"; + return (await rl.question(`Enter your ${label}: `)).trim(); + } finally { + rl.close(); } + } } diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts index 3d0907e3..7ca0a915 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -1,351 +1,377 @@ -import readline from 'readline/promises' -import type { AuthUI3 } from '@keeper-security/keeperapi' -import { KeeperVault } from '../vault/KeeperVault' +import type { AuthUI3 } from "@keeper-security/keeperapi"; +import { KeeperVault } from "../vault/KeeperVault"; +import { getSdkPlatform } from "../platform"; +import type { SdkReadline } from "../platform"; import { - logger, - extractResultCode, - extractErrorMessage, - KeeperSdkError, - SdkDefaults, - AuthDefaults, - ResultCodes, - KEEPER_PUBLIC_HOSTS, -} from '../utils' -import { ConsoleAuthUI } from './ConsoleAuthUI' -import { FileConfigLoader } from './SessionManager' -import type { KeeperJsonConfig } from './SessionManager' - -const DEFAULT_REGION = 'US' -const MASK_CHAR = '*' -const NOOP_WRITE = (() => true) as typeof process.stdout.write + logger, + extractResultCode, + extractErrorMessage, + KeeperSdkError, + SdkDefaults, + AuthDefaults, + ResultCodes, + KEEPER_PUBLIC_HOSTS, +} from "../utils"; +import { ConsoleAuthUI } from "./ConsoleAuthUI"; +import type { KeeperJsonConfig } from "./config"; + +const DEFAULT_REGION = "US"; +const MASK_CHAR = "*"; +const NOOP_WRITE = (() => true) as typeof process.stdout.write; enum CliCharAction { - Submit, - Cancel, - Backspace, - Append, + Submit, + Cancel, + Backspace, + Append, } type ConsoleHandlers = { - log: typeof console.log - warn: typeof console.warn - debug: typeof console.debug - error: typeof console.error - stdoutWrite: typeof process.stdout.write - stderrWrite: typeof process.stderr.write -} - -const defaultConfigLoader = new FileConfigLoader() - -let rlManager: ReadlineManager | null = null -let suppressionDepth = 0 -let originals: ConsoleHandlers | null = null + log: typeof console.log; + warn: typeof console.warn; + debug: typeof console.debug; + error: typeof console.error; + stdoutWrite: typeof process.stdout.write; + stderrWrite: typeof process.stderr.write; +}; + +let rlManager: ReadlineManager | null = null; +let suppressionDepth = 0; +let originals: ConsoleHandlers | null = null; function captureConsoleHandlers(): ConsoleHandlers { - return { - log: console.log, - warn: console.warn, - debug: console.debug, - error: console.error, - stdoutWrite: process.stdout.write.bind(process.stdout), - stderrWrite: process.stderr.write.bind(process.stderr), - } + return { + log: console.log, + warn: console.warn, + debug: console.debug, + error: console.error, + stdoutWrite: process.stdout.write.bind(process.stdout), + stderrWrite: process.stderr.write.bind(process.stderr), + }; } function applyConsoleHandlers(h: ConsoleHandlers): void { - console.log = h.log - console.warn = h.warn - console.debug = h.debug - console.error = h.error - process.stdout.write = h.stdoutWrite - process.stderr.write = h.stderrWrite + console.log = h.log; + console.warn = h.warn; + console.debug = h.debug; + console.error = h.error; + process.stdout.write = h.stdoutWrite; + process.stderr.write = h.stderrWrite; } function classifyInputChar(ch: string): CliCharAction { - if (ch === '\n' || ch === '\r') return CliCharAction.Submit - if (ch === '\u0003') return CliCharAction.Cancel - if (ch === '\u007F' || ch === '\b') return CliCharAction.Backspace - return CliCharAction.Append + if (ch === "\n" || ch === "\r") return CliCharAction.Submit; + if (ch === "\u0003") return CliCharAction.Cancel; + if (ch === "\u007F" || ch === "\b") return CliCharAction.Backspace; + return CliCharAction.Append; } class ReadlineManager { - private rl: readline.Interface | null = null - - private getOrCreate(): readline.Interface { - if (!this.rl) { - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - } - return this.rl - } + private rl: SdkReadline | null = null; - public async question(query: string): Promise { - const rl = this.getOrCreate() - const answer = await rl.question(query) - return answer.trim() + private getOrCreate(): SdkReadline { + if (!this.rl) { + this.rl = getSdkPlatform().createReadline(process.stdin, process.stdout); } - - public close(): void { - if (this.rl) { - this.rl.close() - this.rl = null - } + return this.rl; + } + + public async question(query: string): Promise { + const rl = this.getOrCreate(); + const answer = await rl.question(query); + return answer.trim(); + } + + public close(): void { + if (this.rl) { + this.rl.close(); + this.rl = null; } + } } function getReadlineManager(): ReadlineManager { - if (!rlManager) { - rlManager = new ReadlineManager() - } - return rlManager + if (!rlManager) { + rlManager = new ReadlineManager(); + } + return rlManager; } export function prompt(question: string, masked = false): Promise { - const mgr = getReadlineManager() - if (!masked) { - return mgr.question(question) + const mgr = getReadlineManager(); + if (!masked) { + return mgr.question(question); + } + + return new Promise((resolve, reject) => { + mgr.close(); + process.stdout.write(question); + let buf = ""; + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + function exitRawMode() { + process.stdout.write("\n"); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdin.removeListener("data", onData); } - return new Promise((resolve, reject) => { - mgr.close() - process.stdout.write(question) - let buf = '' - process.stdin.setRawMode(true) - process.stdin.resume() - process.stdin.setEncoding('utf8') - - function exitRawMode() { - process.stdout.write('\n') - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - } - - const onData = (str: string) => { - for (const ch of str) { - switch (classifyInputChar(ch)) { - case CliCharAction.Submit: - exitRawMode() - resolve(buf.trim()) - return - case CliCharAction.Cancel: - exitRawMode() - reject(new KeeperSdkError('Operation cancelled by user.', ResultCodes.USER_CANCELLED)) - return - case CliCharAction.Backspace: - if (buf.length > 0) { - buf = buf.slice(0, -1) - process.stdout.write('\b \b') - } - break - case CliCharAction.Append: - buf += ch - process.stdout.write(MASK_CHAR) - break - } + const onData = (str: string) => { + for (const ch of str) { + switch (classifyInputChar(ch)) { + case CliCharAction.Submit: + exitRawMode(); + resolve(buf.trim()); + return; + case CliCharAction.Cancel: + exitRawMode(); + reject( + new KeeperSdkError( + "Operation cancelled by user.", + ResultCodes.USER_CANCELLED, + ), + ); + return; + case CliCharAction.Backspace: + if (buf.length > 0) { + buf = buf.slice(0, -1); + process.stdout.write("\b \b"); } + break; + case CliCharAction.Append: + buf += ch; + process.stdout.write(MASK_CHAR); + break; } + } + }; - process.stdin.on('data', onData) - }) + process.stdin.on("data", onData); + }); } -export async function loadKeeperConfig(preloaded?: KeeperJsonConfig): Promise { - if (preloaded) return preloaded - return defaultConfigLoader.load() +export async function loadKeeperConfig( + preloaded?: KeeperJsonConfig, +): Promise { + if (preloaded) return preloaded; + return getSdkPlatform().createFileConfigLoader().load(); } -export async function resolveServer(username?: string, preloadedConfig?: KeeperJsonConfig): Promise { - const config = await loadKeeperConfig(preloadedConfig) - const configServer = config.last_server || config.server - - if (username) { - const users = config.users || [] - const userEntry = users.find((u) => u.user?.toLowerCase() === username.toLowerCase()) - if (userEntry?.server) return userEntry.server - } - - if (configServer) return configServer - - logger.info('Select server region:') - const entries = Object.entries(KEEPER_PUBLIC_HOSTS) - entries.forEach(([region, host], i) => { - logger.info(` ${i + 1}. ${region} (${host})`) - }) - logger.info(` Or enter a hostname directly (e.g. dev.keepersecurity.com)`) - - const choice = await prompt(`Region [1 = ${DEFAULT_REGION}]: `) - if (!choice) return KEEPER_PUBLIC_HOSTS[DEFAULT_REGION] - - const idx = parseInt(choice, 10) - 1 - if (idx >= 0 && idx < entries.length) return entries[idx][1] - - return KEEPER_PUBLIC_HOSTS[choice.toUpperCase()] || choice +export async function resolveServer( + username?: string, + preloadedConfig?: KeeperJsonConfig, +): Promise { + const config = await loadKeeperConfig(preloadedConfig); + const configServer = config.last_server || config.server; + + if (username) { + const users = config.users || []; + const userEntry = users.find( + (u) => u.user?.toLowerCase() === username.toLowerCase(), + ); + if (userEntry?.server) return userEntry.server; + } + + if (configServer) return configServer; + + logger.info("Select server region:"); + const entries = Object.entries(KEEPER_PUBLIC_HOSTS); + entries.forEach(([region, host], i) => { + logger.info(` ${i + 1}. ${region} (${host})`); + }); + logger.info(` Or enter a hostname directly (e.g. dev.keepersecurity.com)`); + + const choice = await prompt(`Region [1 = ${DEFAULT_REGION}]: `); + if (!choice) return KEEPER_PUBLIC_HOSTS[DEFAULT_REGION]; + + const idx = parseInt(choice, 10) - 1; + if (idx >= 0 && idx < entries.length) return entries[idx][1]; + + return KEEPER_PUBLIC_HOSTS[choice.toUpperCase()] || choice; } export function suppressLogs(): () => void { - if (suppressionDepth === 0) { - originals = captureConsoleHandlers() - applyConsoleHandlers({ - log: () => {}, - warn: () => {}, - debug: () => {}, - error: () => {}, - stdoutWrite: NOOP_WRITE, - stderrWrite: NOOP_WRITE, - }) - } - suppressionDepth++ - - let restored = false - return () => { - if (restored) return - restored = true - suppressionDepth-- - if (suppressionDepth === 0 && originals) { - applyConsoleHandlers(originals) - originals = null - } + if (suppressionDepth === 0) { + originals = captureConsoleHandlers(); + applyConsoleHandlers({ + log: () => {}, + warn: () => {}, + debug: () => {}, + error: () => {}, + stdoutWrite: NOOP_WRITE, + stderrWrite: NOOP_WRITE, + }); + } + suppressionDepth++; + + let restored = false; + return () => { + if (restored) return; + restored = true; + suppressionDepth--; + if (suppressionDepth === 0 && originals) { + applyConsoleHandlers(originals); + originals = null; } + }; } async function withSuppressedOutput(fn: () => Promise): Promise { - const restore = suppressLogs() - try { - return await fn() - } finally { - restore() - } + const restore = suppressLogs(); + try { + return await fn(); + } finally { + restore(); + } } function unsuppressLogs(): () => void { - if (suppressionDepth === 0 || !originals) return () => {} + if (suppressionDepth === 0 || !originals) return () => {}; - const overrides = captureConsoleHandlers() - applyConsoleHandlers(originals) + const overrides = captureConsoleHandlers(); + applyConsoleHandlers(originals); - let restored = false - return () => { - if (restored) return - restored = true - applyConsoleHandlers(overrides) - } + let restored = false; + return () => { + if (restored) return; + restored = true; + applyConsoleHandlers(overrides); + }; } function unsuppressedAuthUI(): AuthUI3 { - const ui = new ConsoleAuthUI() - const wrap = (fn: (...args: A) => Promise) => - async (...args: A): Promise => { - const restore = unsuppressLogs() - try { - return await fn(...args) - } finally { - restore() - } - } - return { - waitForDeviceApproval: wrap(ui.waitForDeviceApproval.bind(ui)), - waitForTwoFactorCode: wrap(ui.waitForTwoFactorCode.bind(ui)), - getPassword: wrap(ui.getPassword.bind(ui)), - } + const ui = new ConsoleAuthUI(); + const wrap = + (fn: (...args: A) => Promise) => + async (...args: A): Promise => { + const restore = unsuppressLogs(); + try { + return await fn(...args); + } finally { + restore(); + } + }; + return { + waitForDeviceApproval: wrap(ui.waitForDeviceApproval.bind(ui)), + waitForTwoFactorCode: wrap(ui.waitForTwoFactorCode.bind(ui)), + getPassword: wrap(ui.getPassword.bind(ui)), + }; } export async function login(): Promise { - const config = await loadKeeperConfig() - const defaultUsername = config.last_login || config.user || '' - - const host = defaultUsername ? await resolveServer(defaultUsername, config) : undefined + const config = await loadKeeperConfig(); + const defaultUsername = config.last_login || config.user || ""; + + const host = defaultUsername + ? await resolveServer(defaultUsername, config) + : undefined; + + if (defaultUsername && host) { + const vault = await tryPersistentLogin(host, defaultUsername); + if (vault) return vault; + } + + let username: string; + if (defaultUsername) { + logger.info(`Enter master password for ${defaultUsername}`); + username = defaultUsername; + } else { + username = await prompt("Username (email): "); + } + + if (!username) { + throw new KeeperSdkError( + "Username is required.", + ResultCodes.MISSING_USERNAME, + ); + } - if (defaultUsername && host) { - const vault = await tryPersistentLogin(host, defaultUsername) - if (vault) return vault - } + const resolvedHost = host || (await resolveServer(username, config)); + return await interactiveLogin(resolvedHost, username); +} - let username: string - if (defaultUsername) { - logger.info(`Enter master password for ${defaultUsername}`) - username = defaultUsername - } else { - username = await prompt('Username (email): ') - } +async function tryPersistentLogin( + host: string, + username: string, +): Promise { + const vault = new KeeperVault({ + host, + clientVersion: SdkDefaults.CLIENT_VERSION, + authUI: unsuppressedAuthUI(), + }); + try { + await withSuppressedOutput(() => vault.resumeSession()); + logger.info(`Logging in to Keeper as ${username}`); + logger.info("Successfully authenticated with Persistent Login"); + return await syncVault(vault); + } catch (err) { + logger.debug("Persistent login failed:", extractErrorMessage(err)); + vault.disconnect(); + return null; + } +} - if (!username) { - throw new KeeperSdkError('Username is required.', ResultCodes.MISSING_USERNAME) +async function interactiveLogin( + host: string, + username: string, +): Promise { + const vault = new KeeperVault({ + host, + clientVersion: SdkDefaults.CLIENT_VERSION, + authUI: unsuppressedAuthUI(), + }); + + for (let attempt = 1; attempt <= AuthDefaults.MAX_LOGIN_ATTEMPTS; attempt++) { + const password = await prompt("Password: ", true); + + if (!password) { + throw new KeeperSdkError( + "Password is required.", + ResultCodes.MISSING_PASSWORD, + ); } - const resolvedHost = host || (await resolveServer(username, config)) - return await interactiveLogin(resolvedHost, username) -} - -async function tryPersistentLogin(host: string, username: string): Promise { - const vault = new KeeperVault({ - host, - clientVersion: SdkDefaults.CLIENT_VERSION, - authUI: unsuppressedAuthUI(), - }) try { - await withSuppressedOutput(() => vault.resumeSession()) - logger.info(`Logging in to Keeper as ${username}`) - logger.info('Successfully authenticated with Persistent Login') - return await syncVault(vault) + await withSuppressedOutput(() => vault.login(username, password)); + logger.info("Successfully authenticated with Master Password\n"); + return await syncVault(vault); } catch (err) { - logger.debug('Persistent login failed:', extractErrorMessage(err)) - vault.disconnect() - return null - } -} - -async function interactiveLogin(host: string, username: string): Promise { - const vault = new KeeperVault({ - host, - clientVersion: SdkDefaults.CLIENT_VERSION, - authUI: unsuppressedAuthUI(), - }) - - for (let attempt = 1; attempt <= AuthDefaults.MAX_LOGIN_ATTEMPTS; attempt++) { - const password = await prompt('Password: ', true) - - if (!password) { - throw new KeeperSdkError('Password is required.', ResultCodes.MISSING_PASSWORD) - } - - try { - await withSuppressedOutput(() => vault.login(username, password)) - logger.info('Successfully authenticated with Master Password\n') - return await syncVault(vault) - } catch (err) { - const resultCode = extractResultCode(err) - if (resultCode === ResultCodes.INVALID_CREDENTIALS) { - const remaining = AuthDefaults.MAX_LOGIN_ATTEMPTS - attempt - if (remaining > 0) { - logger.warn(`Invalid credentials (${remaining} attempt${remaining === 1 ? '' : 's'} remaining)`) - continue - } - throw new KeeperSdkError( - `Maximum login attempts (${AuthDefaults.MAX_LOGIN_ATTEMPTS}) exceeded.`, - ResultCodes.MAX_ATTEMPTS_EXCEEDED - ) - } - throw KeeperSdkError.from(err) + const resultCode = extractResultCode(err); + if (resultCode === ResultCodes.INVALID_CREDENTIALS) { + const remaining = AuthDefaults.MAX_LOGIN_ATTEMPTS - attempt; + if (remaining > 0) { + logger.warn( + `Invalid credentials (${remaining} attempt${remaining === 1 ? "" : "s"} remaining)`, + ); + continue; } + throw new KeeperSdkError( + `Maximum login attempts (${AuthDefaults.MAX_LOGIN_ATTEMPTS}) exceeded.`, + ResultCodes.MAX_ATTEMPTS_EXCEEDED, + ); + } + throw KeeperSdkError.from(err); } + } - throw new KeeperSdkError( - `Maximum login attempts (${AuthDefaults.MAX_LOGIN_ATTEMPTS}) exceeded.`, - ResultCodes.MAX_ATTEMPTS_EXCEEDED - ) + throw new KeeperSdkError( + `Maximum login attempts (${AuthDefaults.MAX_LOGIN_ATTEMPTS}) exceeded.`, + ResultCodes.MAX_ATTEMPTS_EXCEEDED, + ); } async function syncVault(vault: KeeperVault): Promise { - logger.info('Syncing vault...') - await withSuppressedOutput(() => vault.sync()) - logger.info(`Vault synced. ${vault.getSummary().recordCount} records loaded.\n`) - return vault + logger.info("Syncing vault..."); + await withSuppressedOutput(() => vault.sync()); + logger.info( + `Vault synced. ${vault.getSummary().recordCount} records loaded.\n`, + ); + return vault; } export function cleanup(vault: KeeperVault): void { - vault.disconnect() - getReadlineManager().close() + vault.disconnect(); + getReadlineManager().close(); } diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts index ec3c7a9b..30a21b84 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -1,293 +1,274 @@ -import fs from 'fs/promises' -import path from 'path' -import os from 'os' import { - normal64Bytes, - type DeviceConfig, - type SessionStorage, - type KeeperHost, - type SessionParams, -} from '@keeper-security/keeperapi' -import { logger, extractErrorMessage, SdkDefaults } from '../utils' -import type { Nullable } from '../utils' - -export type ConfigurationUser = { - user?: string - server?: string - last_device?: { device_token?: string } -} - -export type ConfigurationServerConfig = { - server?: string - clone_code?: string -} - -export type ConfigurationDeviceConfig = { - device_token?: string - private_key?: string - server_info?: Array -} - -export type KeeperJsonConfig = { - last_login?: string - last_server?: string - user?: string - server?: string - device_token?: string - private_key?: string - clone_code?: string - users?: Array - devices?: Array -} + normal64Bytes, + type DeviceConfig, + type SessionStorage, + type KeeperHost, + type SessionParams, +} from "@keeper-security/keeperapi"; +import { getSdkPlatform } from "../platform"; +import { logger, extractErrorMessage } from "../utils"; +import type { Nullable } from "../utils"; +import type { + ConfigLoader, + KeeperJsonConfig, + ConfigurationServerConfig, + ConfigurationUser, +} from "./config"; +import { isValidKeeperConfig } from "./config"; + +export type { + KeeperJsonConfig, + ConfigLoader, + ConfigurationUser, + ConfigurationServerConfig, + ConfigurationDeviceConfig, +} from "./config"; type ResolvedDevice = { - deviceToken: Uint8Array - privateKey: Uint8Array - serverInfo: Array> -} + deviceToken: Uint8Array; + privateKey: Uint8Array; + serverInfo: Array>; +}; type DeviceCacheEntry = { - username: string - device: Nullable -} - -export interface ConfigLoader { - load(): Promise - save(config: KeeperJsonConfig): Promise - readonly configDir: string -} - -export class FileConfigLoader implements ConfigLoader { - public readonly configDir: string - - constructor(configDir?: string) { - this.configDir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) - } - - async load(): Promise { - const configPath = path.join(this.configDir, 'config.json') - try { - const content = await fs.readFile(configPath, 'utf-8') - const parsed: unknown = JSON.parse(content) - if (SessionManager.isValidKeeperConfig(parsed)) { - return parsed - } - } catch (err) { - logger.debug('Failed to load keeper config:', extractErrorMessage(err)) - } - return {} - } - - async save(config: KeeperJsonConfig): Promise { - const configPath = path.join(this.configDir, 'config.json') - await fs.writeFile(configPath, JSON.stringify(config, null, 2), { - mode: 0o600, - }) - } -} + username: string; + device: Nullable; +}; export class SessionManager implements SessionStorage { - private readonly configLoader: ConfigLoader - private sessionParams: Nullable = null - private _lastUsername?: string - private _keeperConfig: Nullable = null - private _deviceCache: Nullable = null - private sessionDevices = new Map() - private sessionCloneCodes = new Map() - - constructor(configDir?: string) - constructor(loader: ConfigLoader) - constructor(configDirOrLoader?: string | ConfigLoader) { - if (typeof configDirOrLoader === 'string' || configDirOrLoader === undefined) { - this.configLoader = new FileConfigLoader(configDirOrLoader as string | undefined) - } else { - this.configLoader = configDirOrLoader - } + private readonly configLoader: ConfigLoader; + private sessionParams: Nullable = null; + private _lastUsername?: string; + private _keeperConfig: Nullable = null; + private _deviceCache: Nullable = null; + private sessionDevices = new Map(); + private sessionCloneCodes = new Map(); + + constructor(configDir?: string); + constructor(loader: ConfigLoader); + constructor(configDirOrLoader?: string | ConfigLoader) { + if (typeof configDirOrLoader === "string") { + this.configLoader = + getSdkPlatform().createFileConfigLoader(configDirOrLoader); + } else if (configDirOrLoader === undefined) { + this.configLoader = getSdkPlatform().createFileConfigLoader(); + } else { + this.configLoader = configDirOrLoader; } - - public get configDir(): string { - return this.configLoader.configDir - } - - public get lastUsername(): string | undefined { - return this._lastUsername + } + + public get configDir(): string { + return this.configLoader.configDir; + } + + public get lastUsername(): string | undefined { + return this._lastUsername; + } + + public async getLastUsername(): Promise { + if (this._lastUsername) return this._lastUsername; + const keeperConfig = await this.loadKeeperConfig(); + return keeperConfig.last_login || keeperConfig.user || undefined; + } + + public async getDeviceConfig(host: string): Promise { + const username = await this.getLastUsername(); + if (username) { + const device = await this.findDeviceInKeeperConfig(username); + if (device) { + return { + deviceToken: device.deviceToken, + privateKey: device.privateKey, + }; + } } - public async getLastUsername(): Promise { - if (this._lastUsername) return this._lastUsername - const keeperConfig = await this.loadKeeperConfig() - return keeperConfig.last_login || keeperConfig.user || undefined + return this.sessionDevices.get(host) || {}; + } + + public createOnDeviceConfig( + host: string, + ): (deviceConfig: DeviceConfig) => Promise { + return async (deviceConfig: DeviceConfig) => { + this.sessionDevices.set(host, { ...deviceConfig }); + }; + } + + private cloneCodeKey(host: KeeperHost, username: string): string { + return `${host}::${username}`; + } + + public async getCloneCode( + host: KeeperHost, + username: string, + ): Promise> { + const hostStr = String(host); + + const key = this.cloneCodeKey(host, username); + const sessionCode = this.sessionCloneCodes.get(key); + if (sessionCode) return sessionCode; + + const device = await this.findDeviceInKeeperConfig(username); + if (device) { + const serverInfo = device.serverInfo.find( + (entry) => entry.server === hostStr, + ); + if (serverInfo) { + return normal64Bytes(serverInfo.clone_code); + } } - public async getDeviceConfig(host: string): Promise { - const username = await this.getLastUsername() - if (username) { - const device = await this.findDeviceInKeeperConfig(username) - if (device) { - return { - deviceToken: device.deviceToken, - privateKey: device.privateKey, - } - } + return null; + } + + public async saveCloneCode( + host: KeeperHost, + username: string, + cloneCode: Uint8Array, + ): Promise { + const key = this.cloneCodeKey(host, username); + this.sessionCloneCodes.set(key, cloneCode); + await this.updateKeeperConfigCloneCode(String(host), username, cloneCode); + } + + private async updateKeeperConfigCloneCode( + host: string, + username: string, + cloneCode: Uint8Array, + ): Promise { + try { + const parsed = await this.configLoader.load(); + if (!parsed || Object.keys(parsed).length === 0) return; + + let updated = false; + const encodedCloneCode = Buffer.from(cloneCode).toString("base64url"); + + const server = parsed.last_server || parsed.server; + if ( + parsed.user?.toLowerCase() === username.toLowerCase() && + server === host + ) { + parsed.clone_code = encodedCloneCode; + updated = true; + } + + const user = (parsed.users || []).find( + (configUser) => + configUser.user?.toLowerCase() === username.toLowerCase(), + ); + if (user?.last_device?.device_token) { + const device = (parsed.devices || []).find( + (configDevice) => + configDevice.device_token === user.last_device!.device_token, + ); + if (device?.server_info) { + const serverInfo = device.server_info.find( + (entry) => entry.server === host, + ); + if (serverInfo) { + serverInfo.clone_code = encodedCloneCode; + updated = true; + } } - - return this.sessionDevices.get(host) || {} + } + + if (updated) { + await this.configLoader.save(parsed); + this._keeperConfig = null; + this._deviceCache = null; + } + } catch (err) { + logger.warn( + "Failed to update keeper config clone code:", + extractErrorMessage(err), + ); } - - public createOnDeviceConfig(host: string): (deviceConfig: DeviceConfig) => Promise { - return async (deviceConfig: DeviceConfig) => { - this.sessionDevices.set(host, { ...deviceConfig }) - } + } + + public async getSessionParameters(): Promise> { + return this.sessionParams; + } + + public async saveSessionParameters( + params: Partial, + ): Promise { + this.sessionParams = { ...this.sessionParams, ...params } as SessionParams; + if (params.username) { + this._lastUsername = params.username; } - - private cloneCodeKey(host: KeeperHost, username: string): string { - return `${host}::${username}` - } - - public async getCloneCode(host: KeeperHost, username: string): Promise> { - const hostStr = String(host) - - const key = this.cloneCodeKey(host, username) - const sessionCode = this.sessionCloneCodes.get(key) - if (sessionCode) return sessionCode - - const device = await this.findDeviceInKeeperConfig(username) - if (device) { - const serverInfo = device.serverInfo.find((entry) => entry.server === hostStr) - if (serverInfo) { - return normal64Bytes(serverInfo.clone_code) - } - } - - return null - } - - public async saveCloneCode(host: KeeperHost, username: string, cloneCode: Uint8Array): Promise { - const key = this.cloneCodeKey(host, username) - this.sessionCloneCodes.set(key, cloneCode) - await this.updateKeeperConfigCloneCode(String(host), username, cloneCode) - } - - private async updateKeeperConfigCloneCode(host: string, username: string, cloneCode: Uint8Array): Promise { - try { - const parsed = await this.configLoader.load() - if (!parsed || Object.keys(parsed).length === 0) return - - let updated = false - const encodedCloneCode = Buffer.from(cloneCode).toString('base64url') - - const server = parsed.last_server || parsed.server - if (parsed.user?.toLowerCase() === username.toLowerCase() && server === host) { - parsed.clone_code = encodedCloneCode - updated = true - } - - const user = (parsed.users || []).find( - (configUser) => configUser.user?.toLowerCase() === username.toLowerCase() - ) - if (user?.last_device?.device_token) { - const device = (parsed.devices || []).find( - (configDevice) => configDevice.device_token === user.last_device.device_token - ) - if (device?.server_info) { - const serverInfo = device.server_info.find((entry) => entry.server === host) - if (serverInfo) { - serverInfo.clone_code = encodedCloneCode - updated = true - } - } - } - - if (updated) { - await this.configLoader.save(parsed) - this._keeperConfig = null - this._deviceCache = null - } - } catch (err) { - logger.warn('Failed to update keeper config clone code:', extractErrorMessage(err)) - } - } - - public async getSessionParameters(): Promise> { - return this.sessionParams + } + + public setLastUsername(username: string): void { + this._lastUsername = username; + } + + private async loadKeeperConfig(): Promise { + if (this._keeperConfig) return this._keeperConfig; + this._keeperConfig = await this.configLoader.load(); + return this._keeperConfig; + } + + private async findDeviceInKeeperConfig( + username: string, + ): Promise> { + const normalizedUsername = username.toLowerCase(); + if (this._deviceCache?.username === normalizedUsername) { + return this._deviceCache.device; } - public async saveSessionParameters(params: Partial): Promise { - this.sessionParams = { ...this.sessionParams, ...params } as SessionParams - if (params.username) { - this._lastUsername = params.username + const device = await this.lookupDeviceInKeeperConfig(normalizedUsername); + this._deviceCache = { username: normalizedUsername, device }; + return device; + } + + private async lookupDeviceInKeeperConfig( + normalizedUsername: string, + ): Promise> { + const keeperConfig = await this.loadKeeperConfig(); + + if (keeperConfig.users && keeperConfig.devices) { + const user = keeperConfig.users.find( + (configUser) => configUser.user?.toLowerCase() === normalizedUsername, + ); + if (user?.last_device?.device_token) { + const deviceTokenStr = user.last_device.device_token; + const device = keeperConfig.devices.find( + (configDevice) => configDevice.device_token === deviceTokenStr, + ); + if (device?.private_key) { + return { + deviceToken: normal64Bytes(deviceTokenStr), + privateKey: normal64Bytes(device.private_key), + serverInfo: (device.server_info || []).filter( + (entry): entry is Required => + !!entry.server && !!entry.clone_code, + ), + }; } + } } - public setLastUsername(username: string): void { - this._lastUsername = username - } - - private async loadKeeperConfig(): Promise { - if (this._keeperConfig) return this._keeperConfig - this._keeperConfig = await this.configLoader.load() - return this._keeperConfig + if ( + keeperConfig.device_token && + keeperConfig.private_key && + keeperConfig.user?.toLowerCase() === normalizedUsername + ) { + const serverInfo: Array> = []; + const server = keeperConfig.last_server || keeperConfig.server; + if (server && keeperConfig.clone_code) { + serverInfo.push({ server, clone_code: keeperConfig.clone_code }); + } + return { + deviceToken: normal64Bytes(keeperConfig.device_token), + privateKey: normal64Bytes(keeperConfig.private_key), + serverInfo, + }; } - private async findDeviceInKeeperConfig(username: string): Promise> { - const normalizedUsername = username.toLowerCase() - if (this._deviceCache?.username === normalizedUsername) { - return this._deviceCache.device - } + return null; + } - const device = await this.lookupDeviceInKeeperConfig(normalizedUsername) - this._deviceCache = { username: normalizedUsername, device } - return device - } - - private async lookupDeviceInKeeperConfig(normalizedUsername: string): Promise> { - const keeperConfig = await this.loadKeeperConfig() - - if (keeperConfig.users && keeperConfig.devices) { - const user = keeperConfig.users.find( - (configUser) => configUser.user?.toLowerCase() === normalizedUsername - ) - if (user?.last_device?.device_token) { - const deviceTokenStr = user.last_device.device_token - const device = keeperConfig.devices.find((configDevice) => configDevice.device_token === deviceTokenStr) - if (device?.private_key) { - return { - deviceToken: normal64Bytes(deviceTokenStr), - privateKey: normal64Bytes(device.private_key), - serverInfo: (device.server_info || []).filter( - (entry): entry is Required => - !!entry.server && !!entry.clone_code - ), - } - } - } - } - - if ( - keeperConfig.device_token && - keeperConfig.private_key && - keeperConfig.user?.toLowerCase() === normalizedUsername - ) { - const serverInfo: Array> = [] - const server = keeperConfig.last_server || keeperConfig.server - if (server && keeperConfig.clone_code) { - serverInfo.push({ server, clone_code: keeperConfig.clone_code }) - } - return { - deviceToken: normal64Bytes(keeperConfig.device_token), - privateKey: normal64Bytes(keeperConfig.private_key), - serverInfo, - } - } - - return null - } - - public static isValidKeeperConfig(value: unknown): value is KeeperJsonConfig { - if (typeof value !== 'object' || value === null) return false - const obj = value as Record - if (obj.users !== undefined && !Array.isArray(obj.users)) return false - if (obj.devices !== undefined && !Array.isArray(obj.devices)) return false - return true - } + public static isValidKeeperConfig(value: unknown): value is KeeperJsonConfig { + return isValidKeeperConfig(value); + } } diff --git a/KeeperSdk/src/auth/UnavailableAuthUI.ts b/KeeperSdk/src/auth/UnavailableAuthUI.ts new file mode 100644 index 00000000..fb2e63ef --- /dev/null +++ b/KeeperSdk/src/auth/UnavailableAuthUI.ts @@ -0,0 +1,29 @@ +import type { + AuthUI3, + DeviceApprovalChannel, + TwoFactorChannelData, +} from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; + +const MSG = + "Console authentication is disabled (useConsoleAuth: false). Provide authUI or use a host that handles login (e.g. keeper-shell password prompt)."; + +export class UnavailableAuthUI implements AuthUI3 { + public async waitForDeviceApproval( + _channels: DeviceApprovalChannel[], + _isCloud: boolean, + ): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN); + } + + public async waitForTwoFactorCode( + _channels: TwoFactorChannelData[], + _cancel: Promise, + ): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN); + } + + public async getPassword(_isAlternate: boolean): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN); + } +} diff --git a/KeeperSdk/src/auth/config.ts b/KeeperSdk/src/auth/config.ts new file mode 100644 index 00000000..1f0b7641 --- /dev/null +++ b/KeeperSdk/src/auth/config.ts @@ -0,0 +1,42 @@ +export type ConfigurationUser = { + user?: string; + server?: string; + last_device?: { device_token?: string }; +}; + +export type ConfigurationServerConfig = { + server?: string; + clone_code?: string; +}; + +export type ConfigurationDeviceConfig = { + device_token?: string; + private_key?: string; + server_info?: Array; +}; + +export type KeeperJsonConfig = { + last_login?: string; + last_server?: string; + user?: string; + server?: string; + device_token?: string; + private_key?: string; + clone_code?: string; + users?: Array; + devices?: Array; +}; + +export interface ConfigLoader { + load(): Promise; + save(config: KeeperJsonConfig): Promise; + readonly configDir: string; +} + +export function isValidKeeperConfig(value: unknown): value is KeeperJsonConfig { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + if (obj.users !== undefined && !Array.isArray(obj.users)) return false; + if (obj.devices !== undefined && !Array.isArray(obj.devices)) return false; + return true; +} diff --git a/KeeperSdk/src/auth/node/FileConfigLoader.ts b/KeeperSdk/src/auth/node/FileConfigLoader.ts new file mode 100644 index 00000000..638e80a7 --- /dev/null +++ b/KeeperSdk/src/auth/node/FileConfigLoader.ts @@ -0,0 +1,37 @@ +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import type { ConfigLoader, KeeperJsonConfig } from "../config"; +import { isValidKeeperConfig } from "../config"; +import { logger, extractErrorMessage, SdkDefaults } from "../../utils"; + +/** CAUTION: This is a Node-only class. */ +export class FileConfigLoader implements ConfigLoader { + public readonly configDir: string; + + constructor(configDir?: string) { + this.configDir = + configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR); + } + + async load(): Promise { + const configPath = path.join(this.configDir, "config.json"); + try { + const content = await fs.readFile(configPath, "utf-8"); + const parsed: unknown = JSON.parse(content); + if (isValidKeeperConfig(parsed)) { + return parsed; + } + } catch (err) { + logger.debug("Failed to load keeper config:", extractErrorMessage(err)); + } + return {}; + } + + async save(config: KeeperJsonConfig): Promise { + const configPath = path.join(this.configDir, "config.json"); + await fs.writeFile(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }); + } +} diff --git a/KeeperSdk/src/auth/sessionRestore.ts b/KeeperSdk/src/auth/sessionRestore.ts new file mode 100644 index 00000000..80febf4d --- /dev/null +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -0,0 +1,281 @@ +import { + Authentication, + normal64Bytes, + type SessionParams, +} from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; + +type UserType = SessionParams["userType"]; + +const UserTypeValues = { + normal: "normal" as UserType, + cloudSso: "cloud_sso" as UserType, + onsiteSso: "onsite_sso" as UserType, +}; + +/** String form of {@link SessionParams} as exported from extension / vault storage. */ +export type SessionRestoreInput = { + accountUid: string; + clientKey: string; + dataKey: string; + eccPrivateKey: string; + eccPublicKey: string; + messageSessionUid: string; + privateKey: string; + sessionToken: string; + sessionTokenType: number | string; + username: string; + userType: number | string; + ssoLogoutUrl?: string; + ssoSessionId?: string; + enterprisePublicKey?: string; + enterpriseEccPublicKey?: string; +}; + +const REQUIRED_KEYS: (keyof SessionRestoreInput)[] = [ + "accountUid", + "clientKey", + "dataKey", + "eccPrivateKey", + "eccPublicKey", + "messageSessionUid", + "privateKey", + "sessionToken", + "sessionTokenType", + "username", + "userType", +]; + +function decodeBytes( + label: string, + value: string | undefined, + required: boolean, +): Uint8Array | undefined { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) { + if (required) { + throw new KeeperSdkError( + `restore-session: missing required field: ${label}`, + ResultCodes.MISSING_USERNAME, + ); + } + return undefined; + } + try { + return normal64Bytes(trimmed); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new KeeperSdkError( + `restore-session: invalid base64 for ${label}: ${msg}`, + ResultCodes.INVALID_CREDENTIALS, + ); + } +} + +function parseUserType(value: number | string): UserType { + if (typeof value === "number") { + switch (value) { + case 0: + return UserTypeValues.normal; + case 1: + return UserTypeValues.cloudSso; + case 2: + return UserTypeValues.onsiteSso; + default: + break; + } + } + const s = String(value).toLowerCase(); + if (s === "normal" || s === "0") return UserTypeValues.normal; + if (s === "cloud_sso" || s === "cloudsso" || s === "1") + return UserTypeValues.cloudSso; + if (s === "onsite_sso" || s === "onsitesso" || s === "2") + return UserTypeValues.onsiteSso; + throw new KeeperSdkError( + `restore-session: unknown userType: ${value}`, + ResultCodes.INVALID_CREDENTIALS, + ); +} + +function parseSessionTokenType( + value: number | string, +): Authentication.SessionTokenType { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) { + throw new KeeperSdkError( + `restore-session: invalid sessionTokenType: ${value}`, + ResultCodes.INVALID_CREDENTIALS, + ); + } + return n as Authentication.SessionTokenType; +} + +export function validateSessionRestoreInput( + input: Partial, +): SessionRestoreInput { + const missing = REQUIRED_KEYS.filter((k) => { + const v = input[k]; + return ( + v === undefined || v === null || (typeof v === "string" && !v.trim()) + ); + }); + if (missing.length > 0) { + throw new KeeperSdkError( + `restore-session: missing required field(s): ${missing.join(", ")}`, + ResultCodes.MISSING_USERNAME, + ); + } + return input as SessionRestoreInput; +} + +/** Build keeperapi {@link SessionParams} from extension-style strings. */ +export function toSessionParams(input: SessionRestoreInput): SessionParams { + const enterprisePublicKey = decodeBytes( + "enterprisePublicKey", + input.enterprisePublicKey, + false, + ); + const enterpriseEccPublicKey = decodeBytes( + "enterpriseEccPublicKey", + input.enterpriseEccPublicKey, + false, + ); + + return { + accountUid: decodeBytes("accountUid", input.accountUid, true)!, + clientKey: decodeBytes("clientKey", input.clientKey, true)!, + dataKey: decodeBytes("dataKey", input.dataKey, true)!, + eccPrivateKey: decodeBytes("eccPrivateKey", input.eccPrivateKey, true)!, + eccPublicKey: decodeBytes("eccPublicKey", input.eccPublicKey, true)!, + messageSessionUid: decodeBytes( + "messageSessionUid", + input.messageSessionUid, + true, + )!, + privateKey: decodeBytes("privateKey", input.privateKey, true)!, + sessionToken: input.sessionToken.trim(), + sessionTokenType: parseSessionTokenType(input.sessionTokenType), + username: input.username.trim(), + userType: parseUserType(input.userType), + ssoLogoutUrl: input.ssoLogoutUrl?.trim() ?? "", + ssoSessionId: input.ssoSessionId?.trim() ?? "", + ...(enterprisePublicKey ? { enterprisePublicKey } : {}), + ...(enterpriseEccPublicKey ? { enterpriseEccPublicKey } : {}), + }; +} + +function assertBodyIsJsonNotHtml(body: string, source: string): void { + const head = body.trimStart().slice(0, 32).toLowerCase(); + if ( + head.startsWith("); +} + +/** Parse `--from-json` payload, or read a file when the value is not JSON. */ +export async function resolveSessionRestorePayload( + raw: string, + readFile?: (path: string) => Promise, +): Promise { + const text = raw.trim(); + if (readFile && looksLikeFilePath(text)) { + const body = await readFile(text); + assertBodyIsJsonNotHtml(body, text); + return sessionRestoreFromJson(body); + } + try { + return sessionRestoreFromJson(text); + } catch (e) { + if (looksLikeInlineJson(text) || !readFile) { + throw e; + } + const body = await readFile(text); + assertBodyIsJsonNotHtml(body, text); + return sessionRestoreFromJson(body); + } +} diff --git a/KeeperSdk/src/browser.ts b/KeeperSdk/src/browser.ts new file mode 100644 index 00000000..6edab1fe --- /dev/null +++ b/KeeperSdk/src/browser.ts @@ -0,0 +1,8 @@ +import { connectSdkPlatform } from "./platform"; +import { browserSdkPlatform } from "./platform/browser/platform"; + +connectSdkPlatform(browserSdkPlatform); + +export * from "./api"; +/** Nested Share Folder / Keeper Drive APIs (also on Node `index.ts`). */ +export * from "./nestedShareFolders"; diff --git a/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts b/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts index 92b28e2d..733ae1c0 100644 --- a/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts +++ b/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts @@ -1,35 +1,42 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' -import { runAuditReport } from './auditReport' -import { runActionReport } from './actionReport' +import type { Auth } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; +import { runAuditReport } from "./auditReport"; +import { runActionReport } from "./actionReport"; import type { - ActionReportOptions, - ActionReportResult, - AuditReportOptions, - AuditReportResult, -} from './reportTypes' -import type { AuthProvider } from './reportUtils' + ActionReportOptions, + ActionReportResult, + AuditReportOptions, + AuditReportResult, +} from "./reportTypes"; +import type { AuthProvider } from "./reportUtils"; export class EnterpriseReportManager { - private readonly authProvider: AuthProvider + private readonly authProvider: AuthProvider; - constructor(authProvider: AuthProvider) { - this.authProvider = authProvider - } + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider; + } - public async runAuditReport(options: AuditReportOptions = {}): Promise { - return runAuditReport(this.requireAuth(), options) - } + public async runAuditReport( + options: AuditReportOptions = {}, + ): Promise { + return runAuditReport(this.requireAuth(), options); + } - public async runActionReport(options: ActionReportOptions = {}): Promise { - return runActionReport(this.requireAuth(), options) - } + public async runActionReport( + options: ActionReportOptions = {}, + ): Promise { + return runActionReport(this.requireAuth(), options); + } - private requireAuth(): Auth { - const auth = this.authProvider() - if (!auth) { - throw new KeeperSdkError('You are not logged in. Please log in first.', ResultCodes.NOT_LOGGED_IN) - } - return auth + private requireAuth(): Auth { + const auth = this.authProvider(); + if (!auth) { + throw new KeeperSdkError( + "You are not logged in. Please log in first.", + ResultCodes.NOT_LOGGED_IN, + ); } + return auth; + } } diff --git a/KeeperSdk/src/enterpriseReport/actionReport.ts b/KeeperSdk/src/enterpriseReport/actionReport.ts index c59fb2d3..c54c8ca0 100644 --- a/KeeperSdk/src/enterpriseReport/actionReport.ts +++ b/KeeperSdk/src/enterpriseReport/actionReport.ts @@ -1,518 +1,605 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { getAuditEventReportsCommand } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { actionUsers, UserAction } from '../users/actionUser' -import { deleteUsers } from '../users/deleteUser' +import type { Auth } from "@keeper-security/keeperapi"; +import { getAuditEventReportsCommand } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { actionUsers, UserAction } from "../users/actionUser"; +import { deleteUsers } from "../users/deleteUser"; import { - DeleteUserStatus, - EnterpriseUserStatus, - formatTransferStatus, - formatUserStatus, -} from '../users/userTypes' + DeleteUserStatus, + EnterpriseUserStatus, + formatTransferStatus, + formatUserStatus, +} from "../users/userTypes"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseNode, - type EnterpriseRoleTeamLink, - type EnterpriseRoleUserLink, - type EnterpriseTeamUserLink, - type EnterpriseUser, - type EnterpriseUserAliasLink, - type GetEnterpriseDataResponse, -} from '../teams/enterpriseData' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseRoleTeamLink, + type EnterpriseRoleUserLink, + type EnterpriseTeamUserLink, + type EnterpriseUser, + type EnterpriseUserAliasLink, + type GetEnterpriseDataResponse, +} from "../teams/enterpriseData"; import { - ACTION_COLUMN_LABELS, - ACTION_DEFAULT_DAYS, - ACTION_DEFAULT_DAYS_BY_TARGET, - ACTION_EVENT_SUMMARY_ROW_LIMIT, - ACTION_REPORT_COLUMN_ORDER, - ACTION_STATUS_EVENT_TYPES, - ACTION_USERNAME_BATCH_SIZE, - ActionReportColumn, - AdminAction, - DEFAULT_ACTION_REPORT_COLUMNS, - SECONDS_PER_DAY, - SUPPORTED_ACTION_REPORT_COLUMNS, - TargetUserStatus, - type ActionReportEntry, - type ActionReportOptions, - type ActionReportResult, - type ActionResult, - type AuditReportFilterPayload, -} from './reportTypes' -import { assertSucceeded, chunkArray, resolveTimezone } from './reportUtils' + ACTION_COLUMN_LABELS, + ACTION_DEFAULT_DAYS, + ACTION_DEFAULT_DAYS_BY_TARGET, + ACTION_EVENT_SUMMARY_ROW_LIMIT, + ACTION_REPORT_COLUMN_ORDER, + ACTION_STATUS_EVENT_TYPES, + ACTION_USERNAME_BATCH_SIZE, + ActionReportColumn, + AdminAction, + DEFAULT_ACTION_REPORT_COLUMNS, + SECONDS_PER_DAY, + SUPPORTED_ACTION_REPORT_COLUMNS, + TargetUserStatus, + type ActionReportEntry, + type ActionReportOptions, + type ActionReportResult, + type ActionResult, + type AuditReportFilterPayload, +} from "./reportTypes"; +import { assertSucceeded, chunkArray, resolveTimezone } from "./reportUtils"; const ACTION_REPORT_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Nodes, - EnterpriseDataInclude.Users, - EnterpriseDataInclude.Teams, - EnterpriseDataInclude.TeamUsers, - EnterpriseDataInclude.Roles, - EnterpriseDataInclude.RoleUsers, - EnterpriseDataInclude.RoleTeams, - EnterpriseDataInclude.UserAliases, -] - -type AuditUsernameField = 'username' | 'to_username' + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.RoleTeams, + EnterpriseDataInclude.UserAliases, +]; + +type AuditUsernameField = "username" | "to_username"; type TargetAuditConfig = { - auditColumn: AuditUsernameField - auditFilterField: AuditUsernameField - pickCandidates: (users: EnterpriseUser[]) => EnterpriseUser[] - identity: (user: EnterpriseUser) => string -} - -const ALLOWED_ACTIONS: Readonly>> = { - [TargetUserStatus.NoLogon]: new Set([AdminAction.None, AdminAction.Lock]), - [TargetUserStatus.NoUpdate]: new Set([AdminAction.None]), - [TargetUserStatus.Locked]: new Set([AdminAction.None, AdminAction.Delete, AdminAction.Transfer]), - [TargetUserStatus.Invited]: new Set([AdminAction.None, AdminAction.Delete]), - [TargetUserStatus.NoRecovery]: new Set([AdminAction.None]), -} + auditColumn: AuditUsernameField; + auditFilterField: AuditUsernameField; + pickCandidates: (users: EnterpriseUser[]) => EnterpriseUser[]; + identity: (user: EnterpriseUser) => string; +}; + +const ALLOWED_ACTIONS: Readonly< + Record> +> = { + [TargetUserStatus.NoLogon]: new Set([AdminAction.None, AdminAction.Lock]), + [TargetUserStatus.NoUpdate]: new Set([AdminAction.None]), + [TargetUserStatus.Locked]: new Set([ + AdminAction.None, + AdminAction.Delete, + AdminAction.Transfer, + ]), + [TargetUserStatus.Invited]: new Set([AdminAction.None, AdminAction.Delete]), + [TargetUserStatus.NoRecovery]: new Set([AdminAction.None]), +}; function usernameTargetConfig( - pickCandidates: (users: EnterpriseUser[]) => EnterpriseUser[] + pickCandidates: (users: EnterpriseUser[]) => EnterpriseUser[], ): TargetAuditConfig { - return { - auditColumn: 'username', - auditFilterField: 'username', - pickCandidates, - identity: (user) => user.username, - } + return { + auditColumn: "username", + auditFilterField: "username", + pickCandidates, + identity: (user) => user.username, + }; } const TARGET_AUDIT_CONFIG: Record = { - [TargetUserStatus.NoLogon]: usernameTargetConfig((users) => - users.filter((user) => formatUserStatus(user) === 'Active') - ), - [TargetUserStatus.NoUpdate]: usernameTargetConfig((users) => - users.filter((user) => formatUserStatus(user) === 'Active') - ), - [TargetUserStatus.Locked]: { - auditColumn: 'to_username', - auditFilterField: 'to_username', - pickCandidates: (users) => users.filter((user) => formatUserStatus(user) === 'Locked'), - identity: (user) => user.username, - }, - [TargetUserStatus.Invited]: usernameTargetConfig((users) => - users.filter((user) => user.status === EnterpriseUserStatus.Invited) - ), - [TargetUserStatus.NoRecovery]: usernameTargetConfig((users) => - users.filter((user) => formatUserStatus(user) === 'Active') - ), -} + [TargetUserStatus.NoLogon]: usernameTargetConfig((users) => + users.filter((user) => formatUserStatus(user) === "Active"), + ), + [TargetUserStatus.NoUpdate]: usernameTargetConfig((users) => + users.filter((user) => formatUserStatus(user) === "Active"), + ), + [TargetUserStatus.Locked]: { + auditColumn: "to_username", + auditFilterField: "to_username", + pickCandidates: (users) => + users.filter((user) => formatUserStatus(user) === "Locked"), + identity: (user) => user.username, + }, + [TargetUserStatus.Invited]: usernameTargetConfig((users) => + users.filter((user) => user.status === EnterpriseUserStatus.Invited), + ), + [TargetUserStatus.NoRecovery]: usernameTargetConfig((users) => + users.filter((user) => formatUserStatus(user) === "Active"), + ), +}; export async function runActionReport( - auth: Auth, - options: ActionReportOptions = {} + auth: Auth, + options: ActionReportOptions = {}, ): Promise { - const target = options.target ?? TargetUserStatus.NoLogon - const daysSince = options.daysSince ?? ACTION_DEFAULT_DAYS_BY_TARGET[target] ?? ACTION_DEFAULT_DAYS - const applyAction = options.applyAction ?? AdminAction.None - - validateActionReportOptions(target, applyAction, options.targetUser) - - const enterpriseData = new EnterpriseDataManager(auth) - const data = await enterpriseData.getData(ACTION_REPORT_INCLUDES) - await enterpriseData.decryptNodeNames(data.nodes || []) - await enterpriseData.getDisplayNames() - - const entries = await generateActionReportEntries(auth, data, { - target, - daysSince, - timezone: resolveTimezone(options.timezone), - nodeIds: resolveNodeFilter(data.nodes || [], options.node), - }) - - const actionResult = await applyAdminAction(auth, entries, { - applyAction, - targetUser: options.targetUser, - dryRun: options.dryRun === true, - }) - - const columns = resolveActionReportColumns(options.columns) - const headers = columns.map((column) => ACTION_COLUMN_LABELS[column]) - const rows = entries.map((entry) => columns.map((column) => actionReportEntryCell(entry, column))) - - return { - target, - headers, - rows, - entries, - actionResult, - } + const target = options.target ?? TargetUserStatus.NoLogon; + const daysSince = + options.daysSince ?? + ACTION_DEFAULT_DAYS_BY_TARGET[target] ?? + ACTION_DEFAULT_DAYS; + const applyAction = options.applyAction ?? AdminAction.None; + + validateActionReportOptions(target, applyAction, options.targetUser); + + const enterpriseData = new EnterpriseDataManager(auth); + const data = await enterpriseData.getData(ACTION_REPORT_INCLUDES); + await enterpriseData.decryptNodeNames(data.nodes || []); + await enterpriseData.getDisplayNames(); + + const entries = await generateActionReportEntries(auth, data, { + target, + daysSince, + timezone: resolveTimezone(options.timezone), + nodeIds: resolveNodeFilter(data.nodes || [], options.node), + }); + + const actionResult = await applyAdminAction(auth, entries, { + applyAction, + targetUser: options.targetUser, + dryRun: options.dryRun === true, + }); + + const columns = resolveActionReportColumns(options.columns); + const headers = columns.map((column) => ACTION_COLUMN_LABELS[column]); + const rows = entries.map((entry) => + columns.map((column) => actionReportEntryCell(entry, column)), + ); + + return { + target, + headers, + rows, + entries, + actionResult, + }; } export function getAllowedActions(target: TargetUserStatus): AdminAction[] { - return [...ALLOWED_ACTIONS[target]] + return [...ALLOWED_ACTIONS[target]]; } export function getDefaultDaysSince(target: TargetUserStatus): number { - return ACTION_DEFAULT_DAYS_BY_TARGET[target] ?? ACTION_DEFAULT_DAYS + return ACTION_DEFAULT_DAYS_BY_TARGET[target] ?? ACTION_DEFAULT_DAYS; } async function generateActionReportEntries( - auth: Auth, - data: GetEnterpriseDataResponse, - options: { - target: TargetUserStatus - daysSince: number - timezone: string - nodeIds: Set | null - } + auth: Auth, + data: GetEnterpriseDataResponse, + options: { + target: TargetUserStatus; + daysSince: number; + timezone: string; + nodeIds: Set | null; + }, ): Promise { - const config = TARGET_AUDIT_CONFIG[options.target] - let candidates = config.pickCandidates(data.users || []) - - if (options.nodeIds) { - candidates = candidates.filter( - (user) => user.node_id != null && options.nodeIds!.has(user.node_id) - ) - } - if (candidates.length === 0) return [] - - const identities = candidates.map((user) => config.identity(user)).filter(Boolean) - const recentlyActive = await queryUsersWithRecentEvents( - auth, - config, - identities, - ACTION_STATUS_EVENT_TYPES[options.target], - options.daysSince, - options.timezone - ) - - const context = buildActionEntryContext(data) - return candidates - .filter((user) => { - const identity = config.identity(user).trim().toLowerCase() - return identity && !recentlyActive.has(identity) - }) - .map((user) => buildActionEntry(user, context)) + const config = TARGET_AUDIT_CONFIG[options.target]; + let candidates = config.pickCandidates(data.users || []); + + if (options.nodeIds) { + candidates = candidates.filter( + (user) => user.node_id != null && options.nodeIds!.has(user.node_id), + ); + } + if (candidates.length === 0) return []; + + const identities = candidates + .map((user) => config.identity(user)) + .filter(Boolean); + const recentlyActive = await queryUsersWithRecentEvents( + auth, + config, + identities, + ACTION_STATUS_EVENT_TYPES[options.target], + options.daysSince, + options.timezone, + ); + + const context = buildActionEntryContext(data); + return candidates + .filter((user) => { + const identity = config.identity(user).trim().toLowerCase(); + return identity && !recentlyActive.has(identity); + }) + .map((user) => buildActionEntry(user, context)); } async function queryUsersWithRecentEvents( - auth: Auth, - config: TargetAuditConfig, - identities: string[], - eventTypes: readonly string[], - daysSince: number, - timezone: string + auth: Auth, + config: TargetAuditConfig, + identities: string[], + eventTypes: readonly string[], + daysSince: number, + timezone: string, ): Promise> { - const active = new Set() - const createdFilter: AuditReportFilterPayload['created'] = { - min: Math.floor(Date.now() / 1000) - daysSince * SECONDS_PER_DAY, - } - - for (const batch of chunkArray(identities, ACTION_USERNAME_BATCH_SIZE)) { - const filter: AuditReportFilterPayload = { - created: createdFilter, - audit_event_type: [...eventTypes], - [config.auditFilterField]: batch, - } - - const response = await auth.executeRestCommand( - getAuditEventReportsCommand({ - report_type: 'span', - scope: 'enterprise', - timezone, - limit: ACTION_EVENT_SUMMARY_ROW_LIMIT, - aggregate: ['last_created'], - columns: [config.auditColumn], - filter, - }) - ) - assertSucceeded(response, 'get_audit_event_reports failed', ResultCodes.ACTION_REPORT_FAILED) - - for (const row of response.audit_event_overview_report_rows ?? []) { - const value = (row as Record)[config.auditColumn] - if (value == null || value === '') continue - active.add(String(value).trim().toLowerCase()) - } + const active = new Set(); + const createdFilter: AuditReportFilterPayload["created"] = { + min: Math.floor(Date.now() / 1000) - daysSince * SECONDS_PER_DAY, + }; + + for (const batch of chunkArray(identities, ACTION_USERNAME_BATCH_SIZE)) { + const filter: AuditReportFilterPayload = { + created: createdFilter, + audit_event_type: [...eventTypes], + [config.auditFilterField]: batch, + }; + + const response = await auth.executeRestCommand( + getAuditEventReportsCommand({ + report_type: "span", + scope: "enterprise", + timezone, + limit: ACTION_EVENT_SUMMARY_ROW_LIMIT, + aggregate: ["last_created"], + columns: [config.auditColumn], + filter, + }), + ); + assertSucceeded( + response, + "get_audit_event_reports failed", + ResultCodes.ACTION_REPORT_FAILED, + ); + + for (const row of response.audit_event_overview_report_rows ?? []) { + const value = (row as Record)[config.auditColumn]; + if (value == null || value === "") continue; + active.add(String(value).trim().toLowerCase()); } + } - return active + return active; } type ActionEntryContext = { - nodePaths: Map - userTeams: Map - userRoles: Map - userAliases: Map -} - -function buildActionEntryContext(data: GetEnterpriseDataResponse): ActionEntryContext { - const nodes = data.nodes || [] - const nodePaths = new Map( - nodes.map((node) => [ - node.node_id, - EnterpriseDataManager.getNodePath(nodes, node.node_id, { omitRoot: false }), - ]) - ) - const teamNames = new Map((data.teams || []).map((team) => [team.team_uid, team.name])) - const roleNames = new Map((data.roles || []).map((role) => [role.role_id, role.displayName || String(role.role_id)])) - - return { - nodePaths, - userTeams: buildUserTeamNames(data.team_users || [], teamNames), - userRoles: buildUserRoleNames(data.role_users || [], data.role_teams || [], data.team_users || [], roleNames), - userAliases: buildUserAliases(data.user_aliases || []), - } + nodePaths: Map; + userTeams: Map; + userRoles: Map; + userAliases: Map; +}; + +function buildActionEntryContext( + data: GetEnterpriseDataResponse, +): ActionEntryContext { + const nodes = data.nodes || []; + const nodePaths = new Map( + nodes.map((node) => [ + node.node_id, + EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + }), + ]), + ); + const teamNames = new Map( + (data.teams || []).map((team) => [team.team_uid, team.name]), + ); + const roleNames = new Map( + (data.roles || []).map((role) => [ + role.role_id, + role.displayName || String(role.role_id), + ]), + ); + + return { + nodePaths, + userTeams: buildUserTeamNames(data.team_users || [], teamNames), + userRoles: buildUserRoleNames( + data.role_users || [], + data.role_teams || [], + data.team_users || [], + roleNames, + ), + userAliases: buildUserAliases(data.user_aliases || []), + }; } -function buildActionEntry(user: EnterpriseUser, context: ActionEntryContext): ActionReportEntry { - return { - enterpriseUserId: user.enterprise_user_id, - email: user.username, - fullName: user.full_name || '', - status: formatUserStatus(user), - transferStatus: formatTransferStatus(user), - nodePath: context.nodePaths.get(user.node_id || 0) || '', - teams: context.userTeams.get(user.enterprise_user_id) || [], - roles: context.userRoles.get(user.enterprise_user_id) || [], - aliases: context.userAliases.get(user.enterprise_user_id) || [], - tfaEnabled: user.tfa_enabled === true, - } +function buildActionEntry( + user: EnterpriseUser, + context: ActionEntryContext, +): ActionReportEntry { + return { + enterpriseUserId: user.enterprise_user_id, + email: user.username, + fullName: user.full_name || "", + status: formatUserStatus(user), + transferStatus: formatTransferStatus(user), + nodePath: context.nodePaths.get(user.node_id || 0) || "", + teams: context.userTeams.get(user.enterprise_user_id) || [], + roles: context.userRoles.get(user.enterprise_user_id) || [], + aliases: context.userAliases.get(user.enterprise_user_id) || [], + tfaEnabled: user.tfa_enabled === true, + }; } function buildUserTeamNames( - links: EnterpriseTeamUserLink[], - teamNames: Map + links: EnterpriseTeamUserLink[], + teamNames: Map, ): Map { - const map = new Map>() - for (const link of links) { - if (!link.team_uid) continue - const name = teamNames.get(link.team_uid) || link.team_uid - const names = map.get(link.enterprise_user_id) ?? new Set() - names.add(name) - map.set(link.enterprise_user_id, names) - } - return new Map([...map.entries()].map(([id, names]) => [id, [...names].sort()])) + const map = new Map>(); + for (const link of links) { + if (!link.team_uid) continue; + const name = teamNames.get(link.team_uid) || link.team_uid; + const names = map.get(link.enterprise_user_id) ?? new Set(); + names.add(name); + map.set(link.enterprise_user_id, names); + } + return new Map( + [...map.entries()].map(([id, names]) => [id, [...names].sort()]), + ); } function buildUserRoleNames( - roleUsers: EnterpriseRoleUserLink[], - roleTeams: EnterpriseRoleTeamLink[], - teamUsers: EnterpriseTeamUserLink[], - roleNames: Map + roleUsers: EnterpriseRoleUserLink[], + roleTeams: EnterpriseRoleTeamLink[], + teamUsers: EnterpriseTeamUserLink[], + roleNames: Map, ): Map { - const map = new Map>() - - for (const link of roleUsers) { - const roles = map.get(link.enterprise_user_id) ?? new Set() - roles.add(link.role_id) - map.set(link.enterprise_user_id, roles) - } - - const rolesForTeam = new Map>() - for (const link of roleTeams) { - if (!link.team_uid) continue - const roles = rolesForTeam.get(link.team_uid) ?? new Set() - roles.add(link.role_id) - rolesForTeam.set(link.team_uid, roles) - } - - for (const link of teamUsers) { - if (!link.team_uid) continue - const teamRoles = rolesForTeam.get(link.team_uid) - if (!teamRoles) continue - const userRoles = map.get(link.enterprise_user_id) ?? new Set() - teamRoles.forEach((roleId) => userRoles.add(roleId)) - map.set(link.enterprise_user_id, userRoles) - } - - return new Map( - [...map.entries()].map(([id, roleIds]) => [ - id, - [...roleIds].map((roleId) => roleNames.get(roleId) || String(roleId)).sort(), - ]) - ) + const map = new Map>(); + + for (const link of roleUsers) { + const roles = map.get(link.enterprise_user_id) ?? new Set(); + roles.add(link.role_id); + map.set(link.enterprise_user_id, roles); + } + + const rolesForTeam = new Map>(); + for (const link of roleTeams) { + if (!link.team_uid) continue; + const roles = rolesForTeam.get(link.team_uid) ?? new Set(); + roles.add(link.role_id); + rolesForTeam.set(link.team_uid, roles); + } + + for (const link of teamUsers) { + if (!link.team_uid) continue; + const teamRoles = rolesForTeam.get(link.team_uid); + if (!teamRoles) continue; + const userRoles = map.get(link.enterprise_user_id) ?? new Set(); + teamRoles.forEach((roleId) => userRoles.add(roleId)); + map.set(link.enterprise_user_id, userRoles); + } + + return new Map( + [...map.entries()].map(([id, roleIds]) => [ + id, + [...roleIds] + .map((roleId) => roleNames.get(roleId) || String(roleId)) + .sort(), + ]), + ); } -function buildUserAliases(links: EnterpriseUserAliasLink[]): Map { - const map = new Map() - for (const link of links) { - if (!link.username) continue - const aliases = map.get(link.enterprise_user_id) - if (aliases) aliases.push(link.username) - else map.set(link.enterprise_user_id, [link.username]) - } - return map +function buildUserAliases( + links: EnterpriseUserAliasLink[], +): Map { + const map = new Map(); + for (const link of links) { + if (!link.username) continue; + const aliases = map.get(link.enterprise_user_id); + if (aliases) aliases.push(link.username); + else map.set(link.enterprise_user_id, [link.username]); + } + return map; } async function applyAdminAction( - auth: Auth, - entries: ActionReportEntry[], - options: { applyAction: AdminAction; targetUser?: string; dryRun: boolean } + auth: Auth, + entries: ActionReportEntry[], + options: { applyAction: AdminAction; targetUser?: string; dryRun: boolean }, ): Promise { - const { applyAction, targetUser, dryRun } = options - - if (applyAction === AdminAction.None) { - return { action: AdminAction.None, status: 'none', affectedCount: 0, serverMessage: 'n/a' } - } - if (entries.length === 0) { - return { action: applyAction, status: 'no users matched', affectedCount: 0, serverMessage: 'n/a' } - } - if (dryRun) { - return { action: applyAction, status: 'dry run', affectedCount: entries.length, serverMessage: 'n/a' } - } + const { applyAction, targetUser, dryRun } = options; - const emails = entries.map((entry) => entry.email) - - try { - switch (applyAction) { - case AdminAction.Lock: { - const result = await actionUsers(auth, { action: UserAction.Lock, emails }) - return { - action: AdminAction.Lock, - status: result.success ? 'success' : 'partial', - affectedCount: result.succeeded, - serverMessage: `Succeeded: ${result.succeeded}, Skipped: ${result.skipped}, Failed: ${result.failed}`, - } - } - case AdminAction.Delete: { - const result = await deleteUsers(auth, { emails }) - const deleted = result.items.filter((item) => item.status === DeleteUserStatus.Deleted).length - return { - action: AdminAction.Delete, - status: result.success ? 'success' : 'partial', - affectedCount: deleted, - serverMessage: `Deleted: ${deleted}, Failed: ${result.failed}`, - } - } - case AdminAction.Transfer: { - if (!targetUser?.trim()) { - throw new KeeperSdkError( - '"targetUser" is required for transfer action.', - ResultCodes.ACTION_REPORT_TARGET_USER_REQUIRED - ) - } - throw new KeeperSdkError( - 'Account transfer is not yet supported in the JavaScript SDK.', - ResultCodes.ACTION_REPORT_TRANSFER_NOT_SUPPORTED - ) - } - default: - return { action: applyAction, status: 'unsupported', affectedCount: 0, serverMessage: 'n/a' } - } - } catch (err) { + if (applyAction === AdminAction.None) { + return { + action: AdminAction.None, + status: "none", + affectedCount: 0, + serverMessage: "n/a", + }; + } + if (entries.length === 0) { + return { + action: applyAction, + status: "no users matched", + affectedCount: 0, + serverMessage: "n/a", + }; + } + if (dryRun) { + return { + action: applyAction, + status: "dry run", + affectedCount: entries.length, + serverMessage: "n/a", + }; + } + + const emails = entries.map((entry) => entry.email); + + try { + switch (applyAction) { + case AdminAction.Lock: { + const result = await actionUsers(auth, { + action: UserAction.Lock, + emails, + }); + return { + action: AdminAction.Lock, + status: result.success ? "success" : "partial", + affectedCount: result.succeeded, + serverMessage: `Succeeded: ${result.succeeded}, Skipped: ${result.skipped}, Failed: ${result.failed}`, + }; + } + case AdminAction.Delete: { + const result = await deleteUsers(auth, { emails }); + const deleted = result.items.filter( + (item) => item.status === DeleteUserStatus.Deleted, + ).length; return { - action: applyAction, - status: 'failed', - affectedCount: 0, - serverMessage: extractErrorMessage(err), + action: AdminAction.Delete, + status: result.success ? "success" : "partial", + affectedCount: deleted, + serverMessage: `Deleted: ${deleted}, Failed: ${result.failed}`, + }; + } + case AdminAction.Transfer: { + if (!targetUser?.trim()) { + throw new KeeperSdkError( + '"targetUser" is required for transfer action.', + ResultCodes.ACTION_REPORT_TARGET_USER_REQUIRED, + ); } + throw new KeeperSdkError( + "Account transfer is not yet supported in the JavaScript SDK.", + ResultCodes.ACTION_REPORT_TRANSFER_NOT_SUPPORTED, + ); + } + default: + return { + action: applyAction, + status: "unsupported", + affectedCount: 0, + serverMessage: "n/a", + }; } + } catch (err) { + return { + action: applyAction, + status: "failed", + affectedCount: 0, + serverMessage: extractErrorMessage(err), + }; + } } function validateActionReportOptions( - target: TargetUserStatus, - applyAction: AdminAction, - targetUser: string | undefined + target: TargetUserStatus, + applyAction: AdminAction, + targetUser: string | undefined, ): void { - if (!ALLOWED_ACTIONS[target].has(applyAction)) { - throw new KeeperSdkError( - `Action "${applyAction}" is not allowed for target "${target}".`, - ResultCodes.ACTION_REPORT_INVALID_ACTION - ) - } - if (applyAction === AdminAction.Transfer && !targetUser?.trim()) { - throw new KeeperSdkError( - '"targetUser" is required when applyAction is "transfer".', - ResultCodes.ACTION_REPORT_TARGET_USER_REQUIRED - ) - } + if (!ALLOWED_ACTIONS[target].has(applyAction)) { + throw new KeeperSdkError( + `Action "${applyAction}" is not allowed for target "${target}".`, + ResultCodes.ACTION_REPORT_INVALID_ACTION, + ); + } + if (applyAction === AdminAction.Transfer && !targetUser?.trim()) { + throw new KeeperSdkError( + '"targetUser" is required when applyAction is "transfer".', + ResultCodes.ACTION_REPORT_TARGET_USER_REQUIRED, + ); + } } -function resolveNodeFilter(nodes: EnterpriseNode[], nodeFilter: string | undefined): Set | null { - if (!nodeFilter?.trim()) return null - - const trimmed = nodeFilter.trim() - const numericId = Number(trimmed) - let rootNode: EnterpriseNode | undefined - - if (Number.isInteger(numericId)) { - rootNode = nodes.find((node) => node.node_id === numericId) - } - if (!rootNode) { - const lowered = trimmed.toLowerCase() - const matches = nodes.filter((node) => (node.displayName || '').trim().toLowerCase() === lowered) - if (matches.length === 1) rootNode = matches[0] - else if (matches.length > 1) { - throw new KeeperSdkError(`Multiple nodes match "${trimmed}".`, ResultCodes.ACTION_REPORT_NODE_NOT_UNIQUE) - } - } - if (!rootNode) { - throw new KeeperSdkError(`Node "${trimmed}" was not found.`, ResultCodes.ACTION_REPORT_NODE_NOT_FOUND) - } - - const children = new Map() - for (const node of nodes) { - const parentId = node.parent_id || 0 - const siblings = children.get(parentId) ?? [] - siblings.push(node.node_id) - children.set(parentId, siblings) +function resolveNodeFilter( + nodes: EnterpriseNode[], + nodeFilter: string | undefined, +): Set | null { + if (!nodeFilter?.trim()) return null; + + const trimmed = nodeFilter.trim(); + const numericId = Number(trimmed); + let rootNode: EnterpriseNode | undefined; + + if (Number.isInteger(numericId)) { + rootNode = nodes.find((node) => node.node_id === numericId); + } + if (!rootNode) { + const lowered = trimmed.toLowerCase(); + const matches = nodes.filter( + (node) => (node.displayName || "").trim().toLowerCase() === lowered, + ); + if (matches.length === 1) rootNode = matches[0]; + else if (matches.length > 1) { + throw new KeeperSdkError( + `Multiple nodes match "${trimmed}".`, + ResultCodes.ACTION_REPORT_NODE_NOT_UNIQUE, + ); } - - const allowed = new Set([rootNode.node_id]) - const queue = [rootNode.node_id] - while (queue.length > 0) { - const current = queue.shift()! - for (const childId of children.get(current) || []) { - if (allowed.has(childId)) continue - allowed.add(childId) - queue.push(childId) - } + } + if (!rootNode) { + throw new KeeperSdkError( + `Node "${trimmed}" was not found.`, + ResultCodes.ACTION_REPORT_NODE_NOT_FOUND, + ); + } + + const children = new Map(); + for (const node of nodes) { + const parentId = node.parent_id || 0; + const siblings = children.get(parentId) ?? []; + siblings.push(node.node_id); + children.set(parentId, siblings); + } + + const allowed = new Set([rootNode.node_id]); + const queue = [rootNode.node_id]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const childId of children.get(current) || []) { + if (allowed.has(childId)) continue; + allowed.add(childId); + queue.push(childId); } + } - return allowed + return allowed; } -function resolveActionReportColumns(input: ActionReportOptions['columns']): ActionReportColumn[] { - const defaultColumns: ActionReportColumn[] = [ActionReportColumn.UserId, ...DEFAULT_ACTION_REPORT_COLUMNS] - if (input == null) return defaultColumns - - const requested = - typeof input === 'string' - ? input.split(',').map((part) => part.trim()) - : input.map((part) => String(part).trim()) - - const allowed = new Set(SUPPORTED_ACTION_REPORT_COLUMNS) - const seen = new Set() - for (const column of requested) { - if (column && allowed.has(column)) seen.add(column as ActionReportColumn) - } - - if (seen.size === 0) return defaultColumns - - seen.add(ActionReportColumn.UserId) - return ACTION_REPORT_COLUMN_ORDER.filter((column) => seen.has(column)) +function resolveActionReportColumns( + input: ActionReportOptions["columns"], +): ActionReportColumn[] { + const defaultColumns: ActionReportColumn[] = [ + ActionReportColumn.UserId, + ...DEFAULT_ACTION_REPORT_COLUMNS, + ]; + if (input == null) return defaultColumns; + + const requested = + typeof input === "string" + ? input.split(",").map((part) => part.trim()) + : input.map((part) => String(part).trim()); + + const allowed = new Set(SUPPORTED_ACTION_REPORT_COLUMNS); + const seen = new Set(); + for (const column of requested) { + if (column && allowed.has(column)) seen.add(column as ActionReportColumn); + } + + if (seen.size === 0) return defaultColumns; + + seen.add(ActionReportColumn.UserId); + return ACTION_REPORT_COLUMN_ORDER.filter((column) => seen.has(column)); } -function actionReportEntryCell(entry: ActionReportEntry, column: ActionReportColumn): string { - switch (column) { - case ActionReportColumn.UserId: - return String(entry.enterpriseUserId) - case ActionReportColumn.Email: - return entry.email - case ActionReportColumn.Name: - return entry.fullName - case ActionReportColumn.Status: - return entry.status - case ActionReportColumn.TransferStatus: - return entry.transferStatus - case ActionReportColumn.Node: - return entry.nodePath - case ActionReportColumn.TeamCount: - return String(entry.teams.length) - case ActionReportColumn.Teams: - return entry.teams.join(', ') - case ActionReportColumn.RoleCount: - return String(entry.roles.length) - case ActionReportColumn.Roles: - return entry.roles.join(', ') - case ActionReportColumn.Alias: - return entry.aliases.join(', ') - case ActionReportColumn.TwoFaEnabled: - return entry.tfaEnabled ? 'true' : 'false' - } +function actionReportEntryCell( + entry: ActionReportEntry, + column: ActionReportColumn, +): string { + switch (column) { + case ActionReportColumn.UserId: + return String(entry.enterpriseUserId); + case ActionReportColumn.Email: + return entry.email; + case ActionReportColumn.Name: + return entry.fullName; + case ActionReportColumn.Status: + return entry.status; + case ActionReportColumn.TransferStatus: + return entry.transferStatus; + case ActionReportColumn.Node: + return entry.nodePath; + case ActionReportColumn.TeamCount: + return String(entry.teams.length); + case ActionReportColumn.Teams: + return entry.teams.join(", "); + case ActionReportColumn.RoleCount: + return String(entry.roles.length); + case ActionReportColumn.Roles: + return entry.roles.join(", "); + case ActionReportColumn.Alias: + return entry.aliases.join(", "); + case ActionReportColumn.TwoFaEnabled: + return entry.tfaEnabled ? "true" : "false"; + } } diff --git a/KeeperSdk/src/enterpriseReport/auditReport.ts b/KeeperSdk/src/enterpriseReport/auditReport.ts index c0a975cd..409f197a 100644 --- a/KeeperSdk/src/enterpriseReport/auditReport.ts +++ b/KeeperSdk/src/enterpriseReport/auditReport.ts @@ -1,739 +1,905 @@ -import type { Auth } from '@keeper-security/keeperapi' +import type { Auth } from "@keeper-security/keeperapi"; import { - getAuditEventDimensionsCommand, - getAuditEventReportsCommand, - getEnterpriseDataCommand, -} from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' + getAuditEventDimensionsCommand, + getAuditEventReportsCommand, + getEnterpriseDataCommand, +} from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; import { - AuditAggregate, - AuditReportFormat, - AuditReportOrder, - AUDIT_DEFAULT_RAW_LIMIT, - AUDIT_DEFAULT_SUMMARY_LIMIT, - AUDIT_DIMENSION_API_LIMIT, - AUDIT_MISC_FIELDS, - AUDIT_NO_ARAM_RAW_LIMIT, - AUDIT_RAW_FIELDS, - AUDIT_RAW_PAGE_SIZE, - AUDIT_SUMMARY_MAX_LIMIT, - AUDIT_SYNTAX_HELP, - AUDIT_VIRTUAL_DIMENSIONS, - CREATED_PRESETS, - SUMMARY_REPORT_TYPES, -} from './reportTypes' + AuditAggregate, + AuditReportFormat, + AuditReportOrder, + AUDIT_DEFAULT_RAW_LIMIT, + AUDIT_DEFAULT_SUMMARY_LIMIT, + AUDIT_DIMENSION_API_LIMIT, + AUDIT_MISC_FIELDS, + AUDIT_NO_ARAM_RAW_LIMIT, + AUDIT_RAW_FIELDS, + AUDIT_RAW_PAGE_SIZE, + AUDIT_SUMMARY_MAX_LIMIT, + AUDIT_SYNTAX_HELP, + AUDIT_VIRTUAL_DIMENSIONS, + CREATED_PRESETS, + SUMMARY_REPORT_TYPES, +} from "./reportTypes"; import type { - AuditDimensionEventType, - AuditDimensionIpAddress, - AuditDimensionKeeperVersion, - AuditDimensionRow, - AuditEventOverviewReportRow, - AuditReportFilter, - AuditReportFilterPayload, - AuditReportOptions, - AuditReportResult, - AuditSummaryReportType, - CreatedFilterCriteria, - CreatedPreset, - EnterpriseAuditLicense, -} from './reportTypes' -import { assertSucceeded, resolveTimezone, toAuditApiOrder } from './reportUtils' - -let dimensionCache = new Map() -let cachedUsername = '' -let syslogTemplates: Map | null = null - -export async function runAuditReport(auth: Auth, options: AuditReportOptions = {}): Promise { - if (options.syntaxHelp || !options.reportType) { - return buildSyntaxHelp(auth) - } - - const hasAram = await resolveHasAram(auth, options.hasAram) - const reportType = options.reportType - - if (reportType === 'dim') return runDimensionReport(auth, options) - if (reportType === 'raw') return runRawReport(auth, options, hasAram) - if ((SUMMARY_REPORT_TYPES as readonly string[]).includes(reportType)) { - return runSummaryReport(auth, options, hasAram, reportType as AuditSummaryReportType) - } - - throw new KeeperSdkError(`Unsupported report type "${reportType}".`, ResultCodes.AUDIT_INVALID_REPORT_TYPE) + AuditDimensionEventType, + AuditDimensionIpAddress, + AuditDimensionKeeperVersion, + AuditDimensionRow, + AuditEventOverviewReportRow, + AuditReportFilter, + AuditReportFilterPayload, + AuditReportOptions, + AuditReportResult, + AuditSummaryReportType, + CreatedFilterCriteria, + CreatedPreset, + EnterpriseAuditLicense, +} from "./reportTypes"; +import { + assertSucceeded, + resolveTimezone, + toAuditApiOrder, +} from "./reportUtils"; + +let dimensionCache = new Map(); +let cachedUsername = ""; +let syslogTemplates: Map | null = null; + +export async function runAuditReport( + auth: Auth, + options: AuditReportOptions = {}, +): Promise { + if (options.syntaxHelp || !options.reportType) { + return buildSyntaxHelp(auth); + } + + const hasAram = await resolveHasAram(auth, options.hasAram); + const reportType = options.reportType; + + if (reportType === "dim") return runDimensionReport(auth, options); + if (reportType === "raw") return runRawReport(auth, options, hasAram); + if ((SUMMARY_REPORT_TYPES as readonly string[]).includes(reportType)) { + return runSummaryReport( + auth, + options, + hasAram, + reportType as AuditSummaryReportType, + ); + } + + throw new KeeperSdkError( + `Unsupported report type "${reportType}".`, + ResultCodes.AUDIT_INVALID_REPORT_TYPE, + ); } async function buildSyntaxHelp(auth: Auth): Promise { - const eventTypes = await loadDimension(auth, 'audit_event_type') - const eventTypeReference = eventTypes - .map((row) => { - if (!row || typeof row !== 'object') return null - const typed = row as AuditDimensionEventType - if (typed.id == null || !typed.name) return null - return { id: typed.id, name: typed.name } - }) - .filter((row): row is { id: number; name: string } => row !== null) - .sort((a, b) => a.id - b.id) - - const headers = ['id', 'name'] - const rows = eventTypeReference.map((row) => [String(row.id), row.name]) - return { - reportType: null, - headers, - rows, - syntaxHelp: AUDIT_SYNTAX_HELP, - eventTypeReference, - } + const eventTypes = await loadDimension(auth, "audit_event_type"); + const eventTypeReference = eventTypes + .map((row) => { + if (!row || typeof row !== "object") return null; + const typed = row as AuditDimensionEventType; + if (typed.id == null || !typed.name) return null; + return { id: typed.id, name: typed.name }; + }) + .filter((row): row is { id: number; name: string } => row !== null) + .sort((a, b) => a.id - b.id); + + const headers = ["id", "name"]; + const rows = eventTypeReference.map((row) => [String(row.id), row.name]); + return { + reportType: null, + headers, + rows, + syntaxHelp: AUDIT_SYNTAX_HELP, + eventTypeReference, + }; } -async function runDimensionReport(auth: Auth, options: AuditReportOptions): Promise { - const columns = options.columns - if (!columns || columns.length !== 1) { - throw new KeeperSdkError( - '"dim" reports expect exactly one "columns" value.', - ResultCodes.AUDIT_DIMENSION_COLUMN_REQUIRED - ) - } - - const column = columns[0] - const fields = dimensionFields(column) - const rows = (await loadDimension(auth, column)).map((row) => dimensionCells(row, fields)) - - return { - reportType: 'dim', - headers: fields, - rows, - } +async function runDimensionReport( + auth: Auth, + options: AuditReportOptions, +): Promise { + const columns = options.columns; + if (!columns || columns.length !== 1) { + throw new KeeperSdkError( + '"dim" reports expect exactly one "columns" value.', + ResultCodes.AUDIT_DIMENSION_COLUMN_REQUIRED, + ); + } + + const column = columns[0]; + const fields = dimensionFields(column); + const rows = (await loadDimension(auth, column)).map((row) => + dimensionCells(row, fields), + ); + + return { + reportType: "dim", + headers: fields, + rows, + }; } async function runRawReport( - auth: Auth, - options: AuditReportOptions, - hasAram: boolean + auth: Auth, + options: AuditReportOptions, + hasAram: boolean, ): Promise { - let filter = await resolveFilter(auth, options.filter ?? {}) - let limit = options.limit - let order = options.order - - if (!hasAram) { - const requested = options.limit - limit = requested != null && requested > 0 ? Math.min(requested, AUDIT_NO_ARAM_RAW_LIMIT) : AUDIT_NO_ARAM_RAW_LIMIT - order = order ?? AuditReportOrder.Desc - if (!filter.created) { - filter.created = 'last_30_days' - } - } - - const reportFormat = options.reportFormat ?? AuditReportFormat.Message - const events = await fetchRawEvents(auth, { - filter: Object.keys(filter).length > 0 ? filter : undefined, - limit, - order, - timezone: options.timezone, - }) - const fields: string[] = [...AUDIT_RAW_FIELDS] - const miscFields = new Set(AUDIT_MISC_FIELDS) - const templates = - reportFormat === AuditReportFormat.Message ? await loadSyslogTemplates(auth) : new Map() - - if (reportFormat === AuditReportFormat.Message) fields.push('message') - - const rows: string[][] = [] - for (const event of events) { - if (reportFormat === AuditReportFormat.Fields) { - for (const key of Object.keys(event)) { - if (miscFields.has(key)) { - fields.push(key) - miscFields.delete(key) - } - } + let filter = await resolveFilter(auth, options.filter ?? {}); + let limit = options.limit; + let order = options.order; + + if (!hasAram) { + const requested = options.limit; + limit = + requested != null && requested > 0 + ? Math.min(requested, AUDIT_NO_ARAM_RAW_LIMIT) + : AUDIT_NO_ARAM_RAW_LIMIT; + order = order ?? AuditReportOrder.Desc; + if (!filter.created) { + filter.created = "last_30_days"; + } + } + + const reportFormat = options.reportFormat ?? AuditReportFormat.Message; + const events = await fetchRawEvents(auth, { + filter: Object.keys(filter).length > 0 ? filter : undefined, + limit, + order, + timezone: options.timezone, + }); + const fields: string[] = [...AUDIT_RAW_FIELDS]; + const miscFields = new Set(AUDIT_MISC_FIELDS); + const templates = + reportFormat === AuditReportFormat.Message + ? await loadSyslogTemplates(auth) + : new Map(); + + if (reportFormat === AuditReportFormat.Message) fields.push("message"); + + const rows: string[][] = []; + for (const event of events) { + if (reportFormat === AuditReportFormat.Fields) { + for (const key of Object.keys(event)) { + if (miscFields.has(key)) { + fields.push(key); + miscFields.delete(key); } - rows.push( - fields.map((field) => - field === 'message' ? eventMessage(event, templates) : formatFieldValue(field, getAuditEventField(event, field), 'raw') - ) - ) - } - - return { - reportType: 'raw', - headers: fields, - rows, - events, - } + } + } + rows.push( + fields.map((field) => + field === "message" + ? eventMessage(event, templates) + : formatFieldValue(field, getAuditEventField(event, field), "raw"), + ), + ); + } + + return { + reportType: "raw", + headers: fields, + rows, + events, + }; } async function runSummaryReport( - auth: Auth, - options: AuditReportOptions, - hasAram: boolean, - reportType: (typeof SUMMARY_REPORT_TYPES)[number] + auth: Auth, + options: AuditReportOptions, + hasAram: boolean, + reportType: (typeof SUMMARY_REPORT_TYPES)[number], ): Promise { - if (!hasAram) { - throw new KeeperSdkError('Audit Reporting addon is not enabled.', ResultCodes.AUDIT_REPORTING_NOT_ENABLED) - } - - const limit = options.limit - if (typeof limit === 'number' && (limit < 0 || limit > AUDIT_SUMMARY_MAX_LIMIT)) { - throw new KeeperSdkError(`Invalid "limit" value: ${limit}`, ResultCodes.AUDIT_INVALID_LIMIT) - } - if (!options.columns?.length) { - throw new KeeperSdkError('"columns" parameter cannot be empty.', ResultCodes.AUDIT_COLUMNS_REQUIRED) - } - - const aggregates = - options.aggregates?.length ? options.aggregates : [AuditAggregate.Occurrences] - const events = await fetchSummaryEvents(auth, { - reportType, - filter: await resolveFilter(auth, options.filter), - limit, - order: options.order, - timezone: options.timezone, - columns: options.columns, - aggregates, - }) - - const fields = [...aggregates, ...(reportType !== 'span' ? ['created'] : []), ...options.columns] - const rows = events.map((event) => - fields.map((field) => formatFieldValue(field, getAuditEventField(event, field), reportType)) - ) - return { - reportType, - headers: fields, - rows, - events, - } + if (!hasAram) { + throw new KeeperSdkError( + "Audit Reporting addon is not enabled.", + ResultCodes.AUDIT_REPORTING_NOT_ENABLED, + ); + } + + const limit = options.limit; + if ( + typeof limit === "number" && + (limit < 0 || limit > AUDIT_SUMMARY_MAX_LIMIT) + ) { + throw new KeeperSdkError( + `Invalid "limit" value: ${limit}`, + ResultCodes.AUDIT_INVALID_LIMIT, + ); + } + if (!options.columns?.length) { + throw new KeeperSdkError( + '"columns" parameter cannot be empty.', + ResultCodes.AUDIT_COLUMNS_REQUIRED, + ); + } + + const aggregates = options.aggregates?.length + ? options.aggregates + : [AuditAggregate.Occurrences]; + const events = await fetchSummaryEvents(auth, { + reportType, + filter: await resolveFilter(auth, options.filter), + limit, + order: options.order, + timezone: options.timezone, + columns: options.columns, + aggregates, + }); + + const fields = [ + ...aggregates, + ...(reportType !== "span" ? ["created"] : []), + ...options.columns, + ]; + const rows = events.map((event) => + fields.map((field) => + formatFieldValue(field, getAuditEventField(event, field), reportType), + ), + ); + return { + reportType, + headers: fields, + rows, + events, + }; } async function fetchRawEvents( - auth: Auth, - options: { - filter?: AuditReportFilter - limit?: number - order?: AuditReportOrder - timezone?: string - } + auth: Auth, + options: { + filter?: AuditReportFilter; + limit?: number; + order?: AuditReportOrder; + timezone?: string; + }, ): Promise { - const limit = options.limit ?? AUDIT_DEFAULT_RAW_LIMIT - if (limit === 0) return [] - - const isPaginated = limit < 0 || limit > AUDIT_RAW_PAGE_SIZE - let workingFilter = options.filter ? { ...options.filter } : undefined - const order = options.order ?? AuditReportOrder.Desc - const timezone = resolveTimezone(options.timezone) - - if (isPaginated && typeof workingFilter?.created === 'string') { - if ((CREATED_PRESETS as readonly string[]).includes(workingFilter.created)) { - workingFilter.created = expandCreatedPreset(workingFilter.created as CreatedPreset) - } - } - - const events: AuditEventOverviewReportRow[] = [] - let eventsReturned = 0 - let done = false - - while (!done) { - done = true - const queryLimit = isPaginated - ? limit <= 0 - ? AUDIT_RAW_PAGE_SIZE - : Math.min(AUDIT_RAW_PAGE_SIZE, limit - eventsReturned) - : limit - - const response = await auth.executeRestCommand( - getAuditEventReportsCommand({ - report_type: 'raw', - scope: 'enterprise', - timezone, - limit: queryLimit, - order: toAuditApiOrder(order), - filter: serializeFilter(workingFilter), - }) - ) - assertSucceeded(response, 'get_audit_event_reports failed', ResultCodes.AUDIT_REPORT_FAILED) - - let pageEvents = (response.audit_event_overview_report_rows ?? []) as AuditEventOverviewReportRow[] - if (!isPaginated && pageEvents.length > queryLimit) { - pageEvents = pageEvents.slice(0, queryLimit) - } - if (isPaginated && pageEvents.length === AUDIT_RAW_PAGE_SIZE) { - done = false - const lastEvent = pageEvents[pageEvents.length - 1] - const ts = Number(lastEvent.created) - let pos = pageEvents.length - 1 - while (pos > 900 && Number(pageEvents[pos].created) === ts) pos -= 1 - if (pos > 900) pageEvents = pageEvents.slice(0, pos) - else workingFilter = advanceCreatedFilter(workingFilter, ts + 1, order) - } - - events.push(...pageEvents) - eventsReturned += pageEvents.length - if ((limit > 0 && eventsReturned >= limit) || pageEvents.length === 0) break - } + const limit = options.limit ?? AUDIT_DEFAULT_RAW_LIMIT; + if (limit === 0) return []; + + const isPaginated = limit < 0 || limit > AUDIT_RAW_PAGE_SIZE; + let workingFilter = options.filter ? { ...options.filter } : undefined; + const order = options.order ?? AuditReportOrder.Desc; + const timezone = resolveTimezone(options.timezone); + + if (isPaginated && typeof workingFilter?.created === "string") { + if ( + (CREATED_PRESETS as readonly string[]).includes(workingFilter.created) + ) { + workingFilter.created = expandCreatedPreset( + workingFilter.created as CreatedPreset, + ); + } + } + + const events: AuditEventOverviewReportRow[] = []; + let eventsReturned = 0; + let done = false; + + while (!done) { + done = true; + const queryLimit = isPaginated + ? limit <= 0 + ? AUDIT_RAW_PAGE_SIZE + : Math.min(AUDIT_RAW_PAGE_SIZE, limit - eventsReturned) + : limit; - return limit > 0 ? events.slice(0, limit) : events + const response = await auth.executeRestCommand( + getAuditEventReportsCommand({ + report_type: "raw", + scope: "enterprise", + timezone, + limit: queryLimit, + order: toAuditApiOrder(order), + filter: serializeFilter(workingFilter), + }), + ); + assertSucceeded( + response, + "get_audit_event_reports failed", + ResultCodes.AUDIT_REPORT_FAILED, + ); + + let pageEvents = (response.audit_event_overview_report_rows ?? + []) as AuditEventOverviewReportRow[]; + if (!isPaginated && pageEvents.length > queryLimit) { + pageEvents = pageEvents.slice(0, queryLimit); + } + if (isPaginated && pageEvents.length === AUDIT_RAW_PAGE_SIZE) { + done = false; + const lastEvent = pageEvents[pageEvents.length - 1]; + const ts = Number(lastEvent.created); + let pos = pageEvents.length - 1; + while (pos > 900 && Number(pageEvents[pos].created) === ts) pos -= 1; + if (pos > 900) pageEvents = pageEvents.slice(0, pos); + else workingFilter = advanceCreatedFilter(workingFilter, ts + 1, order); + } + + events.push(...pageEvents); + eventsReturned += pageEvents.length; + if ((limit > 0 && eventsReturned >= limit) || pageEvents.length === 0) + break; + } + + return limit > 0 ? events.slice(0, limit) : events; } async function fetchSummaryEvents( - auth: Auth, - options: { - reportType: AuditSummaryReportType - filter?: AuditReportFilter - limit?: number - order?: AuditReportOrder - timezone?: string - columns: string[] - aggregates: AuditAggregate[] - } + auth: Auth, + options: { + reportType: AuditSummaryReportType; + filter?: AuditReportFilter; + limit?: number; + order?: AuditReportOrder; + timezone?: string; + columns: string[]; + aggregates: AuditAggregate[]; + }, ): Promise { - let limit = options.limit ?? AUDIT_DEFAULT_SUMMARY_LIMIT - if (limit <= 0) limit = AUDIT_DEFAULT_SUMMARY_LIMIT - else if (limit > AUDIT_SUMMARY_MAX_LIMIT) limit = AUDIT_SUMMARY_MAX_LIMIT - - const filter = serializeFilter(options.filter) - - const response = await auth.executeRestCommand( - getAuditEventReportsCommand({ - report_type: options.reportType, - scope: 'enterprise', - timezone: resolveTimezone(options.timezone), - limit, - aggregate: [...options.aggregates], - columns: [...options.columns], - ...(options.order ? { order: toAuditApiOrder(options.order) } : {}), - ...(filter ? { filter } : {}), - }) - ) - assertSucceeded(response, 'get_audit_event_reports failed', ResultCodes.AUDIT_REPORT_FAILED) - return (response.audit_event_overview_report_rows ?? []) as AuditEventOverviewReportRow[] + let limit = options.limit ?? AUDIT_DEFAULT_SUMMARY_LIMIT; + if (limit <= 0) limit = AUDIT_DEFAULT_SUMMARY_LIMIT; + else if (limit > AUDIT_SUMMARY_MAX_LIMIT) limit = AUDIT_SUMMARY_MAX_LIMIT; + + const filter = serializeFilter(options.filter); + + const response = await auth.executeRestCommand( + getAuditEventReportsCommand({ + report_type: options.reportType, + scope: "enterprise", + timezone: resolveTimezone(options.timezone), + limit, + aggregate: [...options.aggregates], + columns: [...options.columns], + ...(options.order ? { order: toAuditApiOrder(options.order) } : {}), + ...(filter ? { filter } : {}), + }), + ); + assertSucceeded( + response, + "get_audit_event_reports failed", + ResultCodes.AUDIT_REPORT_FAILED, + ); + return (response.audit_event_overview_report_rows ?? + []) as AuditEventOverviewReportRow[]; } -async function resolveFilter(auth: Auth, filter: AuditReportFilter = {}): Promise { - const resolved: AuditReportFilter = { ...filter } - - if (filter.geoLocation || filter.ipAddress) { - const ipFilter = new Set() - if (filter.geoLocation) { - const parts = filter.geoLocation.split(',') - const country = (parts.pop() || '').trim().toLowerCase() - if (!country) { - throw new KeeperSdkError('"geoLocation" filter misses country.', ResultCodes.AUDIT_INVALID_FILTER) - } - const region = (parts.pop() || '').trim().toLowerCase() - const city = (parts.pop() || '').trim().toLowerCase() - for (const geo of await loadDimension(auth, 'geo_location')) { - if (!geo || typeof geo !== 'object') continue - const row = geo as AuditDimensionIpAddress & { ip_addresses?: string[] } - if ((row.country_code || '').toLowerCase() !== country) continue - if (region && (row.region || '').toLowerCase() !== region) continue - if (city && (row.city || '').toLowerCase() !== city) continue - row.ip_addresses?.forEach((ip) => ipFilter.add(ip)) - } - if (ipFilter.size === 0) { - throw new KeeperSdkError( - `"geoLocation" filter: invalid GEO location ${filter.geoLocation}`, - ResultCodes.AUDIT_INVALID_FILTER - ) - } - } - const addresses = filter.ipAddress - ? Array.isArray(filter.ipAddress) - ? filter.ipAddress - : [filter.ipAddress] - : [] - addresses.forEach((ip) => ipFilter.add(ip)) - resolved.ipAddress = Array.from(ipFilter) - } - - if (filter.deviceType) { - const versionFilter = new Set() - const parts = filter.deviceType.split(',') - const deviceType = (parts[0] || '').trim().toLowerCase() - let version = (parts[1] || '').trim().toLowerCase() - if (version && !version.includes('.')) version += '.' - if (!deviceType && !version) { - throw new KeeperSdkError('"deviceType" filter is empty.', ResultCodes.AUDIT_INVALID_FILTER) - } - for (const row of await loadDimension(auth, 'device_type')) { - if (!row || typeof row !== 'object') continue - const ver = row as AuditDimensionKeeperVersion & { version_ids?: number[] } - if (deviceType) { - const typeName = (ver.type_name || '').toLowerCase() - const typeCategory = (ver.type_category || '').toLowerCase() - if (deviceType !== typeName && deviceType !== typeCategory) continue - } - if (version && !(ver.version || '').startsWith(version)) continue - ver.version_ids?.forEach((id) => { - if (Number.isInteger(id)) versionFilter.add(id) - }) - } - if (versionFilter.size === 0) { - throw new KeeperSdkError('"deviceType" filter matched no events.', ResultCodes.AUDIT_INVALID_FILTER) - } - resolved.keeperVersion = Array.from(versionFilter) - } - - if (filter.eventType !== undefined) { - resolved.eventType = Array.isArray(filter.eventType) - ? filter.eventType.map((value) => asStrOrInt('event-type', value)) - : asStrOrInt('event-type', filter.eventType) - } - - delete resolved.geoLocation - delete resolved.deviceType - return resolved +async function resolveFilter( + auth: Auth, + filter: AuditReportFilter = {}, +): Promise { + const resolved: AuditReportFilter = { ...filter }; + + if (filter.geoLocation || filter.ipAddress) { + const ipFilter = new Set(); + if (filter.geoLocation) { + const parts = filter.geoLocation.split(","); + const country = (parts.pop() || "").trim().toLowerCase(); + if (!country) { + throw new KeeperSdkError( + '"geoLocation" filter misses country.', + ResultCodes.AUDIT_INVALID_FILTER, + ); + } + const region = (parts.pop() || "").trim().toLowerCase(); + const city = (parts.pop() || "").trim().toLowerCase(); + for (const geo of await loadDimension(auth, "geo_location")) { + if (!geo || typeof geo !== "object") continue; + const row = geo as AuditDimensionIpAddress & { + ip_addresses?: string[]; + }; + if ((row.country_code || "").toLowerCase() !== country) continue; + if (region && (row.region || "").toLowerCase() !== region) continue; + if (city && (row.city || "").toLowerCase() !== city) continue; + row.ip_addresses?.forEach((ip) => ipFilter.add(ip)); + } + if (ipFilter.size === 0) { + throw new KeeperSdkError( + `"geoLocation" filter: invalid GEO location ${filter.geoLocation}`, + ResultCodes.AUDIT_INVALID_FILTER, + ); + } + } + const addresses = filter.ipAddress + ? Array.isArray(filter.ipAddress) + ? filter.ipAddress + : [filter.ipAddress] + : []; + addresses.forEach((ip) => ipFilter.add(ip)); + resolved.ipAddress = Array.from(ipFilter); + } + + if (filter.deviceType) { + const versionFilter = new Set(); + const parts = filter.deviceType.split(","); + const deviceType = (parts[0] || "").trim().toLowerCase(); + let version = (parts[1] || "").trim().toLowerCase(); + if (version && !version.includes(".")) version += "."; + if (!deviceType && !version) { + throw new KeeperSdkError( + '"deviceType" filter is empty.', + ResultCodes.AUDIT_INVALID_FILTER, + ); + } + for (const row of await loadDimension(auth, "device_type")) { + if (!row || typeof row !== "object") continue; + const ver = row as AuditDimensionKeeperVersion & { + version_ids?: number[]; + }; + if (deviceType) { + const typeName = (ver.type_name || "").toLowerCase(); + const typeCategory = (ver.type_category || "").toLowerCase(); + if (deviceType !== typeName && deviceType !== typeCategory) continue; + } + if (version && !(ver.version || "").startsWith(version)) continue; + ver.version_ids?.forEach((id) => { + if (Number.isInteger(id)) versionFilter.add(id); + }); + } + if (versionFilter.size === 0) { + throw new KeeperSdkError( + '"deviceType" filter matched no events.', + ResultCodes.AUDIT_INVALID_FILTER, + ); + } + resolved.keeperVersion = Array.from(versionFilter); + } + + if (filter.eventType !== undefined) { + resolved.eventType = Array.isArray(filter.eventType) + ? filter.eventType.map((value) => asStrOrInt("event-type", value)) + : asStrOrInt("event-type", filter.eventType); + } + + delete resolved.geoLocation; + delete resolved.deviceType; + return resolved; } -async function loadDimension(auth: Auth, dimension: string): Promise { - invalidateAuditCachesIfUserChanged(auth) - const cached = dimensionCache.get(dimension) - if (cached) return cached - - const virtualSource = AUDIT_VIRTUAL_DIMENSIONS[dimension] - const rows = virtualSource - ? buildVirtualDimension(dimension, await loadDimension(auth, virtualSource)) - : await fetchDimensionRows(auth, dimension) - - dimensionCache.set(dimension, rows) - return rows +async function loadDimension( + auth: Auth, + dimension: string, +): Promise { + invalidateAuditCachesIfUserChanged(auth); + const cached = dimensionCache.get(dimension); + if (cached) return cached; + + const virtualSource = AUDIT_VIRTUAL_DIMENSIONS[dimension]; + const rows = virtualSource + ? buildVirtualDimension(dimension, await loadDimension(auth, virtualSource)) + : await fetchDimensionRows(auth, dimension); + + dimensionCache.set(dimension, rows); + return rows; } -async function fetchDimensionRows(auth: Auth, dimension: string): Promise { - const response = await auth.executeRestCommand( - getAuditEventDimensionsCommand({ - report_type: 'dim', - columns: [dimension], - limit: AUDIT_DIMENSION_API_LIMIT, - scope: 'enterprise', - }) - ) - assertSucceeded(response, 'get_audit_event_dimensions failed', ResultCodes.AUDIT_DIMENSION_FAILED) - const rows = (response.dimensions?.[dimension] || []) as AuditDimensionRow[] - if (dimension !== 'ip_address') return rows - - return rows.map((row) => { - if (typeof row === 'string' || !row || typeof row !== 'object') return row - const ipRow = row as AuditDimensionIpAddress - const city = ipRow.city || '' - const region = ipRow.region || '' - const country = ipRow.country_code || '' - if (!city && !region && !country) return row - return { ...ipRow, geo_location: [city, region, country].filter(Boolean).join(', ') } - }) +async function fetchDimensionRows( + auth: Auth, + dimension: string, +): Promise { + const response = await auth.executeRestCommand( + getAuditEventDimensionsCommand({ + report_type: "dim", + columns: [dimension], + limit: AUDIT_DIMENSION_API_LIMIT, + scope: "enterprise", + }), + ); + assertSucceeded( + response, + "get_audit_event_dimensions failed", + ResultCodes.AUDIT_DIMENSION_FAILED, + ); + const rows = (response.dimensions?.[dimension] || []) as AuditDimensionRow[]; + if (dimension !== "ip_address") return rows; + + return rows.map((row) => { + if (typeof row === "string" || !row || typeof row !== "object") return row; + const ipRow = row as AuditDimensionIpAddress; + const city = ipRow.city || ""; + const region = ipRow.region || ""; + const country = ipRow.country_code || ""; + if (!city && !region && !country) return row; + return { + ...ipRow, + geo_location: [city, region, country].filter(Boolean).join(", "), + }; + }); } -function buildVirtualDimension(dimension: string, sourceRows: AuditDimensionRow[]): AuditDimensionRow[] { - if (dimension === 'geo_location') { - const geoMap = new Map>() - for (const row of sourceRows) { - if (!row || typeof row !== 'object') continue - const ipRow = row as AuditDimensionIpAddress & { ip_addresses?: string[] } - if (!ipRow.geo_location || !ipRow.ip_address) continue - const existing = geoMap.get(ipRow.geo_location) - if (existing) (existing.ip_addresses as string[]).push(ipRow.ip_address) - else { - const entry: Record = { ...ipRow } - delete entry.ip_address - entry.ip_addresses = [ipRow.ip_address] - geoMap.set(ipRow.geo_location, entry) - } - } - return Array.from(geoMap.values()).map((entry) => ({ - ...entry, - ip_count: Array.isArray(entry.ip_addresses) ? entry.ip_addresses.length : 0, - })) - } - - if (dimension === 'device_type') { - const deviceMap = new Map>() - for (const row of sourceRows) { - if (!row || typeof row !== 'object') continue - const versionRow = row as AuditDimensionKeeperVersion & { version_ids?: number[] } - if (!versionRow.type_id || !versionRow.version_id) continue - const existing = deviceMap.get(versionRow.type_id) - if (existing) (existing.version_ids as number[]).push(versionRow.version_id) - else { - const entry: Record = { ...versionRow } - delete entry.version_id - entry.version_ids = [versionRow.version_id] - deviceMap.set(versionRow.type_id, entry) - } - } - return Array.from(deviceMap.values()) - } - - return sourceRows +function buildVirtualDimension( + dimension: string, + sourceRows: AuditDimensionRow[], +): AuditDimensionRow[] { + if (dimension === "geo_location") { + const geoMap = new Map>(); + for (const row of sourceRows) { + if (!row || typeof row !== "object") continue; + const ipRow = row as AuditDimensionIpAddress & { + ip_addresses?: string[]; + }; + if (!ipRow.geo_location || !ipRow.ip_address) continue; + const existing = geoMap.get(ipRow.geo_location); + if (existing) (existing.ip_addresses as string[]).push(ipRow.ip_address); + else { + const entry: Record = { ...ipRow }; + delete entry.ip_address; + entry.ip_addresses = [ipRow.ip_address]; + geoMap.set(ipRow.geo_location, entry); + } + } + return Array.from(geoMap.values()).map((entry) => ({ + ...entry, + ip_count: Array.isArray(entry.ip_addresses) + ? entry.ip_addresses.length + : 0, + })); + } + + if (dimension === "device_type") { + const deviceMap = new Map>(); + for (const row of sourceRows) { + if (!row || typeof row !== "object") continue; + const versionRow = row as AuditDimensionKeeperVersion & { + version_ids?: number[]; + }; + if (!versionRow.type_id || !versionRow.version_id) continue; + const existing = deviceMap.get(versionRow.type_id); + if (existing) + (existing.version_ids as number[]).push(versionRow.version_id); + else { + const entry: Record = { ...versionRow }; + delete entry.version_id; + entry.version_ids = [versionRow.version_id]; + deviceMap.set(versionRow.type_id, entry); + } + } + return Array.from(deviceMap.values()); + } + + return sourceRows; } async function loadSyslogTemplates(auth: Auth): Promise> { - invalidateAuditCachesIfUserChanged(auth) - if (syslogTemplates) return syslogTemplates - const templates = new Map() - for (const row of await loadDimension(auth, 'audit_event_type')) { - if (!row || typeof row !== 'object') continue - const typed = row as AuditDimensionEventType - if (typed.name && typed.syslog) templates.set(typed.name, typed.syslog) - } - syslogTemplates = templates - return templates + invalidateAuditCachesIfUserChanged(auth); + if (syslogTemplates) return syslogTemplates; + const templates = new Map(); + for (const row of await loadDimension(auth, "audit_event_type")) { + if (!row || typeof row !== "object") continue; + const typed = row as AuditDimensionEventType; + if (typed.name && typed.syslog) templates.set(typed.name, typed.syslog); + } + syslogTemplates = templates; + return templates; } function invalidateAuditCachesIfUserChanged(auth: Auth): void { - if (auth.username !== cachedUsername) { - dimensionCache = new Map() - syslogTemplates = null - cachedUsername = auth.username || '' - } + if (auth.username !== cachedUsername) { + dimensionCache = new Map(); + syslogTemplates = null; + cachedUsername = auth.username || ""; + } } -function getAuditEventField(event: AuditEventOverviewReportRow, field: string): unknown { - return (event as Record)[field] +function getAuditEventField( + event: AuditEventOverviewReportRow, + field: string, +): unknown { + return (event as Record)[field]; } -function eventMessage(event: AuditEventOverviewReportRow, templates: Map): string { - const template = templates.get(event.audit_event_type || '') - if (!template) return '' - let info = template - while (true) { - const match = /\$\{(\w+)\}/.exec(info) - if (!match) break - const fieldValue = getAuditEventField(event, match[1]) - const value = fieldValue == null ? ' ' : String(fieldValue) - info = info.slice(0, match.index) + value + info.slice(match.index + match[0].length) - } - return info +function eventMessage( + event: AuditEventOverviewReportRow, + templates: Map, +): string { + const template = templates.get(event.audit_event_type || ""); + if (!template) return ""; + let info = template; + while (true) { + const match = /\$\{(\w+)\}/.exec(info); + if (!match) break; + const fieldValue = getAuditEventField(event, match[1]); + const value = fieldValue == null ? " " : String(fieldValue); + info = + info.slice(0, match.index) + + value + + info.slice(match.index + match[0].length); + } + return info; } -function serializeFilter(filter: AuditReportFilter | undefined): AuditReportFilterPayload | undefined { - if (!filter) return undefined - const payload: AuditReportFilterPayload = {} - - if (filter.created !== undefined) { - if (typeof filter.created === 'string') { - payload.created = (CREATED_PRESETS as readonly string[]).includes(filter.created) - ? (filter.created as CreatedPreset) - : createdCriteria(parseCreatedFilter(filter.created)) - } else { - payload.created = createdCriteria(filter.created) - } - } - if (filter.eventType !== undefined) payload.audit_event_type = filter.eventType - if (filter.keeperVersion !== undefined) payload.keeper_version = filter.keeperVersion - if (filter.username !== undefined) payload.username = filter.username - if (filter.toUsername !== undefined) payload.to_username = filter.toUsername - if (filter.ipAddress !== undefined) { - payload.ip_address = Array.isArray(filter.ipAddress) ? filter.ipAddress : [filter.ipAddress] - } - if (filter.recordUid !== undefined) payload.record_uid = filter.recordUid - if (filter.sharedFolderUid !== undefined) payload.shared_folder_uid = filter.sharedFolderUid - if (filter.parentId !== undefined) payload.parent_id = filter.parentId - - return Object.keys(payload).length > 0 ? payload : undefined +function serializeFilter( + filter: AuditReportFilter | undefined, +): AuditReportFilterPayload | undefined { + if (!filter) return undefined; + const payload: AuditReportFilterPayload = {}; + + if (filter.created !== undefined) { + if (typeof filter.created === "string") { + payload.created = (CREATED_PRESETS as readonly string[]).includes( + filter.created, + ) + ? (filter.created as CreatedPreset) + : createdCriteria(parseCreatedFilter(filter.created)); + } else { + payload.created = createdCriteria(filter.created); + } + } + if (filter.eventType !== undefined) + payload.audit_event_type = filter.eventType; + if (filter.keeperVersion !== undefined) + payload.keeper_version = filter.keeperVersion; + if (filter.username !== undefined) payload.username = filter.username; + if (filter.toUsername !== undefined) payload.to_username = filter.toUsername; + if (filter.ipAddress !== undefined) { + payload.ip_address = Array.isArray(filter.ipAddress) + ? filter.ipAddress + : [filter.ipAddress]; + } + if (filter.recordUid !== undefined) payload.record_uid = filter.recordUid; + if (filter.sharedFolderUid !== undefined) + payload.shared_folder_uid = filter.sharedFolderUid; + if (filter.parentId !== undefined) payload.parent_id = filter.parentId; + + return Object.keys(payload).length > 0 ? payload : undefined; } -function createdCriteria(criteria: CreatedFilterCriteria): AuditReportFilterPayload['created'] { - const created: Record = {} - if (criteria.fromDate !== undefined) created.min = criteria.fromDate - if (criteria.excludeFrom === true) created.exclude_min = true - if (criteria.toDate !== undefined) created.max = criteria.toDate - if (criteria.excludeTo === true) created.exclude_max = true - return created as AuditReportFilterPayload['created'] +function createdCriteria( + criteria: CreatedFilterCriteria, +): AuditReportFilterPayload["created"] { + const created: Record = {}; + if (criteria.fromDate !== undefined) created.min = criteria.fromDate; + if (criteria.excludeFrom === true) created.exclude_min = true; + if (criteria.toDate !== undefined) created.max = criteria.toDate; + if (criteria.excludeTo === true) created.exclude_max = true; + return created as AuditReportFilterPayload["created"]; } function advanceCreatedFilter( - filter: AuditReportFilter | undefined, - timestamp: number, - order: AuditReportOrder + filter: AuditReportFilter | undefined, + timestamp: number, + order: AuditReportOrder, ): AuditReportFilter { - const next: AuditReportFilter = { ...(filter || {}) } - const criteria: CreatedFilterCriteria = - next.created && typeof next.created === 'object' && !Array.isArray(next.created) - ? { ...next.created } - : {} - if (order === AuditReportOrder.Asc) { - criteria.fromDate = timestamp - criteria.excludeFrom = false - } else { - criteria.toDate = timestamp - criteria.excludeTo = false - } - next.created = criteria - return next + const next: AuditReportFilter = { ...(filter || {}) }; + const criteria: CreatedFilterCriteria = + next.created && + typeof next.created === "object" && + !Array.isArray(next.created) + ? { ...next.created } + : {}; + if (order === AuditReportOrder.Asc) { + criteria.fromDate = timestamp; + criteria.excludeFrom = false; + } else { + criteria.toDate = timestamp; + criteria.excludeTo = false; + } + next.created = criteria; + return next; } -function parseBetweenCreatedFilter(trimmed: string): CreatedFilterCriteria | null { - const betweenPrefix = /^\s*between\s+/i.exec(trimmed) - if (!betweenPrefix) return null +function parseBetweenCreatedFilter( + trimmed: string, +): CreatedFilterCriteria | null { + const betweenPrefix = /^\s*between\s+/i.exec(trimmed); + if (!betweenPrefix) return null; - const rest = trimmed.slice(betweenPrefix[0].length) - const andMarker = ' and ' - const andIndex = rest.toLowerCase().indexOf(andMarker) - if (andIndex <= 0) return null + const rest = trimmed.slice(betweenPrefix[0].length); + const andMarker = " and "; + const andIndex = rest.toLowerCase().indexOf(andMarker); + if (andIndex <= 0) return null; - const fromDateStr = rest.slice(0, andIndex).trim() - const toDateStr = rest.slice(andIndex + andMarker.length).trim() - if (!fromDateStr || !toDateStr) return null + const fromDateStr = rest.slice(0, andIndex).trim(); + const toDateStr = rest.slice(andIndex + andMarker.length).trim(); + if (!fromDateStr || !toDateStr) return null; - return { fromDate: toEpoch(fromDateStr), toDate: toEpoch(toDateStr) } + return { fromDate: toEpoch(fromDateStr), toDate: toEpoch(toDateStr) }; } function parseCreatedFilter(value: string): CreatedFilterCriteria { - const trimmed = value.trim() - const betweenFilter = parseBetweenCreatedFilter(trimmed) - if (betweenFilter) { - return betweenFilter - } - for (const prefix of ['>=', '<=', '>', '<'] as const) { - if (!trimmed.startsWith(prefix)) continue - const dateValue = toEpoch(trimmed.slice(prefix.length).trim()) - if (prefix === '>=') return { fromDate: dateValue } - if (prefix === '<=') return { toDate: dateValue } - if (prefix === '>') return { fromDate: dateValue, excludeFrom: true } - return { toDate: dateValue, excludeTo: true } - } - throw new KeeperSdkError(`Invalid created filter value "${value}".`, ResultCodes.AUDIT_INVALID_CREATED_FILTER) + const trimmed = value.trim(); + const betweenFilter = parseBetweenCreatedFilter(trimmed); + if (betweenFilter) { + return betweenFilter; + } + for (const prefix of [">=", "<=", ">", "<"] as const) { + if (!trimmed.startsWith(prefix)) continue; + const dateValue = toEpoch(trimmed.slice(prefix.length).trim()); + if (prefix === ">=") return { fromDate: dateValue }; + if (prefix === "<=") return { toDate: dateValue }; + if (prefix === ">") return { fromDate: dateValue, excludeFrom: true }; + return { toDate: dateValue, excludeTo: true }; + } + throw new KeeperSdkError( + `Invalid created filter value "${value}".`, + ResultCodes.AUDIT_INVALID_CREATED_FILTER, + ); } function expandCreatedPreset(preset: CreatedPreset): CreatedFilterCriteria { - const today = startOfDay(new Date()) - let fromDate: Date - let toDate: Date - - switch (preset) { - case 'today': - fromDate = today - toDate = addDays(today, 1) - break - case 'yesterday': - fromDate = addDays(today, -1) - toDate = today - break - case 'last_7_days': - fromDate = addDays(today, -7) - toDate = today - break - case 'last_30_days': - fromDate = addDays(today, -30) - toDate = today - break - case 'month_to_date': - fromDate = new Date(today.getFullYear(), today.getMonth(), 1) - toDate = today - break - case 'last_month': - toDate = new Date(today.getFullYear(), today.getMonth(), 1) - fromDate = new Date(toDate.getFullYear(), toDate.getMonth() - 1, 1) - break - case 'last_year': - fromDate = new Date(today.getFullYear() - 1, 0, 1) - toDate = new Date(today.getFullYear(), 0, 1) - break - case 'year_to_date': - fromDate = new Date(today.getFullYear(), 0, 1) - toDate = today - break - default: - throw new KeeperSdkError(`Unknown created preset "${preset}".`, ResultCodes.AUDIT_INVALID_CREATED_FILTER) - } - - return { - fromDate: Math.floor(fromDate.getTime() / 1000), - toDate: Math.floor(toDate.getTime() / 1000), - excludeFrom: false, - excludeTo: true, - } + const today = startOfDay(new Date()); + let fromDate: Date; + let toDate: Date; + + switch (preset) { + case "today": + fromDate = today; + toDate = addDays(today, 1); + break; + case "yesterday": + fromDate = addDays(today, -1); + toDate = today; + break; + case "last_7_days": + fromDate = addDays(today, -7); + toDate = today; + break; + case "last_30_days": + fromDate = addDays(today, -30); + toDate = today; + break; + case "month_to_date": + fromDate = new Date(today.getFullYear(), today.getMonth(), 1); + toDate = today; + break; + case "last_month": + toDate = new Date(today.getFullYear(), today.getMonth(), 1); + fromDate = new Date(toDate.getFullYear(), toDate.getMonth() - 1, 1); + break; + case "last_year": + fromDate = new Date(today.getFullYear() - 1, 0, 1); + toDate = new Date(today.getFullYear(), 0, 1); + break; + case "year_to_date": + fromDate = new Date(today.getFullYear(), 0, 1); + toDate = today; + break; + default: + throw new KeeperSdkError( + `Unknown created preset "${preset}".`, + ResultCodes.AUDIT_INVALID_CREATED_FILTER, + ); + } + + return { + fromDate: Math.floor(fromDate.getTime() / 1000), + toDate: Math.floor(toDate.getTime() / 1000), + excludeFrom: false, + excludeTo: true, + }; } -async function resolveHasAram(auth: Auth, override: boolean | undefined): Promise { - if (override !== undefined) return override - try { - const response = await auth.executeRestCommand(getEnterpriseDataCommand({ include: ['licenses'] })) - assertSucceeded(response, 'get_enterprise_data failed', ResultCodes.AUDIT_LICENSE_CHECK_FAILED) - return hasEnterpriseAuditReporting(response.licenses as EnterpriseAuditLicense[] | undefined) - } catch { - return false - } +async function resolveHasAram( + auth: Auth, + override: boolean | undefined, +): Promise { + if (override !== undefined) return override; + try { + const response = await auth.executeRestCommand( + getEnterpriseDataCommand({ include: ["licenses"] }), + ); + assertSucceeded( + response, + "get_enterprise_data failed", + ResultCodes.AUDIT_LICENSE_CHECK_FAILED, + ); + return hasEnterpriseAuditReporting( + response.licenses as EnterpriseAuditLicense[] | undefined, + ); + } catch { + return false; + } } -function hasEnterpriseAuditReporting(licenses: EnterpriseAuditLicense[] | undefined): boolean { - if (!licenses || licenses.length === 0) return false - for (const license of licenses) { - if (license.audit_and_reporting_enabled === true) return true - for (const addon of license.add_ons || []) { - if (addon.enterprise_audit_and_reporting_enabled === true) return true - if ((addon.name || '').toLowerCase() === 'enterprise_audit_and_reporting') return true - } - } - return false +function hasEnterpriseAuditReporting( + licenses: EnterpriseAuditLicense[] | undefined, +): boolean { + if (!licenses || licenses.length === 0) return false; + for (const license of licenses) { + if (license.audit_and_reporting_enabled === true) return true; + for (const addon of license.add_ons || []) { + if (addon.enterprise_audit_and_reporting_enabled === true) return true; + if ((addon.name || "").toLowerCase() === "enterprise_audit_and_reporting") + return true; + } + } + return false; } function dimensionFields(column: string): string[] { - switch (column) { - case 'audit_event_type': - return ['id', 'name', 'category', 'syslog'] - case 'keeper_version': - return ['version_id', 'type_name', 'version', 'type_category'] - case 'ip_address': - return ['ip_address', 'city', 'region', 'country_code'] - case 'geo_location': - return ['geo_location', 'city', 'region', 'country_code', 'ip_count'] - case 'device_type': - return ['type_name', 'type_category'] - default: - return [column] - } + switch (column) { + case "audit_event_type": + return ["id", "name", "category", "syslog"]; + case "keeper_version": + return ["version_id", "type_name", "version", "type_category"]; + case "ip_address": + return ["ip_address", "city", "region", "country_code"]; + case "geo_location": + return ["geo_location", "city", "region", "country_code", "ip_count"]; + case "device_type": + return ["type_name", "type_category"]; + default: + return [column]; + } } -function dimensionCells(row: AuditDimensionRow, fields: readonly string[]): string[] { - if (typeof row === 'string') return [row] - if (!row || typeof row !== 'object') return [''] - return fields.map((field) => { - const value = (row as Record)[field] - return value == null ? '' : String(value) - }) +function dimensionCells( + row: AuditDimensionRow, + fields: readonly string[], +): string[] { + if (typeof row === "string") return [row]; + if (!row || typeof row !== "object") return [""]; + return fields.map((field) => { + const value = (row as Record)[field]; + return value == null ? "" : String(value); + }); } -function formatFieldValue(field: string, value: unknown, reportType: string): string { - if (value == null) return '' - if (field === 'created' || field === 'first_created' || field === 'last_created') { - if (typeof value === 'string') return value - if (typeof value === 'number') { - const dt = new Date(value * 1000) - if (reportType === 'day' || reportType === 'week') return dt.toISOString().slice(0, 10) - if (reportType === 'month') return dt.toLocaleString(undefined, { month: 'long', year: 'numeric' }) - if (reportType === 'hour') { - return `${dt.toISOString().slice(0, 10)} @${String(dt.getUTCHours()).padStart(2, '0')}:00` - } - return dt.toLocaleString() - } - } - return String(value) +function formatFieldValue( + field: string, + value: unknown, + reportType: string, +): string { + if (value == null) return ""; + if ( + field === "created" || + field === "first_created" || + field === "last_created" + ) { + if (typeof value === "string") return value; + if (typeof value === "number") { + const dt = new Date(value * 1000); + if (reportType === "day" || reportType === "week") + return dt.toISOString().slice(0, 10); + if (reportType === "month") + return dt.toLocaleString(undefined, { month: "long", year: "numeric" }); + if (reportType === "hour") { + return `${dt.toISOString().slice(0, 10)} @${String(dt.getUTCHours()).padStart(2, "0")}:00`; + } + return dt.toLocaleString(); + } + } + return String(value); } function toEpoch(value: string | number | Date): number { - if (value instanceof Date) return Math.floor(value.getTime() / 1000) - if (typeof value === 'number') return Math.floor(value) - const trimmed = value.trim() - const parsed = - trimmed.length <= 10 - ? new Date(`${trimmed}T00:00:00Z`) - : new Date(trimmed.endsWith('Z') ? trimmed : `${trimmed}Z`) - if (Number.isNaN(parsed.getTime())) { - throw new KeeperSdkError(`Invalid date "${value}".`, ResultCodes.AUDIT_INVALID_CREATED_FILTER) - } - return Math.floor(parsed.getTime() / 1000) + if (value instanceof Date) return Math.floor(value.getTime() / 1000); + if (typeof value === "number") return Math.floor(value); + const trimmed = value.trim(); + const parsed = + trimmed.length <= 10 + ? new Date(`${trimmed}T00:00:00Z`) + : new Date(trimmed.endsWith("Z") ? trimmed : `${trimmed}Z`); + if (Number.isNaN(parsed.getTime())) { + throw new KeeperSdkError( + `Invalid date "${value}".`, + ResultCodes.AUDIT_INVALID_CREATED_FILTER, + ); + } + return Math.floor(parsed.getTime() / 1000); } -function asStrOrInt(propertyName: string, value: string | number): string | number { - if (typeof value === 'number') return value - if (/^\d+$/.test(value)) return Number.parseInt(value, 10) - if (value.trim()) return value - throw new KeeperSdkError(`Invalid "${propertyName}" filter value: ${value}`, ResultCodes.AUDIT_INVALID_FILTER) +function asStrOrInt( + propertyName: string, + value: string | number, +): string | number { + if (typeof value === "number") return value; + if (/^\d+$/.test(value)) return Number.parseInt(value, 10); + if (value.trim()) return value; + throw new KeeperSdkError( + `Invalid "${propertyName}" filter value: ${value}`, + ResultCodes.AUDIT_INVALID_FILTER, + ); } function startOfDay(date: Date): Date { - return new Date(date.getFullYear(), date.getMonth(), date.getDate()) + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function addDays(date: Date, days: number): Date { - const copy = new Date(date.getTime()) - copy.setDate(copy.getDate() + days) - return copy + const copy = new Date(date.getTime()); + copy.setDate(copy.getDate() + days); + return copy; } diff --git a/KeeperSdk/src/enterpriseReport/index.ts b/KeeperSdk/src/enterpriseReport/index.ts index 3acf2bfd..3063093b 100644 --- a/KeeperSdk/src/enterpriseReport/index.ts +++ b/KeeperSdk/src/enterpriseReport/index.ts @@ -1,54 +1,55 @@ +export { runAuditReport } from "./auditReport"; export { - runAuditReport, -} from './auditReport' + runActionReport, + getAllowedActions, + getDefaultDaysSince, +} from "./actionReport"; export { - runActionReport, - getAllowedActions, - getDefaultDaysSince, -} from './actionReport' + runPasswordReport, + getPasswordStrength, + calculatePasswordScore, + parsePasswordPolicy, + isPasswordCompliant, + buildPasswordPolicySummary, +} from "./passwordReport"; +export { EnterpriseReportManager } from "./EnterpriseReportManager"; export { - runPasswordReport, - getPasswordStrength, - calculatePasswordScore, - parsePasswordPolicy, - isPasswordCompliant, - buildPasswordPolicySummary, -} from './passwordReport' -export { EnterpriseReportManager } from './EnterpriseReportManager' -export { EnterpriseReportManager as AuditReportManager, EnterpriseReportManager as ActionReportManager } from './EnterpriseReportManager' -export type { AuthProvider } from './reportUtils' + EnterpriseReportManager as AuditReportManager, + EnterpriseReportManager as ActionReportManager, +} from "./EnterpriseReportManager"; +export type { AuthProvider } from "./reportUtils"; export { - AuditReportOrder, - AuditReportFormat, - AuditOutputFormat, - AuditAggregate, - SUMMARY_REPORT_TYPES, - CREATED_PRESETS, - TargetUserStatus, - AdminAction, - ActionReportColumn, - DEFAULT_ACTION_REPORT_COLUMNS, - SUPPORTED_ACTION_REPORT_COLUMNS, - PW_SPECIAL_CHARACTERS, - DEFAULT_TRUNCATION_LENGTH, - SUPPORTED_RECORD_VERSIONS, -} from './reportTypes' + AuditReportOrder, + AuditReportFormat, + AuditOutputFormat, + AuditAggregate, + SUMMARY_REPORT_TYPES, + CREATED_PRESETS, + TargetUserStatus, + AdminAction, + ActionReportColumn, + DEFAULT_ACTION_REPORT_COLUMNS, + SUPPORTED_ACTION_REPORT_COLUMNS, + PW_SPECIAL_CHARACTERS, + DEFAULT_TRUNCATION_LENGTH, + SUPPORTED_RECORD_VERSIONS, +} from "./reportTypes"; export type { - AuditReportOptions, - AuditReportResult, - AuditReportFilter, - CreatedFilterCriteria, - CreatedPreset, - AuditEventOverviewReportRow, - AuditReportType, - AuditSummaryReportType, - ActionReportEntry, - ActionReportOptions, - ActionReportResult, - ActionResult, - PasswordPolicy, - PasswordStrength, - PasswordReportRow, - PasswordReportOptions, - PasswordReportResult, -} from './reportTypes' + AuditReportOptions, + AuditReportResult, + AuditReportFilter, + CreatedFilterCriteria, + CreatedPreset, + AuditEventOverviewReportRow, + AuditReportType, + AuditSummaryReportType, + ActionReportEntry, + ActionReportOptions, + ActionReportResult, + ActionResult, + PasswordPolicy, + PasswordStrength, + PasswordReportRow, + PasswordReportOptions, + PasswordReportResult, +} from "./reportTypes"; diff --git a/KeeperSdk/src/enterpriseReport/passwordReport.ts b/KeeperSdk/src/enterpriseReport/passwordReport.ts index 3cac6a4b..9a4b60cc 100644 --- a/KeeperSdk/src/enterpriseReport/passwordReport.ts +++ b/KeeperSdk/src/enterpriseReport/passwordReport.ts @@ -1,492 +1,541 @@ -import type { DBWRecord, DRecord } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { resolveSingleFolder, type VaultFolderSession } from '../folders/changeDirectory' -import { listFolder } from '../folders/listFolder' +import type { DBWRecord, DRecord } from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; import { - getRecordPassword, - getRecordSummary, - getRecordTitle, -} from '../records/RecordUtils' -import { KeeperSdkError, ResultCodes } from '../utils' + resolveSingleFolder, + type VaultFolderSession, +} from "../folders/changeDirectory"; +import { listFolder } from "../folders/listFolder"; import { - DEFAULT_TRUNCATION_LENGTH, - PASSWORD_BREACHWATCH_STATUS_NAMES, - PW_SPECIAL_CHARACTERS, - SUPPORTED_RECORD_VERSIONS, - type PasswordPolicy, - type PasswordReportOptions, - type PasswordReportResult, - type PasswordReportRow, - type PasswordStrength, -} from './reportTypes' - -const POLICY_FIELD_COUNT = 5 -const SPECIAL_CHAR_SET = new Set(PW_SPECIAL_CHARACTERS.split('')) - -const POLICY_SUMMARY_LABELS: ReadonlyArray<{ key: keyof PasswordPolicy; label: string }> = [ - { key: 'length', label: 'length' }, - { key: 'lower', label: 'lowercase' }, - { key: 'upper', label: 'uppercase' }, - { key: 'digits', label: 'digits' }, - { key: 'special', label: 'special' }, -] - -type SupportedRecordVersion = (typeof SUPPORTED_RECORD_VERSIONS)[number] + getRecordPassword, + getRecordSummary, + getRecordTitle, +} from "../records/RecordUtils"; +import { KeeperSdkError, ResultCodes } from "../utils"; +import { + DEFAULT_TRUNCATION_LENGTH, + PASSWORD_BREACHWATCH_STATUS_NAMES, + PW_SPECIAL_CHARACTERS, + SUPPORTED_RECORD_VERSIONS, + type PasswordPolicy, + type PasswordReportOptions, + type PasswordReportResult, + type PasswordReportRow, + type PasswordStrength, +} from "./reportTypes"; + +const POLICY_FIELD_COUNT = 5; +const SPECIAL_CHAR_SET = new Set(PW_SPECIAL_CHARACTERS.split("")); + +const POLICY_SUMMARY_LABELS: ReadonlyArray<{ + key: keyof PasswordPolicy; + label: string; +}> = [ + { key: "length", label: "length" }, + { key: "lower", label: "lowercase" }, + { key: "upper", label: "uppercase" }, + { key: "digits", label: "digits" }, + { key: "special", label: "special" }, +]; + +type SupportedRecordVersion = (typeof SUPPORTED_RECORD_VERSIONS)[number]; type VerboseRowContext = { - storage: InMemoryStorage - passwordCounts: ReadonlyMap - breachWatchPasswordCounts: ReadonlyMap -} + storage: InMemoryStorage; + passwordCounts: ReadonlyMap; + breachWatchPasswordCounts: ReadonlyMap; +}; type BWPasswordEntry = { - value?: string - status?: number | string -} + value?: string; + status?: number | string; +}; type BreachWatchRecordData = { - passwords?: BWPasswordEntry[] - status?: number | string - breachWatchStatus?: number | string -} + passwords?: BWPasswordEntry[]; + status?: number | string; + breachWatchStatus?: number | string; +}; export function getPasswordStrength(password: string): PasswordStrength { - let caps = 0 - let lower = 0 - let digits = 0 - let symbols = 0 - - for (const char of password) { - if (char >= 'A' && char <= 'Z') caps++ - else if (char >= 'a' && char <= 'z') lower++ - else if (char >= '0' && char <= '9') digits++ - else if (SPECIAL_CHAR_SET.has(char)) symbols++ - } - - return { length: password.length, caps, lower, digits, symbols } + let caps = 0; + let lower = 0; + let digits = 0; + let symbols = 0; + + for (const char of password) { + if (char >= "A" && char <= "Z") caps++; + else if (char >= "a" && char <= "z") lower++; + else if (char >= "0" && char <= "9") digits++; + else if (SPECIAL_CHAR_SET.has(char)) symbols++; + } + + return { length: password.length, caps, lower, digits, symbols }; } -export function parsePasswordPolicy(options: PasswordReportOptions): PasswordPolicy { - if (options.policy?.trim()) { - const values = options.policy - .split(',') - .map((part) => part.trim()) - .slice(0, POLICY_FIELD_COUNT) - .map(parsePolicyNumber) - - while (values.length < POLICY_FIELD_COUNT) { - values.push(0) - } - - const [length, lower, upper, digits, special] = values - return { length, lower, upper, digits, special } +export function parsePasswordPolicy( + options: PasswordReportOptions, +): PasswordPolicy { + if (options.policy?.trim()) { + const values = options.policy + .split(",") + .map((part) => part.trim()) + .slice(0, POLICY_FIELD_COUNT) + .map(parsePolicyNumber); + + while (values.length < POLICY_FIELD_COUNT) { + values.push(0); } - return { - length: options.length ?? 0, - lower: options.lower ?? 0, - upper: options.upper ?? 0, - digits: options.digits ?? 0, - special: options.special ?? 0, - } + const [length, lower, upper, digits, special] = values; + return { length, lower, upper, digits, special }; + } + + return { + length: options.length ?? 0, + lower: options.lower ?? 0, + upper: options.upper ?? 0, + digits: options.digits ?? 0, + special: options.special ?? 0, + }; } -export function isPasswordCompliant(strength: PasswordStrength, policy: PasswordPolicy): boolean { - return ( - strength.length >= policy.length && - strength.caps >= policy.upper && - strength.lower >= policy.lower && - strength.digits >= policy.digits && - strength.symbols >= policy.special - ) +export function isPasswordCompliant( + strength: PasswordStrength, + policy: PasswordPolicy, +): boolean { + return ( + strength.length >= policy.length && + strength.caps >= policy.upper && + strength.lower >= policy.lower && + strength.digits >= policy.digits && + strength.symbols >= policy.special + ); } export function buildPasswordPolicySummary(policy: PasswordPolicy): string { - return POLICY_SUMMARY_LABELS.filter(({ key }) => policy[key] > 0) - .map(({ key, label }) => `${label} >= ${policy[key]}`) - .join(', ') + return POLICY_SUMMARY_LABELS.filter(({ key }) => policy[key] > 0) + .map(({ key, label }) => `${label} >= ${policy[key]}`) + .join(", "); } /** Keeper Commander-compatible password strength score (0–100). */ export function calculatePasswordScore(password: string): number { - if (!password) return 0 - - let normalized = password - let total = password.length - if (total > 50) { - normalized = password.slice(0, 50) - total = 50 + if (!password) return 0; + + let normalized = password; + let total = password.length; + if (total > 50) { + normalized = password.slice(0, 50); + total = 50; + } + + let uppers = 0; + let lowers = 0; + let digits = 0; + let symbols = 0; + for (const char of normalized) { + if (char >= "A" && char <= "Z") uppers++; + else if (char >= "a" && char <= "z") lowers++; + else if (char >= "0" && char <= "9") digits++; + else symbols++; + } + + let ds = digits + symbols; + if (!/^[A-Za-z]/.test(normalized)) ds--; + if (!/[A-Za-z]$/.test(normalized)) ds--; + if (ds < 0) ds = 0; + + let score = total * 4; + if (uppers > 0) score += (total - uppers) * 2; + if (lowers > 0) score += (total - lowers) * 2; + if (digits > 0) score += digits * 4; + score += symbols * 6; + score += ds * 2; + + let variance = 0; + if (uppers > 0) variance++; + if (lowers > 0) variance++; + if (digits > 0) variance++; + if (symbols > 0) variance++; + if (total >= 8 && variance >= 3) score += (variance + 1) * 2; + + if (digits + symbols === 0) score -= total; + if (uppers + lowers + symbols === 0) score -= total; + + const pwdLen = normalized.length; + let repInc = 0; + let repCount = 0; + for (let i = 0; i < pwdLen; i++) { + let charExists = false; + for (let j = 0; j < pwdLen; j++) { + if (i !== j && normalized[i] === normalized[j]) { + charExists = true; + repInc += pwdLen / Math.abs(i - j); + } } - - let uppers = 0 - let lowers = 0 - let digits = 0 - let symbols = 0 - for (const char of normalized) { - if (char >= 'A' && char <= 'Z') uppers++ - else if (char >= 'a' && char <= 'z') lowers++ - else if (char >= '0' && char <= '9') digits++ - else symbols++ + if (charExists) repCount++; + } + const unqCount = pwdLen - repCount; + repInc = Math.ceil(unqCount === 0 ? repInc : repInc / unqCount); + if (repCount > 0) score -= repInc; + + let consecCount = 0; + const consecPredicates = [ + (char: string) => char >= "A" && char <= "Z", + (char: string) => char >= "a" && char <= "z", + (char: string) => char >= "0" && char <= "9", + ]; + for (const predicate of consecPredicates) { + for (const chunk of chunkText(normalized, predicate)) { + if (chunk.length >= 2) consecCount += chunk.length - 1; } - - let ds = digits + symbols - if (!/^[A-Za-z]/.test(normalized)) ds-- - if (!/[A-Za-z]$/.test(normalized)) ds-- - if (ds < 0) ds = 0 - - let score = total * 4 - if (uppers > 0) score += (total - uppers) * 2 - if (lowers > 0) score += (total - lowers) * 2 - if (digits > 0) score += digits * 4 - score += symbols * 6 - score += ds * 2 - - let variance = 0 - if (uppers > 0) variance++ - if (lowers > 0) variance++ - if (digits > 0) variance++ - if (symbols > 0) variance++ - if (total >= 8 && variance >= 3) score += (variance + 1) * 2 - - if (digits + symbols === 0) score -= total - if (uppers + lowers + symbols === 0) score -= total - - const pwdLen = normalized.length - let repInc = 0 - let repCount = 0 - for (let i = 0; i < pwdLen; i++) { - let charExists = false - for (let j = 0; j < pwdLen; j++) { - if (i !== j && normalized[i] === normalized[j]) { - charExists = true - repInc += pwdLen / Math.abs(i - j) - } + } + if (consecCount > 0) score -= 2 * consecCount; + + let sequenceCount = 0; + for (const [modulus, predicate] of [ + [26, (char: string) => /[a-z]/i.test(char)] as const, + [10, (char: string) => char >= "0" && char <= "9"] as const, + ]) { + for (const chunk of chunkText(normalized.toLowerCase(), predicate)) { + if (chunk.length < 3) continue; + const offsets = offsetChar(chunk, (prev, current) => { + const delta = prev.charCodeAt(0) - current.charCodeAt(0); + return delta >= 0 ? delta : delta + modulus; + }); + let op = offsets[0]; + for (const oc of offsets.slice(1)) { + if (oc === op) { + if (op !== 0) sequenceCount++; + } else { + op = oc; } - if (charExists) repCount++ + } } - const unqCount = pwdLen - repCount - repInc = Math.ceil(unqCount === 0 ? repInc : repInc / unqCount) - if (repCount > 0) score -= repInc - - let consecCount = 0 - const consecPredicates = [ - (char: string) => char >= 'A' && char <= 'Z', - (char: string) => char >= 'a' && char <= 'z', - (char: string) => char >= '0' && char <= '9', - ] - for (const predicate of consecPredicates) { - for (const chunk of chunkText(normalized, predicate)) { - if (chunk.length >= 2) consecCount += chunk.length - 1 - } - } - if (consecCount > 0) score -= 2 * consecCount - - let sequenceCount = 0 - for (const [modulus, predicate] of [ - [26, (char: string) => /[a-z]/i.test(char)] as const, - [10, (char: string) => char >= '0' && char <= '9'] as const, - ]) { - for (const chunk of chunkText(normalized.toLowerCase(), predicate)) { - if (chunk.length < 3) continue - const offsets = offsetChar(chunk, (prev, current) => { - const delta = prev.charCodeAt(0) - current.charCodeAt(0) - return delta >= 0 ? delta : delta + modulus - }) - let op = offsets[0] - for (const oc of offsets.slice(1)) { - if (oc === op) { - if (op !== 0) sequenceCount++ - } else { - op = oc - } - } - } - } - - const symbolMap: Record = {} - '!@#$%^&*()_+[]\\{}\'|;:",./<>?'.split('').forEach((symbol, index) => { - symbolMap[symbol] = index - }) - for (const chunk of chunkText(normalized, (char) => char in symbolMap)) { - if (chunk.length < 3) continue - const offsets = offsetChar(chunk, (prev, current) => { - const delta = symbolMap[prev] - symbolMap[current] - const modulus = Object.keys(symbolMap).length - return delta >= 0 ? delta : delta + modulus - }) - let op = offsets[0] - for (const oc of offsets.slice(1)) { - if (oc === op) { - if (op !== 0) sequenceCount++ - } else { - op = oc - } - } + } + + const symbolMap: Record = {}; + "!@#$%^&*()_+[]\\{}'|;:\",./<>?".split("").forEach((symbol, index) => { + symbolMap[symbol] = index; + }); + for (const chunk of chunkText(normalized, (char) => char in symbolMap)) { + if (chunk.length < 3) continue; + const offsets = offsetChar(chunk, (prev, current) => { + const delta = symbolMap[prev] - symbolMap[current]; + const modulus = Object.keys(symbolMap).length; + return delta >= 0 ? delta : delta + modulus; + }); + let op = offsets[0]; + for (const oc of offsets.slice(1)) { + if (oc === op) { + if (op !== 0) sequenceCount++; + } else { + op = oc; + } } - if (sequenceCount > 0) score -= 3 * sequenceCount + } + if (sequenceCount > 0) score -= 3 * sequenceCount; - if (score < 0) return 0 - if (score > 100) return 100 - return score + if (score < 0) return 0; + if (score > 100) return 100; + return score; } export async function runPasswordReport( - storage: InMemoryStorage, - session: VaultFolderSession, - options: PasswordReportOptions = {} + storage: InMemoryStorage, + session: VaultFolderSession, + options: PasswordReportOptions = {}, ): Promise { - const policy = parsePasswordPolicy(options) - validatePasswordPolicy(policy) - - const verbose = options.verbose === true - const rowNumbers = options.rowNumbers !== false - - const targetRecords = await resolveTargetRecords(storage, session, options.folder) - const passwordCounts = buildPasswordCountMap(storage.getRecords()) - const breachWatchPasswordCounts = buildBreachWatchPasswordCountMap(storage) - const verboseContext: VerboseRowContext = { - storage, - passwordCounts, - breachWatchPasswordCounts, - } - - const rows = targetRecords.flatMap((record) => - buildNonCompliantRow(record, policy, verbose, verboseContext) - ) - - return { - policy, - policySummary: buildPasswordPolicySummary(policy), - rows, - verbose, - rowNumbers, - } + const policy = parsePasswordPolicy(options); + validatePasswordPolicy(policy); + + const verbose = options.verbose === true; + const rowNumbers = options.rowNumbers !== false; + + const targetRecords = await resolveTargetRecords( + storage, + session, + options.folder, + ); + const passwordCounts = buildPasswordCountMap(storage.getRecords()); + const breachWatchPasswordCounts = buildBreachWatchPasswordCountMap(storage); + const verboseContext: VerboseRowContext = { + storage, + passwordCounts, + breachWatchPasswordCounts, + }; + + const rows = targetRecords.flatMap((record) => + buildNonCompliantRow(record, policy, verbose, verboseContext), + ); + + return { + policy, + policySummary: buildPasswordPolicySummary(policy), + rows, + verbose, + rowNumbers, + }; } function parsePolicyNumber(part: string): number { - if (!part) return 0 - const parsed = Number.parseInt(part, 10) - return Number.isFinite(parsed) ? parsed : 0 + if (!part) return 0; + const parsed = Number.parseInt(part, 10); + return Number.isFinite(parsed) ? parsed : 0; } -function isSupportedRecordVersion(version: number): version is SupportedRecordVersion { - return (SUPPORTED_RECORD_VERSIONS as readonly number[]).includes(version) +function isSupportedRecordVersion( + version: number, +): version is SupportedRecordVersion { + return (SUPPORTED_RECORD_VERSIONS as readonly number[]).includes(version); } function validatePasswordPolicy(policy: PasswordPolicy): void { - const hasConstraint = POLICY_SUMMARY_LABELS.some(({ key }) => policy[key] > 0) - if (!hasConstraint) { - throw new KeeperSdkError( - 'At least one password policy constraint must be set.', - ResultCodes.PASSWORD_REPORT_POLICY_REQUIRED - ) - } + const hasConstraint = POLICY_SUMMARY_LABELS.some( + ({ key }) => policy[key] > 0, + ); + if (!hasConstraint) { + throw new KeeperSdkError( + "At least one password policy constraint must be set.", + ResultCodes.PASSWORD_REPORT_POLICY_REQUIRED, + ); + } } -function truncateText(value: string, maxLength = DEFAULT_TRUNCATION_LENGTH): string { - return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...` +function truncateText( + value: string, + maxLength = DEFAULT_TRUNCATION_LENGTH, +): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`; } function getRecordDescription(record: DRecord): string { - const summary = getRecordSummary(record) - const parts: string[] = [] - if (summary.login) parts.push(summary.login) - if (summary.url) parts.push(String(summary.url)) - return parts.join(' @ ') + const summary = getRecordSummary(record); + const parts: string[] = []; + if (summary.login) parts.push(summary.login); + if (summary.url) parts.push(String(summary.url)); + return parts.join(" @ "); } -function chunkText(text: string, predicate: (char: string) => boolean): string[] { - const chunks: string[] = [] - let acc = '' - for (const char of text) { - if (predicate(char)) { - acc += char - } else if (acc) { - chunks.push(acc) - acc = '' - } +function chunkText( + text: string, + predicate: (char: string) => boolean, +): string[] { + const chunks: string[] = []; + let acc = ""; + for (const char of text) { + if (predicate(char)) { + acc += char; + } else if (acc) { + chunks.push(acc); + acc = ""; } - if (acc) chunks.push(acc) - return chunks + } + if (acc) chunks.push(acc); + return chunks; } -function offsetChar(text: string, compare: (prev: string, current: string) => number): number[] { - if (!text) return [] - const offsets: number[] = [] - let prev = text[0] - for (const char of text.slice(1)) { - offsets.push(compare(prev, char)) - prev = char - } - return offsets +function offsetChar( + text: string, + compare: (prev: string, current: string) => number, +): number[] { + if (!text) return []; + const offsets: number[] = []; + let prev = text[0]; + for (const char of text.slice(1)) { + offsets.push(compare(prev, char)); + prev = char; + } + return offsets; } function formatBreachWatchStatus(rawStatus: number | string): string { - if (typeof rawStatus === 'string') { - return rawStatus.toUpperCase() - } - return PASSWORD_BREACHWATCH_STATUS_NAMES[rawStatus] || String(rawStatus) + if (typeof rawStatus === "string") { + return rawStatus.toUpperCase(); + } + return PASSWORD_BREACHWATCH_STATUS_NAMES[rawStatus] || String(rawStatus); } -function getWorstBreachWatchStatus(statuses: Array): number | string | undefined { - const rank = (status: number | string): number => { - if (typeof status === 'string') { - const upper = status.toUpperCase() - if (upper === 'BREACHED') return 4 - if (upper === 'WEAK') return 3 - if (upper === 'CHANGED') return 2 - if (upper === 'IGNORE') return 1 - return 0 - } - return { 3: 4, 2: 3, 1: 2, 4: 1, 0: 0 }[status] ?? 0 +function getWorstBreachWatchStatus( + statuses: Array, +): number | string | undefined { + const rank = (status: number | string): number => { + if (typeof status === "string") { + const upper = status.toUpperCase(); + if (upper === "BREACHED") return 4; + if (upper === "WEAK") return 3; + if (upper === "CHANGED") return 2; + if (upper === "IGNORE") return 1; + return 0; } - - let worst: number | string | undefined - let worstRank = -1 - for (const status of statuses) { - const statusRank = rank(status) - if (statusRank > worstRank) { - worstRank = statusRank - worst = status - } + return { 3: 4, 2: 3, 1: 2, 4: 1, 0: 0 }[status] ?? 0; + }; + + let worst: number | string | undefined; + let worstRank = -1; + for (const status of statuses) { + const statusRank = rank(status); + if (statusRank > worstRank) { + worstRank = statusRank; + worst = status; } - return worst + } + return worst; } -function getBreachWatchPasswordStatus(storage: InMemoryStorage, recordUid: string, password: string): string { - const bwRecord = storage.getByUid('bw_record', recordUid) - if (!bwRecord?.data) return '' - - const data = bwRecord.data as BreachWatchRecordData - const passwords = data.passwords - if (Array.isArray(passwords) && passwords.length > 0) { - const match = passwords.find((entry) => entry.value === password) - if (match?.status != null && match.status !== '') { - return formatBreachWatchStatus(match.status) - } - - const statuses = passwords - .map((entry) => entry.status) - .filter((status): status is number | string => status != null && status !== '') - const worstStatus = getWorstBreachWatchStatus(statuses) - if (worstStatus != null) { - return formatBreachWatchStatus(worstStatus) - } +function getBreachWatchPasswordStatus( + storage: InMemoryStorage, + recordUid: string, + password: string, +): string { + const bwRecord = storage.getByUid("bw_record", recordUid); + if (!bwRecord?.data) return ""; + + const data = bwRecord.data as BreachWatchRecordData; + const passwords = data.passwords; + if (Array.isArray(passwords) && passwords.length > 0) { + const match = passwords.find((entry) => entry.value === password); + if (match?.status != null && match.status !== "") { + return formatBreachWatchStatus(match.status); } - const legacyStatus = data.status ?? data.breachWatchStatus - if (legacyStatus != null && legacyStatus !== '') { - return formatBreachWatchStatus(legacyStatus) + const statuses = passwords + .map((entry) => entry.status) + .filter( + (status): status is number | string => status != null && status !== "", + ); + const worstStatus = getWorstBreachWatchStatus(statuses); + if (worstStatus != null) { + return formatBreachWatchStatus(worstStatus); } + } - return '' + const legacyStatus = data.status ?? data.breachWatchStatus; + if (legacyStatus != null && legacyStatus !== "") { + return formatBreachWatchStatus(legacyStatus); + } + + return ""; } -function buildBreachWatchPasswordCountMap(storage: InMemoryStorage): Map { - const counts = new Map() - for (const bwRecord of storage.getAll('bw_record')) { - const passwords = (bwRecord.data as BreachWatchRecordData | undefined)?.passwords - if (!Array.isArray(passwords)) continue - for (const entry of passwords) { - const value = entry.value - if (!value) continue - counts.set(value, (counts.get(value) ?? 0) + 1) - } +function buildBreachWatchPasswordCountMap( + storage: InMemoryStorage, +): Map { + const counts = new Map(); + for (const bwRecord of storage.getAll("bw_record")) { + const passwords = (bwRecord.data as BreachWatchRecordData | undefined) + ?.passwords; + if (!Array.isArray(passwords)) continue; + for (const entry of passwords) { + const value = entry.value; + if (!value) continue; + counts.set(value, (counts.get(value) ?? 0) + 1); } - return counts + } + return counts; } -async function collectRecordUidsInFolderTree(storage: InMemoryStorage, folderUid: string | null): Promise { - const uids: string[] = [] - const folderQueue = [folderUid] - - while (folderQueue.length > 0) { - const currentFolderUid = folderQueue.shift() - const listed = await listFolder(storage, { - folderUid: currentFolderUid ?? undefined, - showFolders: true, - showRecords: true, - }) - - for (const record of listed.records ?? []) { - uids.push(record.uid) - } - for (const folder of listed.folders) { - folderQueue.push(folder.uid) - } +async function collectRecordUidsInFolderTree( + storage: InMemoryStorage, + folderUid: string | null, +): Promise { + const uids: string[] = []; + const folderQueue = [folderUid]; + + while (folderQueue.length > 0) { + const currentFolderUid = folderQueue.shift(); + const listed = await listFolder(storage, { + folderUid: currentFolderUid ?? undefined, + showFolders: true, + showRecords: true, + }); + + for (const record of listed.records ?? []) { + uids.push(record.uid); } + for (const folder of listed.folders) { + folderQueue.push(folder.uid); + } + } - return uids + return uids; } async function resolveTargetRecords( - storage: InMemoryStorage, - session: VaultFolderSession, - folder?: string | null + storage: InMemoryStorage, + session: VaultFolderSession, + folder?: string | null, ): Promise { - const records = storage.getRecords() - const folderPath = folder?.trim() - if (!folderPath) return records - - const resolved = await resolveSingleFolder(storage, session, folderPath) - const targetUids = new Set(await collectRecordUidsInFolderTree(storage, resolved.folderUid)) - return records.filter((record) => targetUids.has(record.uid)) + const records = storage.getRecords(); + const folderPath = folder?.trim(); + if (!folderPath) return records; + + const resolved = await resolveSingleFolder(storage, session, folderPath); + const targetUids = new Set( + await collectRecordUidsInFolderTree(storage, resolved.folderUid), + ); + return records.filter((record) => targetUids.has(record.uid)); } -function buildPasswordCountMap(records: readonly DRecord[]): Map { - const counts = new Map() - for (const record of records) { - const password = getRecordPassword(record) - if (!password) continue - counts.set(password, (counts.get(password) ?? 0) + 1) - } - return counts +function buildPasswordCountMap( + records: readonly DRecord[], +): Map { + const counts = new Map(); + for (const record of records) { + const password = getRecordPassword(record); + if (!password) continue; + counts.set(password, (counts.get(password) ?? 0) + 1); + } + return counts; } function buildNonCompliantRow( - record: DRecord, - policy: PasswordPolicy, - verbose: boolean, - context: VerboseRowContext + record: DRecord, + policy: PasswordPolicy, + verbose: boolean, + context: VerboseRowContext, ): PasswordReportRow[] { - if (!isSupportedRecordVersion(record.version)) return [] - - const password = getRecordPassword(record) - if (!password) return [] - - const strength = getPasswordStrength(password) - if (isPasswordCompliant(strength, policy)) return [] - - const row: PasswordReportRow = { - recordUid: record.uid, - title: truncateText(getRecordTitle(record)), - description: truncateText(getRecordDescription(record)), - length: strength.length, - lower: strength.lower, - upper: strength.caps, - digits: strength.digits, - special: strength.symbols, - } - - if (verbose) { - applyVerboseFields(row, record.uid, password, context) - } - - return [row] + if (!isSupportedRecordVersion(record.version)) return []; + + const password = getRecordPassword(record); + if (!password) return []; + + const strength = getPasswordStrength(password); + if (isPasswordCompliant(strength, policy)) return []; + + const row: PasswordReportRow = { + recordUid: record.uid, + title: truncateText(getRecordTitle(record)), + description: truncateText(getRecordDescription(record)), + length: strength.length, + lower: strength.lower, + upper: strength.caps, + digits: strength.digits, + special: strength.symbols, + }; + + if (verbose) { + applyVerboseFields(row, record.uid, password, context); + } + + return [row]; } function applyVerboseFields( - row: PasswordReportRow, - recordUid: string, - password: string, - context: VerboseRowContext + row: PasswordReportRow, + recordUid: string, + password: string, + context: VerboseRowContext, ): void { - const { storage, passwordCounts, breachWatchPasswordCounts } = context - row.score = String(calculatePasswordScore(password)) - row.status = getBreachWatchPasswordStatus(storage, recordUid, password) - - const reuseCount = breachWatchPasswordCounts.get(password) ?? passwordCounts.get(password) ?? 0 - if (reuseCount > 1) { - row.reused = String(reuseCount) - } + const { storage, passwordCounts, breachWatchPasswordCounts } = context; + row.score = String(calculatePasswordScore(password)); + row.status = getBreachWatchPasswordStatus(storage, recordUid, password); + + const reuseCount = + breachWatchPasswordCounts.get(password) ?? + passwordCounts.get(password) ?? + 0; + if (reuseCount > 1) { + row.reused = String(reuseCount); + } } diff --git a/KeeperSdk/src/enterpriseReport/reportTypes.ts b/KeeperSdk/src/enterpriseReport/reportTypes.ts index f3cde4f6..ba9f5122 100644 --- a/KeeperSdk/src/enterpriseReport/reportTypes.ts +++ b/KeeperSdk/src/enterpriseReport/reportTypes.ts @@ -1,412 +1,436 @@ -export type AuditReportType = 'raw' | 'dim' | 'hour' | 'day' | 'week' | 'month' | 'span' +export type AuditReportType = + | "raw" + | "dim" + | "hour" + | "day" + | "week" + | "month" + | "span"; -export type AuditSummaryReportType = 'hour' | 'day' | 'week' | 'month' | 'span' +export type AuditSummaryReportType = "hour" | "day" | "week" | "month" | "span"; export type AuditEventOverviewReportRow = { - id?: number - created?: number | string - username?: string - to_username?: string - from_username?: string - ip_address?: string - audit_event_type?: string - keeper_version?: string - keeper_version_category?: string - geo_location?: string - node_id?: number - node?: string - role_id?: string - enforcement?: string - value?: string - record_uid?: string - shared_folder_uid?: string - team_uid?: string - channel?: string - status?: string - recipient?: string - occurrences?: number - first_created?: number | string - last_created?: number | string -} + id?: number; + created?: number | string; + username?: string; + to_username?: string; + from_username?: string; + ip_address?: string; + audit_event_type?: string; + keeper_version?: string; + keeper_version_category?: string; + geo_location?: string; + node_id?: number; + node?: string; + role_id?: string; + enforcement?: string; + value?: string; + record_uid?: string; + shared_folder_uid?: string; + team_uid?: string; + channel?: string; + status?: string; + recipient?: string; + occurrences?: number; + first_created?: number | string; + last_created?: number | string; +}; export type AuditDimensionIpAddress = { - ip_address?: string - city?: string - region?: string - country?: string - country_code?: string - country_name?: string - geo_location?: string - ip_addresses?: string[] -} + ip_address?: string; + city?: string; + region?: string; + country?: string; + country_code?: string; + country_name?: string; + geo_location?: string; + ip_addresses?: string[]; +}; export type AuditDimensionKeeperVersion = { - version_id?: number - type_id?: number - type_name?: string - type_category?: string - version?: string - version_ids?: number[] -} + version_id?: number; + type_id?: number; + type_name?: string; + type_category?: string; + version?: string; + version_ids?: number[]; +}; export type AuditDimensionRow = - | AuditDimensionIpAddress - | AuditDimensionKeeperVersion - | string - | Record + | AuditDimensionIpAddress + | AuditDimensionKeeperVersion + | string + | Record; export type AuditReportFilterPayload = { - created?: - | CreatedPreset - | { - min?: number | string - max?: number | string - exclude_min?: boolean - exclude_max?: boolean - } - audit_event_type?: string | number | Array - keeper_version?: number | number[] - username?: string | string[] - to_username?: string | string[] - ip_address?: string[] - record_uid?: string | string[] - shared_folder_uid?: string | string[] - parent_id?: number | number[] -} + created?: + | CreatedPreset + | { + min?: number | string; + max?: number | string; + exclude_min?: boolean; + exclude_max?: boolean; + }; + audit_event_type?: string | number | Array; + keeper_version?: number | number[]; + username?: string | string[]; + to_username?: string | string[]; + ip_address?: string[]; + record_uid?: string | string[]; + shared_folder_uid?: string | string[]; + parent_id?: number | number[]; +}; export enum AuditReportOrder { - Asc = 'asc', - Desc = 'desc', + Asc = "asc", + Desc = "desc", } export enum AuditReportFormat { - Message = 'message', - Fields = 'fields', + Message = "message", + Fields = "fields", } export enum AuditOutputFormat { - Table = 'table', - Json = 'json', - Csv = 'csv', + Table = "table", + Json = "json", + Csv = "csv", } export enum AuditAggregate { - Occurrences = 'occurrences', - FirstCreated = 'first_created', - LastCreated = 'last_created', + Occurrences = "occurrences", + FirstCreated = "first_created", + LastCreated = "last_created", } export const SUMMARY_REPORT_TYPES: readonly AuditSummaryReportType[] = [ - 'hour', - 'day', - 'week', - 'month', - 'span', -] + "hour", + "day", + "week", + "month", + "span", +]; export const CREATED_PRESETS = [ - 'today', - 'yesterday', - 'last_7_days', - 'last_30_days', - 'month_to_date', - 'last_month', - 'year_to_date', - 'last_year', -] as const - -export type CreatedPreset = (typeof CREATED_PRESETS)[number] + "today", + "yesterday", + "last_7_days", + "last_30_days", + "month_to_date", + "last_month", + "year_to_date", + "last_year", +] as const; + +export type CreatedPreset = (typeof CREATED_PRESETS)[number]; export type CreatedFilterCriteria = { - fromDate?: number - toDate?: number - excludeFrom?: boolean - excludeTo?: boolean -} + fromDate?: number; + toDate?: number; + excludeFrom?: boolean; + excludeTo?: boolean; +}; export type AuditReportFilter = { - created?: CreatedPreset | string | CreatedFilterCriteria - eventType?: string | number | Array - keeperVersion?: number | number[] - username?: string | string[] - toUsername?: string | string[] - ipAddress?: string | string[] - recordUid?: string | string[] - sharedFolderUid?: string | string[] - geoLocation?: string - deviceType?: string - parentId?: number | number[] -} + created?: CreatedPreset | string | CreatedFilterCriteria; + eventType?: string | number | Array; + keeperVersion?: number | number[]; + username?: string | string[]; + toUsername?: string | string[]; + ipAddress?: string | string[]; + recordUid?: string | string[]; + sharedFolderUid?: string | string[]; + geoLocation?: string; + deviceType?: string; + parentId?: number | number[]; +}; export type AuditReportOptions = { - syntaxHelp?: boolean - reportType?: AuditReportType - reportFormat?: AuditReportFormat - columns?: string[] - aggregates?: AuditAggregate[] - timezone?: string - limit?: number - order?: AuditReportOrder - filter?: AuditReportFilter - hasAram?: boolean -} + syntaxHelp?: boolean; + reportType?: AuditReportType; + reportFormat?: AuditReportFormat; + columns?: string[]; + aggregates?: AuditAggregate[]; + timezone?: string; + limit?: number; + order?: AuditReportOrder; + filter?: AuditReportFilter; + hasAram?: boolean; +}; export type AuditReportResult = { - reportType: AuditReportType | null - headers: string[] - rows: string[][] - events?: AuditEventOverviewReportRow[] - syntaxHelp?: string - eventTypeReference?: Array<{ id: number; name: string }> -} + reportType: AuditReportType | null; + headers: string[]; + rows: string[][]; + events?: AuditEventOverviewReportRow[]; + syntaxHelp?: string; + eventTypeReference?: Array<{ id: number; name: string }>; +}; export enum TargetUserStatus { - NoLogon = 'no-logon', - NoUpdate = 'no-update', - Locked = 'locked', - Invited = 'invited', - NoRecovery = 'no-recovery', + NoLogon = "no-logon", + NoUpdate = "no-update", + Locked = "locked", + Invited = "invited", + NoRecovery = "no-recovery", } export enum AdminAction { - None = 'none', - Lock = 'lock', - Delete = 'delete', - Transfer = 'transfer', + None = "none", + Lock = "lock", + Delete = "delete", + Transfer = "transfer", } export enum ActionReportColumn { - UserId = 'user_id', - Email = 'email', - Name = 'name', - Status = 'status', - TransferStatus = 'transfer_status', - Node = 'node', - TeamCount = 'team_count', - Teams = 'teams', - RoleCount = 'role_count', - Roles = 'roles', - Alias = 'alias', - TwoFaEnabled = '2fa_enabled', + UserId = "user_id", + Email = "email", + Name = "name", + Status = "status", + TransferStatus = "transfer_status", + Node = "node", + TeamCount = "team_count", + Teams = "teams", + RoleCount = "role_count", + Roles = "roles", + Alias = "alias", + TwoFaEnabled = "2fa_enabled", } export const DEFAULT_ACTION_REPORT_COLUMNS: readonly ActionReportColumn[] = [ - ActionReportColumn.Email, - ActionReportColumn.Name, - ActionReportColumn.Status, - ActionReportColumn.TransferStatus, - ActionReportColumn.Node, -] + ActionReportColumn.Email, + ActionReportColumn.Name, + ActionReportColumn.Status, + ActionReportColumn.TransferStatus, + ActionReportColumn.Node, +]; export const SUPPORTED_ACTION_REPORT_COLUMNS: readonly ActionReportColumn[] = [ - ActionReportColumn.UserId, - ...DEFAULT_ACTION_REPORT_COLUMNS, - ActionReportColumn.TeamCount, - ActionReportColumn.Teams, - ActionReportColumn.RoleCount, - ActionReportColumn.Roles, - ActionReportColumn.Alias, - ActionReportColumn.TwoFaEnabled, -] - -export const ACTION_REPORT_COLUMN_ORDER: readonly ActionReportColumn[] = SUPPORTED_ACTION_REPORT_COLUMNS + ActionReportColumn.UserId, + ...DEFAULT_ACTION_REPORT_COLUMNS, + ActionReportColumn.TeamCount, + ActionReportColumn.Teams, + ActionReportColumn.RoleCount, + ActionReportColumn.Roles, + ActionReportColumn.Alias, + ActionReportColumn.TwoFaEnabled, +]; + +export const ACTION_REPORT_COLUMN_ORDER: readonly ActionReportColumn[] = + SUPPORTED_ACTION_REPORT_COLUMNS; export type ActionReportEntry = { - enterpriseUserId: number - email: string - fullName: string - status: string - transferStatus: string - nodePath: string - teams: string[] - roles: string[] - aliases: string[] - tfaEnabled: boolean -} + enterpriseUserId: number; + email: string; + fullName: string; + status: string; + transferStatus: string; + nodePath: string; + teams: string[]; + roles: string[]; + aliases: string[]; + tfaEnabled: boolean; +}; export type ActionReportOptions = { - target?: TargetUserStatus - daysSince?: number - columns?: ActionReportColumn[] | `${ActionReportColumn}`[] | string | null - applyAction?: AdminAction - targetUser?: string - dryRun?: boolean - node?: string - timezone?: string -} + target?: TargetUserStatus; + daysSince?: number; + columns?: ActionReportColumn[] | `${ActionReportColumn}`[] | string | null; + applyAction?: AdminAction; + targetUser?: string; + dryRun?: boolean; + node?: string; + timezone?: string; +}; export type ActionResult = { - action: AdminAction | string - status: string - affectedCount: number - serverMessage: string -} + action: AdminAction | string; + status: string; + affectedCount: number; + serverMessage: string; +}; export type ActionReportResult = { - target: TargetUserStatus - headers: string[] - rows: string[][] - entries: ActionReportEntry[] - actionResult: ActionResult -} + target: TargetUserStatus; + headers: string[]; + rows: string[][]; + entries: ActionReportEntry[]; + actionResult: ActionResult; +}; -export const PW_SPECIAL_CHARACTERS = '!@#$%()+;<>=?[]{}^.,' -export const DEFAULT_TRUNCATION_LENGTH = 32 -export const SUPPORTED_RECORD_VERSIONS = [2, 3] as const +export const PW_SPECIAL_CHARACTERS = "!@#$%()+;<>=?[]{}^.,"; +export const DEFAULT_TRUNCATION_LENGTH = 32; +export const SUPPORTED_RECORD_VERSIONS = [2, 3] as const; export type PasswordPolicy = { - length: number - lower: number - upper: number - digits: number - special: number -} + length: number; + lower: number; + upper: number; + digits: number; + special: number; +}; export type PasswordStrength = { - length: number - lower: number - caps: number - digits: number - symbols: number -} + length: number; + lower: number; + caps: number; + digits: number; + symbols: number; +}; export type PasswordReportRow = { - recordUid: string - title: string - description: string - length: number - lower: number - upper: number - digits: number - special: number - score?: string - status?: string - reused?: string -} + recordUid: string; + title: string; + description: string; + length: number; + lower: number; + upper: number; + digits: number; + special: number; + score?: string; + status?: string; + reused?: string; +}; export type PasswordReportOptions = { - folder?: string | null - policy?: string | null - length?: number - lower?: number - upper?: number - digits?: number - special?: number - verbose?: boolean - rowNumbers?: boolean -} + folder?: string | null; + policy?: string | null; + length?: number; + lower?: number; + upper?: number; + digits?: number; + special?: number; + verbose?: boolean; + rowNumbers?: boolean; +}; export type PasswordReportResult = { - policy: PasswordPolicy - policySummary: string - rows: PasswordReportRow[] - verbose: boolean - rowNumbers: boolean -} + policy: PasswordPolicy; + policySummary: string; + rows: PasswordReportRow[]; + verbose: boolean; + rowNumbers: boolean; +}; export const AUDIT_RAW_FIELDS = [ - 'created', - 'audit_event_type', - 'username', - 'ip_address', - 'keeper_version', - 'geo_location', -] as const + "created", + "audit_event_type", + "username", + "ip_address", + "keeper_version", + "geo_location", +] as const; export const AUDIT_MISC_FIELDS = [ - 'to_username', - 'from_username', - 'record_uid', - 'shared_folder_uid', - 'node', - 'role_id', - 'team_uid', - 'channel', - 'status', - 'recipient', - 'value', -] as const - -export const AUDIT_RAW_PAGE_SIZE = 1000 -export const AUDIT_NO_ARAM_RAW_LIMIT = 1000 -export const AUDIT_DEFAULT_RAW_LIMIT = 50 -export const AUDIT_DEFAULT_SUMMARY_LIMIT = 100 -export const AUDIT_SUMMARY_MAX_LIMIT = 2000 -export const AUDIT_DIMENSION_API_LIMIT = 2000 + "to_username", + "from_username", + "record_uid", + "shared_folder_uid", + "node", + "role_id", + "team_uid", + "channel", + "status", + "recipient", + "value", +] as const; + +export const AUDIT_RAW_PAGE_SIZE = 1000; +export const AUDIT_NO_ARAM_RAW_LIMIT = 1000; +export const AUDIT_DEFAULT_RAW_LIMIT = 50; +export const AUDIT_DEFAULT_SUMMARY_LIMIT = 100; +export const AUDIT_SUMMARY_MAX_LIMIT = 2000; +export const AUDIT_DIMENSION_API_LIMIT = 2000; export const AUDIT_VIRTUAL_DIMENSIONS: Readonly> = { - geo_location: 'ip_address', - device_type: 'keeper_version', -} + geo_location: "ip_address", + device_type: "keeper_version", +}; export const AUDIT_SYNTAX_HELP = [ - 'Audit report types: raw, dim, hour, day, week, month, span', - 'Filters: created, eventType, username, toUsername, ipAddress, recordUid, sharedFolderUid, geoLocation, deviceType', - 'Raw defaults to message format; use reportFormat=fields for all columns.', -].join('\n') - -export const ACTION_STATUS_EVENT_TYPES: Readonly> = { - [TargetUserStatus.NoLogon]: ['login', 'login_console', 'chat_login', 'accept_invitation'], - [TargetUserStatus.NoUpdate]: ['record_add', 'record_update'], - [TargetUserStatus.Locked]: ['lock_user'], - [TargetUserStatus.Invited]: ['send_invitation', 'auto_invite_user'], - [TargetUserStatus.NoRecovery]: ['change_security_question', 'account_recovery_setup'], -} - -export const ACTION_DEFAULT_DAYS_BY_TARGET: Partial> = { - [TargetUserStatus.Locked]: 90, -} - -export const ACTION_DEFAULT_DAYS = 30 -export const ACTION_USERNAME_BATCH_SIZE = 2000 -export const ACTION_EVENT_SUMMARY_ROW_LIMIT = 2000 -export const SECONDS_PER_DAY = 86400 - -export const ACTION_COLUMN_LABELS: Readonly> = { - [ActionReportColumn.UserId]: 'User ID', - [ActionReportColumn.Email]: 'Email', - [ActionReportColumn.Name]: 'Name', - [ActionReportColumn.Status]: 'Status', - [ActionReportColumn.TransferStatus]: 'Transfer Status', - [ActionReportColumn.Node]: 'Node', - [ActionReportColumn.TeamCount]: 'Team Count', - [ActionReportColumn.Teams]: 'Teams', - [ActionReportColumn.RoleCount]: 'Role Count', - [ActionReportColumn.Roles]: 'Roles', - [ActionReportColumn.Alias]: 'Alias', - [ActionReportColumn.TwoFaEnabled]: '2FA Enabled', -} + "Audit report types: raw, dim, hour, day, week, month, span", + "Filters: created, eventType, username, toUsername, ipAddress, recordUid, sharedFolderUid, geoLocation, deviceType", + "Raw defaults to message format; use reportFormat=fields for all columns.", +].join("\n"); + +export const ACTION_STATUS_EVENT_TYPES: Readonly< + Record +> = { + [TargetUserStatus.NoLogon]: [ + "login", + "login_console", + "chat_login", + "accept_invitation", + ], + [TargetUserStatus.NoUpdate]: ["record_add", "record_update"], + [TargetUserStatus.Locked]: ["lock_user"], + [TargetUserStatus.Invited]: ["send_invitation", "auto_invite_user"], + [TargetUserStatus.NoRecovery]: [ + "change_security_question", + "account_recovery_setup", + ], +}; + +export const ACTION_DEFAULT_DAYS_BY_TARGET: Partial< + Record +> = { + [TargetUserStatus.Locked]: 90, +}; + +export const ACTION_DEFAULT_DAYS = 30; +export const ACTION_USERNAME_BATCH_SIZE = 2000; +export const ACTION_EVENT_SUMMARY_ROW_LIMIT = 2000; +export const SECONDS_PER_DAY = 86400; + +export const ACTION_COLUMN_LABELS: Readonly< + Record +> = { + [ActionReportColumn.UserId]: "User ID", + [ActionReportColumn.Email]: "Email", + [ActionReportColumn.Name]: "Name", + [ActionReportColumn.Status]: "Status", + [ActionReportColumn.TransferStatus]: "Transfer Status", + [ActionReportColumn.Node]: "Node", + [ActionReportColumn.TeamCount]: "Team Count", + [ActionReportColumn.Teams]: "Teams", + [ActionReportColumn.RoleCount]: "Role Count", + [ActionReportColumn.Roles]: "Roles", + [ActionReportColumn.Alias]: "Alias", + [ActionReportColumn.TwoFaEnabled]: "2FA Enabled", +}; export const PASSWORD_REPORT_BASE_HEADERS = [ - 'record_uid', - 'title', - 'description', - 'length', - 'lower', - 'upper', - 'digits', - 'special', -] - -export const PASSWORD_REPORT_VERBOSE_HEADERS = ['score', 'status', 'reused'] - -export const PASSWORD_BREACHWATCH_STATUS_NAMES: Readonly> = { - 0: 'GOOD', - 1: 'CHANGED', - 2: 'WEAK', - 3: 'BREACHED', - 4: 'IGNORE', -} + "record_uid", + "title", + "description", + "length", + "lower", + "upper", + "digits", + "special", +]; + +export const PASSWORD_REPORT_VERBOSE_HEADERS = ["score", "status", "reused"]; + +export const PASSWORD_BREACHWATCH_STATUS_NAMES: Readonly< + Record +> = { + 0: "GOOD", + 1: "CHANGED", + 2: "WEAK", + 3: "BREACHED", + 4: "IGNORE", +}; export type EnterpriseAuditLicense = { - audit_and_reporting_enabled?: boolean - add_ons?: Array<{ - name?: string - enterprise_audit_and_reporting_enabled?: boolean - }> -} + audit_and_reporting_enabled?: boolean; + add_ons?: Array<{ + name?: string; + enterprise_audit_and_reporting_enabled?: boolean; + }>; +}; export type AuditDimensionEventType = { - id?: number - name?: string - category?: string - syslog?: string -} + id?: number; + name?: string; + category?: string; + syslog?: string; +}; diff --git a/KeeperSdk/src/enterpriseReport/reportUtils.ts b/KeeperSdk/src/enterpriseReport/reportUtils.ts index cf647d99..db9e8fce 100644 --- a/KeeperSdk/src/enterpriseReport/reportUtils.ts +++ b/KeeperSdk/src/enterpriseReport/reportUtils.ts @@ -1,36 +1,38 @@ -import type { Auth, KeeperResponse } from '@keeper-security/keeperapi' -import { KeeperSdkError } from '../utils' -import { AuditReportOrder } from './reportTypes' +import type { Auth, KeeperResponse } from "@keeper-security/keeperapi"; +import { KeeperSdkError } from "../utils"; +import { AuditReportOrder } from "./reportTypes"; -export type AuthProvider = () => Auth +export type AuthProvider = () => Auth; export function resolveTimezone(timezone: string | undefined): string { - if (timezone?.trim()) return timezone.trim() - const hours = -new Date().getTimezoneOffset() / 60 - return `Etc/GMT${hours >= 0 ? '+' : ''}${hours}` + if (timezone?.trim()) return timezone.trim(); + const hours = -new Date().getTimezoneOffset() / 60; + return `Etc/GMT${hours >= 0 ? "+" : ""}${hours}`; } export function assertSucceeded( - response: KeeperResponse, - fallbackMessage: string, - fallbackCode: string + response: KeeperResponse, + fallbackMessage: string, + fallbackCode: string, ): void { - if ((response.result || '').toLowerCase() === 'fail') { - throw new KeeperSdkError( - response.message || response.result_code || fallbackMessage, - response.result_code || fallbackCode - ) - } + if ((response.result || "").toLowerCase() === "fail") { + throw new KeeperSdkError( + response.message || response.result_code || fallbackMessage, + response.result_code || fallbackCode, + ); + } } -export function toAuditApiOrder(order: AuditReportOrder): 'ascending' | 'descending' { - return order === AuditReportOrder.Asc ? 'ascending' : 'descending' +export function toAuditApiOrder( + order: AuditReportOrder, +): "ascending" | "descending" { + return order === AuditReportOrder.Asc ? "ascending" : "descending"; } export function chunkArray(values: T[], size: number): T[][] { - const chunks: T[][] = [] - for (let index = 0; index < values.length; index += size) { - chunks.push(values.slice(index, index + size)) - } - return chunks + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; } diff --git a/KeeperSdk/src/folders/FolderManager.ts b/KeeperSdk/src/folders/FolderManager.ts index 54e07cbe..bcb07732 100644 --- a/KeeperSdk/src/folders/FolderManager.ts +++ b/KeeperSdk/src/folders/FolderManager.ts @@ -1,174 +1,261 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes } from '../utils' -import { addFolder, mkdir } from './addFolder' -import type { AddFolderInput, AddFolderResult, MkdirOptions } from './addFolder' +import type { Auth } from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes } from "../utils"; +import { addFolder, mkdir } from "./addFolder"; +import type { + AddFolderInput, + AddFolderResult, + MkdirOptions, +} from "./addFolder"; import { - changeDirectory, - createVaultFolderSession, - findParentFolderUid, - getWorkingFolderDisplayName, - resolveSingleFolder, - splitPathComponents, - tryResolvePath, -} from './changeDirectory' -import type { ChangeDirectoryResult, TryResolvePathResult, VaultFolderSession } from './changeDirectory' -import { buildFolderDeleteObject, deleteFolder, resolveRmdirPatternsToFolderUids, rmdir } from './deleteFolder' -import type { DeleteFolderResult, RmdirOptions } from './deleteFolder' -import { buildFolderTree, folderTreeAscii, renderFolderTreeAscii } from './folderTree' -import type { FolderTreeBuildOptions, FolderTreeResult } from './folderTree' -import { findFolder, getFolder } from './getFolder' -import type { FoundFolder, GetFolderOptions, GetFolderResult } from './getFolder' -import { findFolderUidByNameOrUid, listFolder, listRootUserFolders, listVaultRootFolders } from './listFolder' -import type { ListFolderFolderSimple, ListFolderOptions, ListFolderResult } from './listFolder' -import { renameFolder, updateFolder, updateSharedFolderPermissions } from './updateFolder' -import type { RenameFolderResult, UpdateFolderInput, UpdateFolderResult } from './updateFolder' - -export type AuthProvider = () => Auth + changeDirectory, + createVaultFolderSession, + findParentFolderUid, + getWorkingFolderDisplayName, + resolveSingleFolder, + splitPathComponents, + tryResolvePath, +} from "./changeDirectory"; +import type { + ChangeDirectoryResult, + TryResolvePathResult, + VaultFolderSession, +} from "./changeDirectory"; +import { + buildFolderDeleteObject, + deleteFolder, + resolveRmdirPatternsToFolderUids, + rmdir, +} from "./deleteFolder"; +import type { DeleteFolderResult, RmdirOptions } from "./deleteFolder"; +import { + buildFolderTree, + folderTreeAscii, + renderFolderTreeAscii, +} from "./folderTree"; +import type { FolderTreeBuildOptions, FolderTreeResult } from "./folderTree"; +import { findFolder, getFolder } from "./getFolder"; +import type { + FoundFolder, + GetFolderOptions, + GetFolderResult, +} from "./getFolder"; +import { + findFolderUidByNameOrUid, + listFolder, + listRootUserFolders, + listVaultRootFolders, +} from "./listFolder"; +import type { + ListFolderFolderSimple, + ListFolderOptions, + ListFolderResult, +} from "./listFolder"; +import { + renameFolder, + updateFolder, + updateSharedFolderPermissions, +} from "./updateFolder"; +import type { + RenameFolderResult, + UpdateFolderInput, + UpdateFolderResult, +} from "./updateFolder"; + +export type AuthProvider = () => Auth; export type SharedFolderPermissionsInput = { - manageUsers?: boolean | null - manageRecords?: boolean | null - canShare?: boolean | null - canEdit?: boolean | null -} + manageUsers?: boolean | null; + manageRecords?: boolean | null; + canShare?: boolean | null; + canEdit?: boolean | null; +}; export class FolderManager { - private readonly storage: InMemoryStorage - private readonly session: VaultFolderSession - private readonly authProvider: AuthProvider - - constructor(storage: InMemoryStorage, session: VaultFolderSession, authProvider: AuthProvider) { - this.storage = storage - this.session = session - this.authProvider = authProvider - } - - public static createSession(): VaultFolderSession { - return createVaultFolderSession() - } - - public static splitPathComponents(path: string): string[] { - return splitPathComponents(path) - } - - public getSession(): VaultFolderSession { - return this.session - } - - public getCurrentFolderUid(): string | null { - return this.session.currentFolderUid - } - - public getWorkingFolderDisplayName(): string { - return getWorkingFolderDisplayName(this.storage, this.session.currentFolderUid) - } - - private requireAuth(): Auth { - const auth = this.authProvider() - if (!auth) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } - return auth - } - - public async listFolder(options: ListFolderOptions = {}): Promise { - return listFolder(this.storage, options) - } - - public listRootUserFolders() { - return listRootUserFolders(this.storage) - } - - public async listVaultRootFolders(): Promise<{ - rows: ListFolderFolderSimple[] - promotedRootSharedUids: Set - }> { - return listVaultRootFolders(this.storage) - } - - public findFolderUidByNameOrUid(nameOrUid: string): string | undefined { - return findFolderUidByNameOrUid(this.storage, nameOrUid) - } - - public findFolder(nameOrUid: string): FoundFolder | undefined { - return findFolder(this.storage, nameOrUid) - } - - public async findParentFolderUid(folderUid: string): Promise { - return findParentFolderUid(this.storage, folderUid) - } - - public async getFolder(uidOrName: string, options: GetFolderOptions = {}): Promise { - return getFolder(this.storage, uidOrName, options) - } - - public async changeDirectory(path: string): Promise { - return changeDirectory(this.storage, this.session, path) - } - - public async tryResolvePath(path: string): Promise { - return tryResolvePath(this.storage, this.session, path) - } - - public async resolveSingleFolder(folderName: string): Promise { - return resolveSingleFolder(this.storage, this.session, folderName) - } - - public async addFolder(input: AddFolderInput): Promise { - return addFolder(this.requireAuth(), this.storage, input) - } - - public async mkdir( - path: string, - options: MkdirOptions = {} - ): Promise<{ folderUid: string; success: boolean; message?: string }> { - return mkdir(this.requireAuth(), this.storage, this.session, path, options) - } - - public async updateFolder(input: UpdateFolderInput): Promise { - return updateFolder(this.requireAuth(), this.storage, input) - } - - public async renameFolder(folderPath: string, newName: string): Promise { - return renameFolder(this.requireAuth(), this.storage, this.session, folderPath, newName) - } - - public async updateSharedFolderPermissions( - sharedFolderUid: string, - permissions: SharedFolderPermissionsInput - ): Promise { - return updateSharedFolderPermissions(this.requireAuth(), this.storage, sharedFolderUid, permissions) - } - - public async deleteFolder( - folderRefs: string[], - confirm?: (summary: string) => boolean | Promise - ): Promise { - return deleteFolder(this.requireAuth(), this.storage, folderRefs, confirm) - } - - public async rmdir(patterns: string[], options: RmdirOptions = {}): Promise { - return rmdir(this.requireAuth(), this.storage, this.session, patterns, options) - } - - public async resolveRmdirPatternsToFolderUids(pattern: string): Promise> { - return resolveRmdirPatternsToFolderUids(this.storage, this.session, pattern) - } - - public async buildFolderDeleteObject(folderUid: string) { - return buildFolderDeleteObject(this.storage, folderUid) - } - - public async buildFolderTree(options: FolderTreeBuildOptions = {}): Promise { - return buildFolderTree(this.storage, this.session, options) - } - - public async folderTreeAscii(options: FolderTreeBuildOptions = {}): Promise { - return folderTreeAscii(this.storage, this.session, options) - } - - public renderFolderTreeAscii(tree: FolderTreeResult): string { - return renderFolderTreeAscii(tree) + private readonly storage: InMemoryStorage; + private readonly session: VaultFolderSession; + private readonly authProvider: AuthProvider; + + constructor( + storage: InMemoryStorage, + session: VaultFolderSession, + authProvider: AuthProvider, + ) { + this.storage = storage; + this.session = session; + this.authProvider = authProvider; + } + + public static createSession(): VaultFolderSession { + return createVaultFolderSession(); + } + + public static splitPathComponents(path: string): string[] { + return splitPathComponents(path); + } + + public getSession(): VaultFolderSession { + return this.session; + } + + public getCurrentFolderUid(): string | null { + return this.session.currentFolderUid; + } + + public getWorkingFolderDisplayName(): string { + return getWorkingFolderDisplayName(this.storage, this.session); + } + + private requireAuth(): Auth { + const auth = this.authProvider(); + if (!auth) { + throw new KeeperSdkError( + "Not logged in. Call login() first.", + ResultCodes.NOT_LOGGED_IN, + ); } + return auth; + } + + public async listFolder( + options: ListFolderOptions = {}, + ): Promise { + return listFolder(this.storage, options); + } + + public listRootUserFolders() { + return listRootUserFolders(this.storage); + } + + public async listVaultRootFolders(): Promise<{ + rows: ListFolderFolderSimple[]; + promotedRootSharedUids: Set; + }> { + return listVaultRootFolders(this.storage); + } + + public findFolderUidByNameOrUid(nameOrUid: string): string | undefined { + return findFolderUidByNameOrUid(this.storage, nameOrUid); + } + + public findFolder(nameOrUid: string): FoundFolder | undefined { + return findFolder(this.storage, nameOrUid); + } + + public async findParentFolderUid(folderUid: string): Promise { + return findParentFolderUid(this.storage, folderUid); + } + + public async getFolder( + uidOrName: string, + options: GetFolderOptions = {}, + ): Promise { + return getFolder(this.storage, uidOrName, options); + } + + public async changeDirectory(path: string): Promise { + return changeDirectory(this.storage, this.session, path); + } + + public async tryResolvePath(path: string): Promise { + return tryResolvePath(this.storage, this.session, path); + } + + public async resolveSingleFolder( + folderName: string, + ): Promise { + return resolveSingleFolder(this.storage, this.session, folderName); + } + + public async addFolder(input: AddFolderInput): Promise { + return addFolder(this.requireAuth(), this.storage, input); + } + + public async mkdir( + path: string, + options: MkdirOptions = {}, + ): Promise<{ folderUid: string; success: boolean; message?: string }> { + return mkdir(this.requireAuth(), this.storage, this.session, path, options); + } + + public async updateFolder( + input: UpdateFolderInput, + ): Promise { + return updateFolder(this.requireAuth(), this.storage, input); + } + + public async renameFolder( + folderPath: string, + newName: string, + ): Promise { + return renameFolder( + this.requireAuth(), + this.storage, + this.session, + folderPath, + newName, + ); + } + + public async updateSharedFolderPermissions( + sharedFolderUid: string, + permissions: SharedFolderPermissionsInput, + ): Promise { + return updateSharedFolderPermissions( + this.requireAuth(), + this.storage, + sharedFolderUid, + permissions, + ); + } + + public async deleteFolder( + folderRefs: string[], + confirm?: (summary: string) => boolean | Promise, + ): Promise { + return deleteFolder(this.requireAuth(), this.storage, folderRefs, confirm); + } + + public async rmdir( + patterns: string[], + options: RmdirOptions = {}, + ): Promise { + return rmdir( + this.requireAuth(), + this.storage, + this.session, + patterns, + options, + ); + } + + public async resolveRmdirPatternsToFolderUids( + pattern: string, + ): Promise> { + return resolveRmdirPatternsToFolderUids( + this.storage, + this.session, + pattern, + ); + } + + public async buildFolderDeleteObject(folderUid: string) { + return buildFolderDeleteObject(this.storage, folderUid); + } + + public async buildFolderTree( + options: FolderTreeBuildOptions = {}, + ): Promise { + return buildFolderTree(this.storage, this.session, options); + } + + public async folderTreeAscii( + options: FolderTreeBuildOptions = {}, + ): Promise { + return folderTreeAscii(this.storage, this.session, options); + } + + public renderFolderTreeAscii(tree: FolderTreeResult): string { + return renderFolderTreeAscii(tree); + } } diff --git a/KeeperSdk/src/folders/addFolder.ts b/KeeperSdk/src/folders/addFolder.ts index c90b724c..50ab6fa1 100644 --- a/KeeperSdk/src/folders/addFolder.ts +++ b/KeeperSdk/src/folders/addFolder.ts @@ -1,295 +1,401 @@ -import type { Auth, FolderAddRequest } from '@keeper-security/keeperapi' +import type { Auth, FolderAddRequest } from "@keeper-security/keeperapi"; import { - encryptForStorage, - encryptObjectForStorage, - folderAddCommand, - generateEncryptionKey, - generateUid, - platform, -} from '@keeper-security/keeperapi' -import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { isBoolean, KeeperSdkError, extractErrorMessage } from '../utils' -import { listFolder } from './listFolder' -import { tryResolvePath, splitPathComponents, type VaultFolderSession } from './changeDirectory' -import { FolderKind, FolderResultStatus, ParentFolderKind, validateFolderName } from './folderHelpers' - -type NewFolderKind = FolderKind + encryptForStorage, + encryptObjectForStorage, + folderAddCommand, + generateEncryptionKey, + generateUid, + platform, +} from "@keeper-security/keeperapi"; +import type { + DSharedFolder, + DSharedFolderFolder, + DUserFolder, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { isBoolean, KeeperSdkError, extractErrorMessage } from "../utils"; +import { listFolder } from "./listFolder"; +import { + tryResolvePath, + splitPathComponents, + type VaultFolderSession, +} from "./changeDirectory"; +import { + FolderKind, + FolderResultStatus, + ParentFolderKind, + validateFolderName, +} from "./folderHelpers"; + +type NewFolderKind = FolderKind; export type AddFolderInput = { - folderName: string - isSharedFolder?: boolean - parentUid?: string | null - manageUsers?: boolean - manageRecords?: boolean - canShare?: boolean - canEdit?: boolean -} + folderName: string; + isSharedFolder?: boolean; + parentUid?: string | null; + manageUsers?: boolean; + manageRecords?: boolean; + canShare?: boolean; + canEdit?: boolean; + color?: string | null; +}; export type AddFolderResult = { - folderUid: string - success: boolean - message?: string -} + folderUid: string; + success: boolean; + message?: string; +}; export type MkdirOptions = { - sharedFolder?: boolean - userFolder?: boolean - grantAll?: boolean - manageUsers?: boolean - manageRecords?: boolean - canShare?: boolean - canEdit?: boolean -} + sharedFolder?: boolean; + userFolder?: boolean; + grantAll?: boolean; + manageUsers?: boolean; + manageRecords?: boolean; + canShare?: boolean; + canEdit?: boolean; + color?: string | null; +}; type ParentContext = { - kind: ParentFolderKind - sharedScopeUid: string | null + kind: ParentFolderKind; + sharedScopeUid: string | null; +}; + +function resolveParentContext( + storage: InMemoryStorage, + parentUid: string | null, +): ParentContext { + if (parentUid === null || parentUid === "") { + return { kind: ParentFolderKind.VirtualRoot, sharedScopeUid: null }; + } + if (storage.getByUid(FolderKind.UserFolder, parentUid)) { + return { kind: ParentFolderKind.UserFolder, sharedScopeUid: null }; + } + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + parentUid, + ); + if (sharedFolder) { + return { + kind: ParentFolderKind.SharedFolder, + sharedScopeUid: sharedFolder.uid, + }; + } + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + parentUid, + ); + if (sharedFolderFolder) { + return { + kind: ParentFolderKind.SharedFolderFolder, + sharedScopeUid: sharedFolderFolder.sharedFolderUid, + }; + } + throw new KeeperSdkError( + `Parent folder "${parentUid}" not found`, + "folder_not_found", + ); } -function resolveParentContext(storage: InMemoryStorage, parentUid: string | null): ParentContext { - if (parentUid === null || parentUid === '') { - return { kind: ParentFolderKind.VirtualRoot, sharedScopeUid: null } - } - if (storage.getByUid(FolderKind.UserFolder, parentUid)) { - return { kind: ParentFolderKind.UserFolder, sharedScopeUid: null } - } - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, parentUid) - if (sharedFolder) { - return { kind: ParentFolderKind.SharedFolder, sharedScopeUid: sharedFolder.uid } +function decideNewFolderType( + parent: ParentContext, + isSharedFolder: boolean, +): NewFolderKind { + if (isSharedFolder) { + if ( + parent.kind !== ParentFolderKind.UserFolder && + parent.kind !== ParentFolderKind.VirtualRoot + ) { + throw new KeeperSdkError( + "Shared folders cannot be nested inside other shared folders.", + "shared_folder_nested", + ); } - const sharedFolderFolder = storage.getByUid(FolderKind.SharedFolderFolder, parentUid) - if (sharedFolderFolder) { - return { - kind: ParentFolderKind.SharedFolderFolder, - sharedScopeUid: sharedFolderFolder.sharedFolderUid, - } - } - throw new KeeperSdkError(`Parent folder "${parentUid}" not found`, 'folder_not_found') -} - -function decideNewFolderType(parent: ParentContext, isSharedFolder: boolean): NewFolderKind { - if (isSharedFolder) { - if (parent.kind !== ParentFolderKind.UserFolder && parent.kind !== ParentFolderKind.VirtualRoot) { - throw new KeeperSdkError( - 'Shared folders cannot be nested inside other shared folders.', - 'shared_folder_nested' - ) - } - return FolderKind.SharedFolder - } - if (parent.kind === ParentFolderKind.VirtualRoot || parent.kind === ParentFolderKind.UserFolder) { - return FolderKind.UserFolder - } - return FolderKind.SharedFolderFolder + return FolderKind.SharedFolder; + } + if ( + parent.kind === ParentFolderKind.VirtualRoot || + parent.kind === ParentFolderKind.UserFolder + ) { + return FolderKind.UserFolder; + } + return FolderKind.SharedFolderFolder; } async function getEncryptionKeyForNewFolder( - auth: Auth, - storage: InMemoryStorage, - folderType: NewFolderKind, - sharedScopeUid: string | null + auth: Auth, + storage: InMemoryStorage, + folderType: NewFolderKind, + sharedScopeUid: string | null, ): Promise { - if (folderType === FolderKind.SharedFolderFolder) { - if (!sharedScopeUid) { - throw new KeeperSdkError('Shared folder scope could not be resolved.', 'shared_folder_scope_missing') - } - const sharedFolderKey = await storage.getKeyBytes(sharedScopeUid) - if (!sharedFolderKey) { - throw new KeeperSdkError( - 'Shared folder encryption key not available. Sync the vault and try again.', - 'shared_folder_key_missing' - ) - } - return sharedFolderKey + if (folderType === FolderKind.SharedFolderFolder) { + if (!sharedScopeUid) { + throw new KeeperSdkError( + "Shared folder scope could not be resolved.", + "shared_folder_scope_missing", + ); } - if (!auth.dataKey) { - throw new KeeperSdkError('Data key not available. Ensure you are logged in.', 'data_key_missing') + const sharedFolderKey = await storage.getKeyBytes(sharedScopeUid); + if (!sharedFolderKey) { + throw new KeeperSdkError( + "Shared folder encryption key not available. Sync the vault and try again.", + "shared_folder_key_missing", + ); } - return auth.dataKey + return sharedFolderKey; + } + if (!auth.dataKey) { + throw new KeeperSdkError( + "Data key not available. Ensure you are logged in.", + "data_key_missing", + ); + } + return auth.dataKey; } async function findChildFolderUidByName( - storage: InMemoryStorage, - parentUid: string | null, - name: string + storage: InMemoryStorage, + parentUid: string | null, + name: string, ): Promise { - const result = await listFolder(storage, { - folderUid: parentUid === null ? undefined : parentUid, - showFolders: true, - showRecords: false, - }) - const trimmedName = name.trim() - const lowerName = trimmedName.toLowerCase() - for (const folder of result.folders) { - if (folder.uid === trimmedName) return folder.uid - if (folder.name.trim() === trimmedName) return folder.uid - if (folder.name.trim().toLowerCase() === lowerName) return folder.uid - } - return undefined + const result = await listFolder(storage, { + folderUid: parentUid === null ? undefined : parentUid, + showFolders: true, + showRecords: false, + }); + const trimmedName = name.trim(); + const lowerName = trimmedName.toLowerCase(); + for (const folder of result.folders) { + if (folder.uid === trimmedName) return folder.uid; + if (folder.name.trim() === trimmedName) return folder.uid; + if (folder.name.trim().toLowerCase() === lowerName) return folder.uid; + } + return undefined; } -export async function addFolder(auth: Auth, storage: InMemoryStorage, input: AddFolderInput): Promise { - const name = input.folderName?.trim() - if (!name) { - throw new KeeperSdkError('Folder name cannot be empty.', 'folder_name_required') - } - validateFolderName(name) - - const parentUid = input.parentUid === undefined || input.parentUid === '' ? null : input.parentUid - const parent = resolveParentContext(storage, parentUid) - - const isShared = input.isSharedFolder === true - const folderType = decideNewFolderType(parent, isShared) - - const folderUid = generateUid() - const folderKey = generateEncryptionKey() - - const sharedScope = folderType === FolderKind.SharedFolderFolder ? parent.sharedScopeUid : null - - const encryptionKey = await getEncryptionKeyForNewFolder(auth, storage, folderType, sharedScope) - - const request: FolderAddRequest = { - folder_uid: folderUid, - folder_type: folderType, - key: await encryptForStorage(folderKey, encryptionKey), - data: await encryptObjectForStorage({ name, title: name }, folderKey), - link: false, - } - - if (parentUid) { - request.parent_uid = parentUid - } - if (folderType === FolderKind.SharedFolderFolder && sharedScope) { - request.shared_folder_uid = sharedScope - } - - if (folderType === FolderKind.SharedFolder) { - request.name = await encryptForStorage(platform.stringToBytes(name), folderKey) - request.manage_users = isBoolean(input.manageUsers) ? input.manageUsers : false - request.manage_records = isBoolean(input.manageRecords) ? input.manageRecords : false - request.can_edit = isBoolean(input.canEdit) ? input.canEdit : false - request.can_share = isBoolean(input.canShare) ? input.canShare : false +export async function addFolder( + auth: Auth, + storage: InMemoryStorage, + input: AddFolderInput, +): Promise { + const name = input.folderName?.trim(); + if (!name) { + throw new KeeperSdkError( + "Folder name cannot be empty.", + "folder_name_required", + ); + } + validateFolderName(name); + + const parentUid = + input.parentUid === undefined || input.parentUid === "" + ? null + : input.parentUid; + const parent = resolveParentContext(storage, parentUid); + + const isShared = input.isSharedFolder === true; + const folderType = decideNewFolderType(parent, isShared); + + const folderUid = generateUid(); + const folderKey = generateEncryptionKey(); + + const sharedScope = + folderType === FolderKind.SharedFolderFolder ? parent.sharedScopeUid : null; + + const encryptionKey = await getEncryptionKeyForNewFolder( + auth, + storage, + folderType, + sharedScope, + ); + + const folderData: Record = { name, title: name }; + const color = input.color?.trim().toLowerCase(); + if (color && color !== "none") { + folderData.color = color; + } + + const request: FolderAddRequest = { + folder_uid: folderUid, + folder_type: folderType, + key: await encryptForStorage(folderKey, encryptionKey), + data: await encryptObjectForStorage(folderData, folderKey), + link: false, + }; + + if (parentUid) { + request.parent_uid = parentUid; + } + if (folderType === FolderKind.SharedFolderFolder && sharedScope) { + request.shared_folder_uid = sharedScope; + } + + if (folderType === FolderKind.SharedFolder) { + request.name = await encryptForStorage( + platform.stringToBytes(name), + folderKey, + ); + request.manage_users = isBoolean(input.manageUsers) + ? input.manageUsers + : false; + request.manage_records = isBoolean(input.manageRecords) + ? input.manageRecords + : false; + request.can_edit = isBoolean(input.canEdit) ? input.canEdit : false; + request.can_share = isBoolean(input.canShare) ? input.canShare : false; + } + + try { + const response = await auth.executeRestCommand(folderAddCommand(request)); + const succeeded = + response.result === FolderResultStatus.Success || + response.result_code === FolderResultStatus.Success; + if (!succeeded) { + const reason = + response.message || + response.result_code || + `folder_add failed for "${name}" (uid=${folderUid}, type=${folderType}): server returned no message or result_code`; + return { + folderUid, + success: false, + message: reason, + }; } - try { - const response = await auth.executeRestCommand(folderAddCommand(request)) - const succeeded = - response.result === FolderResultStatus.Success || response.result_code === FolderResultStatus.Success - if (!succeeded) { - const reason = - response.message || - response.result_code || - `folder_add failed for "${name}" (uid=${folderUid}, type=${folderType}): server returned no message or result_code` - return { - folderUid, - success: false, - message: reason, - } - } - return { folderUid, success: true } - } catch (err) { - return { - folderUid, - success: false, - message: `folder_add failed for "${name}" (uid=${folderUid}, type=${folderType}): ${extractErrorMessage(err)}`, - } - } + return { folderUid, success: true }; + } catch (err) { + return { + folderUid, + success: false, + message: `folder_add failed for "${name}" (uid=${folderUid}, type=${folderType}): ${extractErrorMessage(err)}`, + }; + } } export async function mkdir( - auth: Auth, - storage: InMemoryStorage, - session: VaultFolderSession, - path: string, - options: MkdirOptions = {} + auth: Auth, + storage: InMemoryStorage, + session: VaultFolderSession, + path: string, + options: MkdirOptions = {}, ): Promise<{ folderUid: string; success: boolean; message?: string }> { - const trimmed = path.trim() - if (!trimmed) { - throw new KeeperSdkError('Folder path cannot be empty.', 'folder_path_required') + const trimmed = path.trim(); + if (!trimmed) { + throw new KeeperSdkError( + "Folder path cannot be empty.", + "folder_path_required", + ); + } + + if (options.sharedFolder && options.userFolder) { + throw new KeeperSdkError( + "Use only one of sharedFolder (-sf) or userFolder (-uf).", + "mkdir_flags_conflict", + ); + } + + const { folderUid: baseUid, remaining } = await tryResolvePath( + storage, + session, + trimmed, + ); + if (!remaining.trim()) { + throw new KeeperSdkError( + `Folder "${trimmed}" already exists.`, + "folder_already_exists", + ); + } + + const grantAll = options.grantAll === true; + let manageUsers = isBoolean(options.manageUsers) + ? options.manageUsers + : false; + let manageRecords = isBoolean(options.manageRecords) + ? options.manageRecords + : false; + let canShare = isBoolean(options.canShare) ? options.canShare : false; + let canEdit = isBoolean(options.canEdit) ? options.canEdit : false; + if (grantAll) { + manageUsers = true; + manageRecords = true; + canShare = true; + canEdit = true; + } + + const segments = splitPathComponents(remaining) + .map((segment) => segment.trim()) + .filter((segment) => segment !== "" && segment !== "."); + + if (segments.length === 0) { + throw new KeeperSdkError( + `Folder "${trimmed}" already exists.`, + "folder_already_exists", + ); + } + + let currentParent: string | null = baseUid; + let lastResult: AddFolderResult = { + folderUid: "", + success: false, + message: "not run", + }; + + for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { + const segment = segments[segmentIndex]; + const isLastSegment = segmentIndex === segments.length - 1; + const existingChildUid = await findChildFolderUidByName( + storage, + currentParent, + segment, + ); + if (existingChildUid) { + if (isLastSegment) { + throw new KeeperSdkError( + `Folder "${segment}" already exists.`, + "folder_already_exists", + ); + } + currentParent = existingChildUid; + continue; } - if (options.sharedFolder && options.userFolder) { - throw new KeeperSdkError('Use only one of sharedFolder (-sf) or userFolder (-uf).', 'mkdir_flags_conflict') + const createAsSharedFolder = isLastSegment && options.sharedFolder === true; + + const parentContext = resolveParentContext(storage, currentParent); + if ( + createAsSharedFolder && + parentContext.kind !== ParentFolderKind.UserFolder && + parentContext.kind !== ParentFolderKind.VirtualRoot + ) { + throw new KeeperSdkError( + "Shared folders can only be created under a personal folder.", + "shared_folder_invalid_parent", + ); } - const { folderUid: baseUid, remaining } = await tryResolvePath(storage, session, trimmed) - if (!remaining.trim()) { - throw new KeeperSdkError(`Folder "${trimmed}" already exists.`, 'folder_already_exists') - } - - const grantAll = options.grantAll === true - let manageUsers = isBoolean(options.manageUsers) ? options.manageUsers : false - let manageRecords = isBoolean(options.manageRecords) ? options.manageRecords : false - let canShare = isBoolean(options.canShare) ? options.canShare : false - let canEdit = isBoolean(options.canEdit) ? options.canEdit : false - if (grantAll) { - manageUsers = true - manageRecords = true - canShare = true - canEdit = true - } - - const segments = splitPathComponents(remaining) - .map((segment) => segment.trim()) - .filter((segment) => segment !== '' && segment !== '.') - - if (segments.length === 0) { - throw new KeeperSdkError(`Folder "${trimmed}" already exists.`, 'folder_already_exists') - } - - let currentParent: string | null = baseUid - let lastResult: AddFolderResult = { - folderUid: '', + lastResult = await addFolder(auth, storage, { + folderName: segment, + parentUid: currentParent, + isSharedFolder: createAsSharedFolder, + manageUsers: + isLastSegment && createAsSharedFolder ? manageUsers : undefined, + manageRecords: + isLastSegment && createAsSharedFolder ? manageRecords : undefined, + canShare: isLastSegment && createAsSharedFolder ? canShare : undefined, + canEdit: isLastSegment && createAsSharedFolder ? canEdit : undefined, + color: isLastSegment ? options.color : undefined, + }); + + if (!lastResult.success) { + return { + folderUid: lastResult.folderUid, success: false, - message: 'not run', - } - - for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { - const segment = segments[segmentIndex] - const isLastSegment = segmentIndex === segments.length - 1 - const existingChildUid = await findChildFolderUidByName(storage, currentParent, segment) - if (existingChildUid) { - if (isLastSegment) { - throw new KeeperSdkError(`Folder "${segment}" already exists.`, 'folder_already_exists') - } - currentParent = existingChildUid - continue - } - - const createAsSharedFolder = isLastSegment && options.sharedFolder === true - - const parentContext = resolveParentContext(storage, currentParent) - if ( - createAsSharedFolder && - parentContext.kind !== ParentFolderKind.UserFolder && - parentContext.kind !== ParentFolderKind.VirtualRoot - ) { - throw new KeeperSdkError( - 'Shared folders can only be created under a personal folder.', - 'shared_folder_invalid_parent' - ) - } - - lastResult = await addFolder(auth, storage, { - folderName: segment, - parentUid: currentParent, - isSharedFolder: createAsSharedFolder, - manageUsers: isLastSegment && createAsSharedFolder ? manageUsers : undefined, - manageRecords: isLastSegment && createAsSharedFolder ? manageRecords : undefined, - canShare: isLastSegment && createAsSharedFolder ? canShare : undefined, - canEdit: isLastSegment && createAsSharedFolder ? canEdit : undefined, - }) - - if (!lastResult.success) { - return { - folderUid: lastResult.folderUid, - success: false, - message: lastResult.message, - } - } - currentParent = lastResult.folderUid + message: lastResult.message, + }; } + currentParent = lastResult.folderUid; + } - return { folderUid: lastResult.folderUid, success: true } + return { folderUid: lastResult.folderUid, success: true }; } diff --git a/KeeperSdk/src/folders/changeDirectory.ts b/KeeperSdk/src/folders/changeDirectory.ts index 7f8aeb96..d6055dd4 100644 --- a/KeeperSdk/src/folders/changeDirectory.ts +++ b/KeeperSdk/src/folders/changeDirectory.ts @@ -1,217 +1,297 @@ -import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError } from '../utils' -import { listFolder, listRootUserFolders } from './listFolder' -import type { ListFolderFolderSimple } from './listFolder' -import { FolderKind, VaultObjectKind, sharedFolderFolderName, sharedFolderName, userFolderName } from './folderHelpers' +import type { + DSharedFolder, + DSharedFolderFolder, + DUserFolder, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError } from "../utils"; +import { listFolder, listRootUserFolders } from "./listFolder"; +import type { ListFolderFolderSimple } from "./listFolder"; +import { + FolderKind, + VaultObjectKind, + sharedFolderFolderName, + sharedFolderName, + userFolderName, +} from "./folderHelpers"; -const VAULT_ROOT_DISPLAY_NAME = 'My Vault' +export const VAULT_ROOT_DISPLAY_NAME = "My Vault"; -const ESCAPED_SEPARATOR_PLACEHOLDER = '\x00' +const ESCAPED_SEPARATOR_PLACEHOLDER = "\x00"; export type VaultFolderSession = { - currentFolderUid: string | null -} + currentFolderUid: string | null; + /** Full display path e.g. `My Vault/Subfolder` (updated on cd). */ + workingFolderDisplayPath?: string; +}; export type ChangeDirectoryResult = { - folderUid: string | null - name: string -} + folderUid: string | null; + name: string; +}; export function createVaultFolderSession(): VaultFolderSession { - return { currentFolderUid: null } + return { currentFolderUid: null }; } -function getFolderEntryByUid(storage: InMemoryStorage, uid: string): ListFolderFolderSimple | undefined { - const userFolder = storage.getByUid(FolderKind.UserFolder, uid) - if (userFolder) { - return { uid: userFolder.uid, name: userFolderName(userFolder), folderKind: FolderKind.UserFolder } - } - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, uid) - if (sharedFolder) { - return { - uid: sharedFolder.uid, - name: sharedFolderName(sharedFolder), - folderKind: FolderKind.SharedFolder, - } - } - const sharedFolderFolder = storage.getByUid(FolderKind.SharedFolderFolder, uid) - if (sharedFolderFolder) { - return { - uid: sharedFolderFolder.uid, - name: sharedFolderFolderName(sharedFolderFolder), - folderKind: FolderKind.SharedFolderFolder, - } - } - return undefined +function getFolderEntryByUid( + storage: InMemoryStorage, + uid: string, +): ListFolderFolderSimple | undefined { + const userFolder = storage.getByUid(FolderKind.UserFolder, uid); + if (userFolder) { + return { + uid: userFolder.uid, + name: userFolderName(userFolder), + folderKind: FolderKind.UserFolder, + }; + } + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + uid, + ); + if (sharedFolder) { + return { + uid: sharedFolder.uid, + name: sharedFolderName(sharedFolder), + folderKind: FolderKind.SharedFolder, + }; + } + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + uid, + ); + if (sharedFolderFolder) { + return { + uid: sharedFolderFolder.uid, + name: sharedFolderFolderName(sharedFolderFolder), + folderKind: FolderKind.SharedFolderFolder, + }; + } + return undefined; } -export async function findParentFolderUid(storage: InMemoryStorage, folderUid: string): Promise { - const rootFolderUids = new Set((await listRootUserFolders(storage)).map((folder) => folder.uid)) - if (rootFolderUids.has(folderUid)) return null - - const parentKinds = [ - FolderKind.UserFolder, - FolderKind.SharedFolder, - FolderKind.SharedFolderFolder, - VaultObjectKind.Team, - ] as const - for (const kind of parentKinds) { - for (const candidate of storage.getAll(kind)) { - const candidateUid = (candidate as { uid: string }).uid - const dependencies = (await storage.getDependencies(candidateUid)) || [] - for (const dependency of dependencies) { - if (dependency.uid !== folderUid) continue - if ( - dependency.kind === FolderKind.UserFolder || - dependency.kind === FolderKind.SharedFolder || - dependency.kind === FolderKind.SharedFolderFolder - ) { - return candidateUid - } - } +export async function findParentFolderUid( + storage: InMemoryStorage, + folderUid: string, +): Promise { + const rootFolderUids = new Set( + (await listRootUserFolders(storage)).map((folder) => folder.uid), + ); + if (rootFolderUids.has(folderUid)) return null; + + const parentKinds = [ + FolderKind.UserFolder, + FolderKind.SharedFolder, + FolderKind.SharedFolderFolder, + VaultObjectKind.Team, + ] as const; + for (const kind of parentKinds) { + for (const candidate of storage.getAll(kind)) { + const candidateUid = (candidate as { uid: string }).uid; + const dependencies = (await storage.getDependencies(candidateUid)) || []; + for (const dependency of dependencies) { + if (dependency.uid !== folderUid) continue; + if ( + dependency.kind === FolderKind.UserFolder || + dependency.kind === FolderKind.SharedFolder || + dependency.kind === FolderKind.SharedFolderFolder + ) { + return candidateUid; } + } } + } - return null + return null; } async function listFolderChildrenForCd( - storage: InMemoryStorage, - parentUid: string | null + storage: InMemoryStorage, + parentUid: string | null, ): Promise { - const result = await listFolder(storage, { - folderUid: parentUid === null ? undefined : parentUid, - showFolders: true, - showRecords: false, - }) - return result.folders + const result = await listFolder(storage, { + folderUid: parentUid === null ? undefined : parentUid, + showFolders: true, + showRecords: false, + }); + return result.folders; } function findChildFolder( - children: ListFolderFolderSimple[], - component: string + children: ListFolderFolderSimple[], + component: string, ): ListFolderFolderSimple | undefined { - const trimmedComponent = component.trim() - if (!trimmedComponent) return undefined - const matchByUid = children.find((child) => child.uid === trimmedComponent) - if (matchByUid) return matchByUid - const exactNameMatch = children.find((child) => child.name.trim() === trimmedComponent) - if (exactNameMatch) return exactNameMatch - const lowerComponent = trimmedComponent.toLowerCase() - return children.find((child) => child.name.trim().toLowerCase() === lowerComponent) + const trimmedComponent = component.trim(); + if (!trimmedComponent) return undefined; + const matchByUid = children.find((child) => child.uid === trimmedComponent); + if (matchByUid) return matchByUid; + const exactNameMatch = children.find( + (child) => child.name.trim() === trimmedComponent, + ); + if (exactNameMatch) return exactNameMatch; + const lowerComponent = trimmedComponent.toLowerCase(); + return children.find( + (child) => child.name.trim().toLowerCase() === lowerComponent, + ); } export function splitPathComponents(path: string): string[] { - const escaped = path.replace(/\/\//g, ESCAPED_SEPARATOR_PLACEHOLDER) - return escaped.split('/').map((segment) => segment.replace(/\x00/g, '/')) + const escaped = path.replace(/\/\//g, ESCAPED_SEPARATOR_PLACEHOLDER); + return escaped.split("/").map((segment) => segment.replace(/\x00/g, "/")); } export type TryResolvePathResult = { - folderUid: string | null - remaining: string -} + folderUid: string | null; + remaining: string; +}; export async function tryResolvePath( - storage: InMemoryStorage, - session: VaultFolderSession, - path: string + storage: InMemoryStorage, + session: VaultFolderSession, + path: string, ): Promise { - const trimmed = path.trim() - if (!trimmed) { - return { folderUid: session.currentFolderUid, remaining: '' } - } + const trimmed = path.trim(); + if (!trimmed) { + return { folderUid: session.currentFolderUid, remaining: "" }; + } - const direct = getFolderEntryByUid(storage, trimmed) - if (direct) { - return { folderUid: direct.uid, remaining: '' } - } + const direct = getFolderEntryByUid(storage, trimmed); + if (direct) { + return { folderUid: direct.uid, remaining: "" }; + } - const absolute = trimmed.startsWith('/') && !trimmed.startsWith('//') - const pathToWalk = absolute ? trimmed.slice(1) : trimmed - const components = splitPathComponents(pathToWalk) + const absolute = trimmed.startsWith("/") && !trimmed.startsWith("//"); + const pathToWalk = absolute ? trimmed.slice(1) : trimmed; + const components = splitPathComponents(pathToWalk); - let folderUid: string | null = absolute ? null : session.currentFolderUid || null + let folderUid: string | null = absolute + ? null + : session.currentFolderUid || null; - if (!absolute && folderUid !== null) { - if (!getFolderEntryByUid(storage, folderUid)) { - folderUid = null - } + if (!absolute && folderUid !== null) { + if (!getFolderEntryByUid(storage, folderUid)) { + folderUid = null; } + } - let componentIndex = 0 - while (componentIndex < components.length) { - const component = components[componentIndex] - componentIndex++ + let componentIndex = 0; + while (componentIndex < components.length) { + const component = components[componentIndex]; + componentIndex++; - if (component === '' || component === '.') { - continue - } + if (component === "" || component === ".") { + continue; + } - if (component === '..') { - if (folderUid === null) { - continue - } - folderUid = await findParentFolderUid(storage, folderUid) - continue - } + if (component === "..") { + if (folderUid === null) { + continue; + } + folderUid = await findParentFolderUid(storage, folderUid); + continue; + } - const children = await listFolderChildrenForCd(storage, folderUid) - const match = findChildFolder(children, component) - if (match) { - folderUid = match.uid - } else { - const remaining = [component, ...components.slice(componentIndex)].join('/') - return { folderUid, remaining } - } + const children = await listFolderChildrenForCd(storage, folderUid); + const match = findChildFolder(children, component); + if (match) { + folderUid = match.uid; + } else { + const remaining = [component, ...components.slice(componentIndex)].join( + "/", + ); + return { folderUid, remaining }; } + } - return { folderUid, remaining: '' } + return { folderUid, remaining: "" }; } export async function resolveSingleFolder( - storage: InMemoryStorage, - session: VaultFolderSession, - folderName: string + storage: InMemoryStorage, + session: VaultFolderSession, + folderName: string, ): Promise { - const trimmed = folderName.trim() - if (!trimmed) { - throw new KeeperSdkError('Folder cannot be empty.', 'folder_required') - } + const trimmed = folderName.trim(); + if (!trimmed) { + throw new KeeperSdkError("Folder cannot be empty.", "folder_required"); + } - const direct = getFolderEntryByUid(storage, trimmed) - if (direct) { - return { folderUid: direct.uid, name: direct.name } - } + const direct = getFolderEntryByUid(storage, trimmed); + if (direct) { + return { folderUid: direct.uid, name: direct.name }; + } - const { folderUid, remaining } = await tryResolvePath(storage, session, trimmed) - if (remaining) { - throw new KeeperSdkError(`Folder "${folderName}" not found`, 'folder_not_found') - } + const { folderUid, remaining } = await tryResolvePath( + storage, + session, + trimmed, + ); + if (remaining) { + throw new KeeperSdkError( + `Folder "${folderName}" not found`, + "folder_not_found", + ); + } - if (folderUid === null) { - return { folderUid: null, name: VAULT_ROOT_DISPLAY_NAME } - } + if (folderUid === null) { + return { folderUid: null, name: VAULT_ROOT_DISPLAY_NAME }; + } - const entry = getFolderEntryByUid(storage, folderUid) - if (!entry) { - throw new KeeperSdkError(`Folder "${folderName}" not found`, 'folder_not_found') - } - return { folderUid: entry.uid, name: entry.name } + const entry = getFolderEntryByUid(storage, folderUid); + if (!entry) { + throw new KeeperSdkError( + `Folder "${folderName}" not found`, + "folder_not_found", + ); + } + return { folderUid: entry.uid, name: entry.name }; +} + +export async function buildWorkingFolderDisplayPath( + storage: InMemoryStorage, + folderUid: string | null, +): Promise { + if (folderUid === null) return VAULT_ROOT_DISPLAY_NAME; + + const segments: string[] = []; + let current: string | null = folderUid; + while (current) { + const entry = getFolderEntryByUid(storage, current); + if (!entry) break; + segments.unshift(entry.name); + current = await findParentFolderUid(storage, current); + } + + if (segments.length === 0) return VAULT_ROOT_DISPLAY_NAME; + return `${VAULT_ROOT_DISPLAY_NAME}/${segments.join("/")}`; } export async function changeDirectory( - storage: InMemoryStorage, - session: VaultFolderSession, - path: string + storage: InMemoryStorage, + session: VaultFolderSession, + path: string, ): Promise { - const resolved = await resolveSingleFolder(storage, session, path) - session.currentFolderUid = resolved.folderUid - return resolved + const resolved = await resolveSingleFolder(storage, session, path); + session.currentFolderUid = resolved.folderUid; + session.workingFolderDisplayPath = await buildWorkingFolderDisplayPath( + storage, + resolved.folderUid, + ); + return { + folderUid: resolved.folderUid, + name: session.workingFolderDisplayPath, + }; } -export function getWorkingFolderDisplayName(storage: InMemoryStorage, currentFolderUid: string | null): string { - if (currentFolderUid === null) return VAULT_ROOT_DISPLAY_NAME - const entry = getFolderEntryByUid(storage, currentFolderUid) - return entry?.name || VAULT_ROOT_DISPLAY_NAME +export function getWorkingFolderDisplayName( + storage: InMemoryStorage, + session: VaultFolderSession, +): string { + if (session.workingFolderDisplayPath) return session.workingFolderDisplayPath; + if (session.currentFolderUid === null) return VAULT_ROOT_DISPLAY_NAME; + const entry = getFolderEntryByUid(storage, session.currentFolderUid); + return entry?.name + ? `${VAULT_ROOT_DISPLAY_NAME}/${entry.name}` + : VAULT_ROOT_DISPLAY_NAME; } diff --git a/KeeperSdk/src/folders/deleteFolder.ts b/KeeperSdk/src/folders/deleteFolder.ts index 93a512fe..1072cc83 100644 --- a/KeeperSdk/src/folders/deleteFolder.ts +++ b/KeeperSdk/src/folders/deleteFolder.ts @@ -1,293 +1,409 @@ -import type { Auth, DeleteObject, KeeperPreDeleteResponse } from '@keeper-security/keeperapi' -import { preDeleteCommand, recordDeleteCommand } from '@keeper-security/keeperapi' -import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, extractErrorMessage, logger } from '../utils' -import { listFolder } from './listFolder' -import type { ListFolderFolderSimple } from './listFolder' -import { tryResolvePath, findParentFolderUid, type VaultFolderSession } from './changeDirectory' +import type { + Auth, + DeleteObject, + KeeperPreDeleteResponse, +} from "@keeper-security/keeperapi"; import { - DeleteResolution, - FolderKind, - globToRegex, - sharedFolderFolderName, - sharedFolderName, - userFolderName, -} from './folderHelpers' -import { findFolder } from './getFolder' + preDeleteCommand, + recordDeleteCommand, +} from "@keeper-security/keeperapi"; +import type { + DSharedFolder, + DSharedFolderFolder, + DUserFolder, +} from "@keeper-security/keeperapi"; +import { getSdkPlatform } from "../platform"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, extractErrorMessage, logger } from "../utils"; +import { listFolder } from "./listFolder"; +import type { ListFolderFolderSimple } from "./listFolder"; +import { + tryResolvePath, + findParentFolderUid, + type VaultFolderSession, +} from "./changeDirectory"; +import { + DeleteResolution, + FolderKind, + globToRegex, + sharedFolderFolderName, + sharedFolderName, + userFolderName, +} from "./folderHelpers"; +import { findFolder } from "./getFolder"; export type DeleteFolderResult = { - success: boolean - message?: string - cancelled?: boolean -} + success: boolean; + message?: string; + cancelled?: boolean; + /** Folder list shown before delete (for CLI output). */ + foldersPreview?: string; +}; export type RmdirOptions = { - force?: boolean - quiet?: boolean - confirm?: (summary: string) => boolean | Promise + force?: boolean; + quiet?: boolean; + confirm?: (summary: string) => boolean | Promise; + /** Override prompt (e.g. browser shell UI). Defaults to SdkPlatform readline. */ + ask?: (prompt: string) => Promise; +}; + +function isYesAnswer(answer: string): boolean { + const normalized = answer.trim().toLowerCase(); + return normalized === "y" || normalized === "yes"; +} + +async function defaultAsk(prompt: string): Promise { + const rl = getSdkPlatform().createReadline(); + try { + return await rl.question(prompt); + } finally { + rl.close(); + } +} + +function resolveRmdirConfirm( + options: RmdirOptions, +): ((summary: string) => Promise) | undefined { + if (options.force) return undefined; + if (options.confirm) { + return async (summary) => Promise.resolve(options.confirm!(summary)); + } + const ask = options.ask ?? defaultAsk; + return async (summary) => { + logger.info(`\n${summary}\n`); + const answer = await ask("Do you want to proceed? (y/n) "); + return isYesAnswer(answer); + }; } function folderKindOfUid(storage: InMemoryStorage, uid: string): FolderKind { - if (storage.getByUid(FolderKind.UserFolder, uid)) return FolderKind.UserFolder - if (storage.getByUid(FolderKind.SharedFolder, uid)) return FolderKind.SharedFolder - if (storage.getByUid(FolderKind.SharedFolderFolder, uid)) return FolderKind.SharedFolderFolder - return FolderKind.UserFolder + if (storage.getByUid(FolderKind.UserFolder, uid)) + return FolderKind.UserFolder; + if (storage.getByUid(FolderKind.SharedFolder, uid)) + return FolderKind.SharedFolder; + if (storage.getByUid(FolderKind.SharedFolderFolder, uid)) + return FolderKind.SharedFolderFolder; + return FolderKind.UserFolder; } async function listFolderChildrenFolders( - storage: InMemoryStorage, - parentUid: string | null + storage: InMemoryStorage, + parentUid: string | null, ): Promise { - const result = await listFolder(storage, { - folderUid: parentUid === null ? undefined : parentUid, - showFolders: true, - showRecords: false, - }) - return result.folders + const result = await listFolder(storage, { + folderUid: parentUid === null ? undefined : parentUid, + showFolders: true, + showRecords: false, + }); + return result.folders; } function folderDisplayName(storage: InMemoryStorage, uid: string): string { - const userFolder = storage.getByUid(FolderKind.UserFolder, uid) - if (userFolder) return userFolderName(userFolder) - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, uid) - if (sharedFolder) return sharedFolderName(sharedFolder) - const sharedFolderFolder = storage.getByUid(FolderKind.SharedFolderFolder, uid) - if (sharedFolderFolder) return sharedFolderFolderName(sharedFolderFolder) - return uid + const userFolder = storage.getByUid(FolderKind.UserFolder, uid); + if (userFolder) return userFolderName(userFolder); + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + uid, + ); + if (sharedFolder) return sharedFolderName(sharedFolder); + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + uid, + ); + if (sharedFolderFolder) return sharedFolderFolderName(sharedFolderFolder); + return uid; } export async function buildFolderDeleteObject( - storage: InMemoryStorage, - folderUid: string + storage: InMemoryStorage, + folderUid: string, ): Promise { - const userFolder = storage.getByUid(FolderKind.UserFolder, folderUid) - if (userFolder) { - const parentUid = await findParentFolderUid(storage, folderUid) - const deleteObject: DeleteObject = { - delete_resolution: DeleteResolution.Unlink, - object_uid: folderUid, - object_type: FolderKind.UserFolder, - from_type: FolderKind.UserFolder, - } - if (parentUid) { - deleteObject.from_uid = parentUid - deleteObject.from_type = folderKindOfUid(storage, parentUid) - } else { - deleteObject.from_uid = '' - } - return deleteObject + const userFolder = storage.getByUid( + FolderKind.UserFolder, + folderUid, + ); + if (userFolder) { + const parentUid = await findParentFolderUid(storage, folderUid); + const deleteObject: DeleteObject = { + delete_resolution: DeleteResolution.Unlink, + object_uid: folderUid, + object_type: FolderKind.UserFolder, + from_type: FolderKind.UserFolder, + }; + if (parentUid) { + deleteObject.from_uid = parentUid; + deleteObject.from_type = folderKindOfUid(storage, parentUid); + } else { + deleteObject.from_uid = ""; } - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, folderUid) - if (sharedFolder) { - const parentUid = await findParentFolderUid(storage, folderUid) - const deleteObject: DeleteObject = { - delete_resolution: DeleteResolution.Unlink, - object_uid: folderUid, - object_type: FolderKind.SharedFolder, - from_type: FolderKind.UserFolder, - } - if (parentUid) { - deleteObject.from_uid = parentUid - deleteObject.from_type = folderKindOfUid(storage, parentUid) - } else { - deleteObject.from_uid = '' - } - return deleteObject + return deleteObject; + } + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + folderUid, + ); + if (sharedFolder) { + const parentUid = await findParentFolderUid(storage, folderUid); + const deleteObject: DeleteObject = { + delete_resolution: DeleteResolution.Unlink, + object_uid: folderUid, + object_type: FolderKind.SharedFolder, + from_type: FolderKind.UserFolder, + }; + if (parentUid) { + deleteObject.from_uid = parentUid; + deleteObject.from_type = folderKindOfUid(storage, parentUid); + } else { + deleteObject.from_uid = ""; } - const sharedFolderFolder = storage.getByUid(FolderKind.SharedFolderFolder, folderUid) - if (sharedFolderFolder) { - const parentUid = await findParentFolderUid(storage, folderUid) - const deleteObject: DeleteObject = { - delete_resolution: DeleteResolution.Unlink, - object_uid: folderUid, - object_type: FolderKind.SharedFolderFolder, - from_type: FolderKind.SharedFolder, - } - if (parentUid) { - deleteObject.from_uid = parentUid - deleteObject.from_type = folderKindOfUid(storage, parentUid) - } else { - deleteObject.from_uid = sharedFolderFolder.sharedFolderUid - deleteObject.from_type = FolderKind.SharedFolder - } - return deleteObject + return deleteObject; + } + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + folderUid, + ); + if (sharedFolderFolder) { + const parentUid = await findParentFolderUid(storage, folderUid); + const deleteObject: DeleteObject = { + delete_resolution: DeleteResolution.Unlink, + object_uid: folderUid, + object_type: FolderKind.SharedFolderFolder, + from_type: FolderKind.SharedFolder, + }; + if (parentUid) { + deleteObject.from_uid = parentUid; + deleteObject.from_type = folderKindOfUid(storage, parentUid); + } else { + deleteObject.from_uid = sharedFolderFolder.sharedFolderUid; + deleteObject.from_type = FolderKind.SharedFolder; } - return null + return deleteObject; + } + return null; } async function buildDeleteObjectForFolderRef( - storage: InMemoryStorage, - ref: string + storage: InMemoryStorage, + ref: string, ): Promise { - const trimmed = ref.trim() - if (!trimmed) return null + const trimmed = ref.trim(); + if (!trimmed) return null; - const direct = await buildFolderDeleteObject(storage, trimmed) - if (direct) return direct + const direct = await buildFolderDeleteObject(storage, trimmed); + if (direct) return direct; - const found = findFolder(storage, trimmed) - if (!found) return null - return buildFolderDeleteObject(storage, found.folder.uid) + const found = findFolder(storage, trimmed); + if (!found) return null; + return buildFolderDeleteObject(storage, found.folder.uid); } export async function deleteFolder( - auth: Auth, - storage: InMemoryStorage, - folderRefs: string[], - confirm?: (summary: string) => boolean | Promise + auth: Auth, + storage: InMemoryStorage, + folderRefs: string[], + confirm?: (summary: string) => boolean | Promise, ): Promise { - const objects: DeleteObject[] = [] - for (const ref of folderRefs) { - const deleteObject = await buildDeleteObjectForFolderRef(storage, ref) - if (deleteObject) objects.push(deleteObject) - } - if (objects.length === 0) { - throw new KeeperSdkError( - 'No folders found to delete (not a folder UID or name).', - 'delete_nothing' - ) - } + const objects: DeleteObject[] = []; + for (const ref of folderRefs) { + const deleteObject = await buildDeleteObjectForFolderRef(storage, ref); + if (deleteObject) objects.push(deleteObject); + } + if (objects.length === 0) { + throw new KeeperSdkError( + "No folders found to delete (not a folder UID or name).", + "delete_nothing", + ); + } + + const targetUids = objects + .map((deleteObject) => deleteObject.object_uid) + .join(", "); - const targetUids = objects.map((deleteObject) => deleteObject.object_uid).join(', ') + let preResp: KeeperPreDeleteResponse; + try { + preResp = await auth.executeRestCommand(preDeleteCommand({ objects })); + } catch (err) { + return { + success: false, + message: `pre_delete failed for [${targetUids}]: ${extractErrorMessage(err)}`, + }; + } - let preResp: KeeperPreDeleteResponse + const inner = preResp.pre_delete_response; + let deleteToken = inner?.pre_delete_token; + if (!deleteToken) { + const reason = + preResp.message || + preResp.result_code || + `pre_delete failed for [${targetUids}]: server did not return a pre_delete_token`; + return { + success: false, + message: reason, + }; + } + + if (confirm) { + const wouldDelete = inner?.would_delete; + const summaryItems = wouldDelete?.deletion_summary; + const summary = Array.isArray(summaryItems) ? summaryItems.join("\n") : ""; + const confirmed = await confirm(summary); + if (!confirmed) { + return { success: false, cancelled: true, message: "Cancelled." }; + } + + // Interactive confirmation can outlive the pre_delete token; fetch a fresh one. try { - preResp = await auth.executeRestCommand(preDeleteCommand({ objects })) + preResp = await auth.executeRestCommand(preDeleteCommand({ objects })); } catch (err) { - return { - success: false, - message: `pre_delete failed for [${targetUids}]: ${extractErrorMessage(err)}`, - } + return { + success: false, + message: `pre_delete refresh failed for [${targetUids}]: ${extractErrorMessage(err)}`, + }; } - - const inner = preResp.pre_delete_response - const token = inner?.pre_delete_token - if (!token) { - const reason = - preResp.message || - preResp.result_code || - `pre_delete failed for [${targetUids}]: server did not return a pre_delete_token` - return { - success: false, - message: reason, - } + deleteToken = preResp.pre_delete_response?.pre_delete_token; + if (!deleteToken) { + const reason = + preResp.message || + preResp.result_code || + `pre_delete refresh failed for [${targetUids}]: server did not return a pre_delete_token`; + return { + success: false, + message: reason, + }; } + } - if (confirm) { - const wouldDelete = inner?.would_delete - const summaryItems = wouldDelete?.deletion_summary - if (Array.isArray(summaryItems) && summaryItems.length > 0) { - const summary = summaryItems.join('\n') - const confirmed = await confirm(summary) - if (!confirmed) { - return { success: false, cancelled: true, message: 'Cancelled.' } - } - } - } + try { + await auth.executeRestCommand( + recordDeleteCommand({ pre_delete_token: deleteToken }), + ); + } catch (err) { + return { + success: false, + message: `record_delete failed for [${targetUids}]: ${extractErrorMessage(err)}`, + }; + } + for (const deleteObject of objects) { try { - await auth.executeRestCommand(recordDeleteCommand({ pre_delete_token: token })) + await storage.delete(deleteObject.object_type, deleteObject.object_uid); } catch (err) { - return { - success: false, - message: `record_delete failed for [${targetUids}]: ${extractErrorMessage(err)}`, - } + logger.debug( + `Failed to purge ${deleteObject.object_type} ${deleteObject.object_uid}:`, + extractErrorMessage(err), + ); } + } - return { success: true } + return { success: true }; } export async function resolveRmdirPatternsToFolderUids( - storage: InMemoryStorage, - session: VaultFolderSession, - pattern: string + storage: InMemoryStorage, + session: VaultFolderSession, + pattern: string, ): Promise> { - const matchedUids = new Set() - const trimmedPattern = pattern.trim() - if (!trimmedPattern) return matchedUids - - const { folderUid: baseUid, remaining } = await tryResolvePath(storage, session, trimmedPattern) - const remainingPattern = remaining.trim() - - if (!remainingPattern) { - if (baseUid !== null) { - matchedUids.add(baseUid) - } - return matchedUids - } + const matchedUids = new Set(); + const trimmedPattern = pattern.trim(); + if (!trimmedPattern) return matchedUids; - const children = await listFolderChildrenFolders(storage, baseUid) - const exactChild = children.find((child) => child.uid === remainingPattern) - if (exactChild) { - matchedUids.add(exactChild.uid) - return matchedUids + const { folderUid: baseUid, remaining } = await tryResolvePath( + storage, + session, + trimmedPattern, + ); + const remainingPattern = remaining.trim(); + + if (!remainingPattern) { + if (baseUid !== null) { + matchedUids.add(baseUid); } + return matchedUids; + } + + const children = await listFolderChildrenFolders(storage, baseUid); + const exactChild = children.find((child) => child.uid === remainingPattern); + if (exactChild) { + matchedUids.add(exactChild.uid); + return matchedUids; + } - const matcher = globToRegex(remainingPattern) - for (const child of children) { - if (matcher.test(child.name.trim())) { - matchedUids.add(child.uid) - } + const matcher = globToRegex(remainingPattern); + for (const child of children) { + if (matcher.test(child.name.trim())) { + matchedUids.add(child.uid); } - return matchedUids + } + return matchedUids; } -async function dedupeNestedFolderDeletes(storage: InMemoryStorage, folderUids: Set): Promise { - for (const folderUid of [...folderUids]) { - let current = folderUid - while (true) { - const parentUid = await findParentFolderUid(storage, current) - if (!parentUid) break - if (folderUids.has(parentUid)) { - folderUids.delete(folderUid) - break - } - current = parentUid - } +async function dedupeNestedFolderDeletes( + storage: InMemoryStorage, + folderUids: Set, +): Promise { + for (const folderUid of [...folderUids]) { + let current = folderUid; + while (true) { + const parentUid = await findParentFolderUid(storage, current); + if (!parentUid) break; + if (folderUids.has(parentUid)) { + folderUids.delete(folderUid); + break; + } + current = parentUid; } + } } export async function rmdir( - auth: Auth, - storage: InMemoryStorage, - session: VaultFolderSession, - patterns: string[], - options: RmdirOptions = {} + auth: Auth, + storage: InMemoryStorage, + session: VaultFolderSession, + patterns: string[], + options: RmdirOptions = {}, ): Promise { - const { force = false, quiet = false, confirm } = options - if (!force && !confirm) { - throw new KeeperSdkError( - 'Confirmation is required: pass `confirm` or set `force: true`.', - 'rmdir_confirm_required' - ) - } - const folderUids = new Set() - - for (const pattern of patterns) { - const trimmedPattern = pattern.trim() - if (!trimmedPattern) continue - const resolved = await resolveRmdirPatternsToFolderUids(storage, session, trimmedPattern) - for (const matchedUid of resolved) { - folderUids.add(matchedUid) - } - } + const { force = false, quiet = false } = options; + const folderUids = new Set(); - if (folderUids.size === 0) { - throw new KeeperSdkError('Enter name of an existing folder.', 'rmdir_no_match') + for (const pattern of patterns) { + const trimmedPattern = pattern.trim(); + if (!trimmedPattern) continue; + const resolved = await resolveRmdirPatternsToFolderUids( + storage, + session, + trimmedPattern, + ); + for (const matchedUid of resolved) { + folderUids.add(matchedUid); } + } - await dedupeNestedFolderDeletes(storage, folderUids) + if (folderUids.size === 0) { + throw new KeeperSdkError( + "Enter name of an existing folder.", + "rmdir_no_match", + ); + } - const sortedNames = [...folderUids] - .map((uid) => folderDisplayName(storage, uid)) - .sort((nameA, nameB) => nameA.localeCompare(nameB, undefined, { sensitivity: 'base' })) + await dedupeNestedFolderDeletes(storage, folderUids); - if (!quiet || !force) { - logger.info(`\nThe following folder(s) will be removed:\n${sortedNames.join(', ')}\n`) - } + const sortedNames = [...folderUids] + .map((uid) => folderDisplayName(storage, uid)) + .sort((nameA, nameB) => + nameA.localeCompare(nameB, undefined, { sensitivity: "base" }), + ); - const confirmFn = force ? undefined : confirm + const foldersPreview = `\nThe following folder(s) will be removed:\n${sortedNames.join(", ")}\n`; + if (!quiet || !force) { + logger.info(foldersPreview); + } - return deleteFolder(auth, storage, [...folderUids], confirmFn) + const result = await deleteFolder( + auth, + storage, + [...folderUids], + resolveRmdirConfirm(options), + ); + return { ...result, foldersPreview }; } diff --git a/KeeperSdk/src/folders/folderHelpers.ts b/KeeperSdk/src/folders/folderHelpers.ts index 618ad342..e1321b48 100644 --- a/KeeperSdk/src/folders/folderHelpers.ts +++ b/KeeperSdk/src/folders/folderHelpers.ts @@ -1,107 +1,129 @@ -import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { escapeRegExp, KeeperSdkError } from '../utils' +import type { + DSharedFolder, + DSharedFolderFolder, + DUserFolder, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { escapeRegExp, KeeperSdkError } from "../utils"; -export const MAX_FOLDER_NAME_LENGTH = 255 +export const MAX_FOLDER_NAME_LENGTH = 255; export function validateFolderName(name: string): void { - if (name.length > MAX_FOLDER_NAME_LENGTH) { - throw new KeeperSdkError(`Folder name exceeds ${MAX_FOLDER_NAME_LENGTH} characters.`, 'folder_name_too_long') - } + if (name.length > MAX_FOLDER_NAME_LENGTH) { + throw new KeeperSdkError( + `Folder name exceeds ${MAX_FOLDER_NAME_LENGTH} characters.`, + "folder_name_too_long", + ); + } } export enum FolderKind { - UserFolder = 'user_folder', - SharedFolder = 'shared_folder', - SharedFolderFolder = 'shared_folder_folder', + UserFolder = "user_folder", + SharedFolder = "shared_folder", + SharedFolderFolder = "shared_folder_folder", } export enum ParentFolderKind { - VirtualRoot = 'virtual_root', - UserFolder = 'user_folder', - SharedFolder = 'shared_folder', - SharedFolderFolder = 'shared_folder_folder', + VirtualRoot = "virtual_root", + UserFolder = "user_folder", + SharedFolder = "shared_folder", + SharedFolderFolder = "shared_folder_folder", } export enum FolderObjectType { - Folder = 'folder', - SharedFolder = 'shared_folder', + Folder = "folder", + SharedFolder = "shared_folder", } export enum DeleteResolution { - Unlink = 'unlink', + Unlink = "unlink", } export enum DeleteObjectType { - Record = 'record', - UserFolder = 'user_folder', - SharedFolder = 'shared_folder', - SharedFolderFolder = 'shared_folder_folder', + Record = "record", + UserFolder = "user_folder", + SharedFolder = "shared_folder", + SharedFolderFolder = "shared_folder_folder", } export enum FolderResultStatus { - Success = 'success', - Invited = 'invited', - Unknown = 'unknown', + Success = "success", + Invited = "invited", + Unknown = "unknown", } export enum VaultObjectKind { - Record = 'record', - Metadata = 'metadata', - NonSharedData = 'non_shared_data', - Team = 'team', - User = 'user', - SharedFolderUser = 'shared_folder_user', - SharedFolderTeam = 'shared_folder_team', - SharedFolderRecord = 'shared_folder_record', + Record = "record", + Metadata = "metadata", + NonSharedData = "non_shared_data", + Team = "team", + User = "user", + SharedFolderUser = "shared_folder_user", + SharedFolderTeam = "shared_folder_team", + SharedFolderRecord = "shared_folder_record", } -export type FolderKindOrLiteral = FolderKind | `${FolderKind}` - -export function folderKindFromString(value: string | undefined | null): FolderKind | undefined { - if (!value) return undefined - switch (value) { - case FolderKind.UserFolder: - return FolderKind.UserFolder - case FolderKind.SharedFolder: - return FolderKind.SharedFolder - case FolderKind.SharedFolderFolder: - return FolderKind.SharedFolderFolder - default: - return undefined - } +export type FolderKindOrLiteral = FolderKind | `${FolderKind}`; + +export function folderKindFromString( + value: string | undefined | null, +): FolderKind | undefined { + if (!value) return undefined; + switch (value) { + case FolderKind.UserFolder: + return FolderKind.UserFolder; + case FolderKind.SharedFolder: + return FolderKind.SharedFolder; + case FolderKind.SharedFolderFolder: + return FolderKind.SharedFolderFolder; + default: + return undefined; + } } +type UserFolderData = { title?: string; name?: string; color?: string }; + export function userFolderName(folder: DUserFolder): string { - const data = folder.data as { title?: string; name?: string } | undefined - return (data?.title || data?.name || folder.uid).trim() || folder.uid + const data = folder.data as UserFolderData | undefined; + return (data?.title || data?.name || folder.uid).trim() || folder.uid; +} + +/** Vault folder color from user-folder data (`none` and missing → undefined). */ +export function userFolderColor(folder: DUserFolder): string | undefined { + const color = (folder.data as UserFolderData | undefined)?.color; + if (typeof color !== "string") return undefined; + const trimmed = color.trim().toLowerCase(); + if (!trimmed || trimmed === "none") return undefined; + return trimmed; } export function sharedFolderFolderName(folder: DSharedFolderFolder): string { - const data = folder.data as { title?: string; name?: string } | undefined - return (data?.title || data?.name || folder.uid).trim() || folder.uid + const data = folder.data as { title?: string; name?: string } | undefined; + return (data?.title || data?.name || folder.uid).trim() || folder.uid; } export function sharedFolderName(folder: DSharedFolder): string { - return (folder.name || folder.uid).trim() || folder.uid + return (folder.name || folder.uid).trim() || folder.uid; } export function globToRegex(pattern: string): RegExp { - const escapedPattern = escapeRegExp(pattern) - const regexBody = escapedPattern.replace(/\*/g, '.*').replace(/\?/g, '.') - return new RegExp(`^${regexBody}$`, 'i') + const escapedPattern = escapeRegExp(pattern); + const regexBody = escapedPattern.replace(/\*/g, ".*").replace(/\?/g, "."); + return new RegExp(`^${regexBody}$`, "i"); } -export async function getUserFolderParentMap(storage: InMemoryStorage): Promise> { - const userFolders = storage.getAll(FolderKind.UserFolder) - const childToParent = new Map() - for (const userFolder of userFolders) { - const dependencies = (await storage.getDependencies(userFolder.uid)) || [] - for (const dependency of dependencies) { - if (dependency.kind === FolderKind.UserFolder) { - childToParent.set(dependency.uid, userFolder.uid) - } - } +export async function getUserFolderParentMap( + storage: InMemoryStorage, +): Promise> { + const userFolders = storage.getAll(FolderKind.UserFolder); + const childToParent = new Map(); + for (const userFolder of userFolders) { + const dependencies = (await storage.getDependencies(userFolder.uid)) || []; + for (const dependency of dependencies) { + if (dependency.kind === FolderKind.UserFolder) { + childToParent.set(dependency.uid, userFolder.uid); + } } - return childToParent + } + return childToParent; } diff --git a/KeeperSdk/src/folders/folderTree.ts b/KeeperSdk/src/folders/folderTree.ts index 03f1dfd2..0420bccf 100644 --- a/KeeperSdk/src/folders/folderTree.ts +++ b/KeeperSdk/src/folders/folderTree.ts @@ -1,321 +1,442 @@ import type { - DRecord, - DSharedFolder, - DSharedFolderFolder, - DSharedFolderTeam, - DSharedFolderUser, - DUser, - DUserFolder, -} from '@keeper-security/keeperapi' -import { webSafe64FromBytes } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { getRecordTitle } from '../records/RecordUtils' -import { listFolder, listVaultRootFolders } from './listFolder' -import { resolveSingleFolder, type VaultFolderSession } from './changeDirectory' -import { FolderKind, VaultObjectKind, sharedFolderFolderName, sharedFolderName, userFolderName } from './folderHelpers' + DRecord, + DSharedFolder, + DSharedFolderFolder, + DSharedFolderTeam, + DSharedFolderUser, + DUser, + DUserFolder, +} from "@keeper-security/keeperapi"; +import { webSafe64FromBytes } from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { getRecordTitle } from "../records/RecordUtils"; +import { listFolder, listVaultRootFolders } from "./listFolder"; +import { + resolveSingleFolder, + VAULT_ROOT_DISPLAY_NAME, + type VaultFolderSession, +} from "./changeDirectory"; +import { + FolderKind, + VaultObjectKind, + sharedFolderFolderName, + sharedFolderName, + userFolderName, +} from "./folderHelpers"; enum TreeItemKind { - Permission = 'perm', - Record = 'record', - Folder = 'folder', + Permission = "perm", + Record = "record", + Folder = "folder", } +const TREE_TAG = { + folder: "[folder]", + sharedFolder: "[shared folder]", + record: "[record]", +} as const; + export type FolderTreeBuildOptions = { - folderPath?: string | null - verbose?: boolean - showRecords?: boolean - showShares?: boolean - hideSharesKey?: boolean - title?: string | null -} + folderPath?: string | null; + verbose?: boolean; + showRecords?: boolean; + showShares?: boolean; + hideSharesKey?: boolean; + title?: string | null; +}; export type FolderTreeNode = { - displayName: string - children: FolderTreeNode[] - permissions?: { display: string }[] - records?: { display: string }[] -} + displayName: string; + children: FolderTreeNode[]; + permissions?: { display: string }[]; + records?: { display: string }[]; +}; export type FolderTreeResult = { - title?: string | null - root: FolderTreeNode -} + title?: string | null; + root: FolderTreeNode; +}; -export function userPermissionToText(manageUsers: boolean, manageRecords: boolean): string { - if (manageUsers && manageRecords) return 'Can Manage Users & Records' - if (manageUsers) return 'Can Manage Users' - if (manageRecords) return 'Can Manage Records' - return 'No User Permissions' +export function userPermissionToText( + manageUsers: boolean, + manageRecords: boolean, +): string { + if (manageUsers && manageRecords) return "Can Manage Users & Records"; + if (manageUsers) return "Can Manage Users"; + if (manageRecords) return "Can Manage Records"; + return "No User Permissions"; } -export function recordPermissionToText(canEdit: boolean, canShare: boolean): string { - if (canEdit && canShare) return 'Can Edit & Share' - if (canEdit) return 'Can Edit' - if (canShare) return 'Can Share' - return 'View Only' +export function recordPermissionToText( + canEdit: boolean, + canShare: boolean, +): string { + if (canEdit && canShare) return "Can Edit & Share"; + if (canEdit) return "Can Edit"; + if (canShare) return "Can Share"; + return "View Only"; } -function buildAccountUidEmailMap(storage: InMemoryStorage): Map { - const accountUidToEmail = new Map() - for (const user of storage.getAll(VaultObjectKind.User)) { - const uid = user.accountUid ? webSafe64FromBytes(user.accountUid) : '' - const email = (user.username || '').trim() - if (uid && email) accountUidToEmail.set(uid, email) - } - return accountUidToEmail +function buildAccountUidEmailMap( + storage: InMemoryStorage, +): Map { + const accountUidToEmail = new Map(); + for (const user of storage.getAll(VaultObjectKind.User)) { + const uid = user.accountUid ? webSafe64FromBytes(user.accountUid) : ""; + const email = (user.username || "").trim(); + if (uid && email) accountUidToEmail.set(uid, email); + } + return accountUidToEmail; } function resolveUserDisplayName( - accountUid: string | undefined, - accountUsername: string | undefined, - emailMap: Map + accountUid: string | undefined, + accountUsername: string | undefined, + emailMap: Map, ): string { - const explicit = (accountUsername || '').trim() - if (explicit) return explicit - if (accountUid) { - const fromMap = emailMap.get(accountUid) - if (fromMap) return fromMap - return accountUid - } - return 'unknown' + const explicit = (accountUsername || "").trim(); + if (explicit) return explicit; + if (accountUid) { + const fromMap = emailMap.get(accountUid); + if (fromMap) return fromMap; + return accountUid; + } + return "unknown"; } async function collectSharedFolderPermissions( - storage: InMemoryStorage, - sharedFolder: DSharedFolder, - verbose: boolean, - hideSharesKey: boolean, - emailMap: Map + storage: InMemoryStorage, + sharedFolder: DSharedFolder, + verbose: boolean, + hideSharesKey: boolean, + emailMap: Map, ): Promise<{ display: string }[]> { - const rows: { display: string; sortKey: string }[] = [] - const seenUserUids = new Set() - - for (const sharedUser of storage.getAll(VaultObjectKind.SharedFolderUser)) { - if (sharedUser.sharedFolderUid !== sharedFolder.uid) continue - if (sharedUser.accountUid) seenUserUids.add(sharedUser.accountUid) - const permissionText = userPermissionToText(sharedUser.manageUsers, sharedUser.manageRecords) - let name = resolveUserDisplayName(sharedUser.accountUid, sharedUser.accountUsername, emailMap) - if (verbose && sharedUser.accountUid) name += ` (${sharedUser.accountUid})` - const suffix = hideSharesKey ? '' : ` [User]` - rows.push({ - display: `${name}: ${permissionText}${suffix}`, - sortKey: name.toLowerCase(), - }) - } - - if (sharedFolder.ownerAccountUid && !seenUserUids.has(sharedFolder.ownerAccountUid)) { - const ownerName = resolveUserDisplayName(sharedFolder.ownerAccountUid, sharedFolder.ownerUsername, emailMap) - let display = ownerName - if (verbose) display += ` (${sharedFolder.ownerAccountUid})` - const suffix = hideSharesKey ? '' : ` [User]` - rows.push({ - display: `${display}: ${userPermissionToText(true, true)}${suffix}`, - sortKey: ownerName.toLowerCase(), - }) - } + const rows: { display: string; sortKey: string }[] = []; + const seenUserUids = new Set(); + + for (const sharedUser of storage.getAll( + VaultObjectKind.SharedFolderUser, + )) { + if (sharedUser.sharedFolderUid !== sharedFolder.uid) continue; + if (sharedUser.accountUid) seenUserUids.add(sharedUser.accountUid); + const permissionText = userPermissionToText( + sharedUser.manageUsers, + sharedUser.manageRecords, + ); + let name = resolveUserDisplayName( + sharedUser.accountUid, + sharedUser.accountUsername, + emailMap, + ); + if (verbose && sharedUser.accountUid) name += ` (${sharedUser.accountUid})`; + const suffix = hideSharesKey ? "" : ` [User]`; + rows.push({ + display: `${name}: ${permissionText}${suffix}`, + sortKey: name.toLowerCase(), + }); + } + + if ( + sharedFolder.ownerAccountUid && + !seenUserUids.has(sharedFolder.ownerAccountUid) + ) { + const ownerName = resolveUserDisplayName( + sharedFolder.ownerAccountUid, + sharedFolder.ownerUsername, + emailMap, + ); + let display = ownerName; + if (verbose) display += ` (${sharedFolder.ownerAccountUid})`; + const suffix = hideSharesKey ? "" : ` [User]`; + rows.push({ + display: `${display}: ${userPermissionToText(true, true)}${suffix}`, + sortKey: ownerName.toLowerCase(), + }); + } + + for (const sharedTeam of storage.getAll( + VaultObjectKind.SharedFolderTeam, + )) { + if (sharedTeam.sharedFolderUid !== sharedFolder.uid) continue; + const permissionText = userPermissionToText( + sharedTeam.manageUsers, + sharedTeam.manageRecords, + ); + let name = sharedTeam.name || `(${sharedTeam.teamUid})`; + if (verbose && sharedTeam.teamUid) name += ` (${sharedTeam.teamUid})`; + const suffix = hideSharesKey ? "" : ` [Team]`; + rows.push({ + display: `${name}: ${permissionText}${suffix}`, + sortKey: name.toLowerCase(), + }); + } + + rows.sort((rowA, rowB) => rowA.sortKey.localeCompare(rowB.sortKey)); + return rows.map((row) => ({ display: row.display })); +} - for (const sharedTeam of storage.getAll(VaultObjectKind.SharedFolderTeam)) { - if (sharedTeam.sharedFolderUid !== sharedFolder.uid) continue - const permissionText = userPermissionToText(sharedTeam.manageUsers, sharedTeam.manageRecords) - let name = sharedTeam.name || `(${sharedTeam.teamUid})` - if (verbose && sharedTeam.teamUid) name += ` (${sharedTeam.teamUid})` - const suffix = hideSharesKey ? '' : ` [Team]` - rows.push({ - display: `${name}: ${permissionText}${suffix}`, - sortKey: name.toLowerCase(), - }) - } +function folderTreeTag( + userFolder: DUserFolder | undefined, + sharedFolder: DSharedFolder | undefined, + _sharedFolderFolder: DSharedFolderFolder | undefined, +): string { + if (sharedFolder) return TREE_TAG.sharedFolder; + if (userFolder) return TREE_TAG.folder; + return TREE_TAG.folder; +} - rows.sort((rowA, rowB) => rowA.sortKey.localeCompare(rowB.sortKey)) - return rows.map((row) => ({ display: row.display })) +function formatTreeNodeName( + baseName: string, + tag: string, + verbose: boolean, + uid?: string, +): string { + const name = verbose && uid ? `${baseName} (${uid})` : baseName; + return `${name} ${tag}`; } -type BuildOpts = Required> & { - promotedRootSharedUids?: Set - accountUidEmailMap: Map +function formatTreeRecordName( + title: string, + verbose: boolean, + recordUid?: string, +): string { + const name = verbose && recordUid ? `${title} (${recordUid})` : title; + return `${name} ${TREE_TAG.record}`; } +type BuildOpts = Required< + Pick< + FolderTreeBuildOptions, + "verbose" | "showRecords" | "showShares" | "hideSharesKey" + > +> & { + promotedRootSharedUids?: Set; + accountUidEmailMap: Map; +}; + async function buildFolderSubtree( - storage: InMemoryStorage, - folderUid: string, - opts: BuildOpts + storage: InMemoryStorage, + folderUid: string, + opts: BuildOpts, ): Promise { - const userFolder = storage.getByUid(FolderKind.UserFolder, folderUid) - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, folderUid) - const sharedFolderFolder = storage.getByUid(FolderKind.SharedFolderFolder, folderUid) - if (!userFolder && !sharedFolder && !sharedFolderFolder) { - return { displayName: `(missing folder ${folderUid})`, children: [] } - } - - let baseName: string - if (userFolder) baseName = userFolderName(userFolder) - else if (sharedFolder) baseName = sharedFolderName(sharedFolder) - else baseName = sharedFolderFolderName(sharedFolderFolder!) - - let displayName = baseName - if (opts.verbose) { - displayName = `${baseName} (${folderUid})` - } - if (sharedFolder) { - displayName += ' [Shared]' - } - - const node: FolderTreeNode = { displayName, children: [] } - - if (opts.showShares && sharedFolder) { - node.permissions = await collectSharedFolderPermissions( - storage, - sharedFolder, - opts.verbose, - opts.hideSharesKey, - opts.accountUidEmailMap - ) - } - - const listed = await listFolder(storage, { - folderUid: folderUid, - showFolders: true, - showRecords: opts.showRecords, - }) - - for (const childFolder of listed.folders) { - if (opts.promotedRootSharedUids?.has(childFolder.uid)) continue - node.children.push(await buildFolderSubtree(storage, childFolder.uid, opts)) - } - - if (opts.showRecords && 'records' in listed) { - const records = listed.records - node.records = records.map((recordRow) => { - const record = storage.getByUid(VaultObjectKind.Record, recordRow.uid) - const title = record ? getRecordTitle(record) : recordRow.name - const display = opts.verbose && record ? `${title} (${recordRow.uid}) [Record]` : `${title} [Record]` - return { display } - }) - } - - return node + const userFolder = storage.getByUid( + FolderKind.UserFolder, + folderUid, + ); + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + folderUid, + ); + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + folderUid, + ); + if (!userFolder && !sharedFolder && !sharedFolderFolder) { + return { displayName: `(missing folder ${folderUid})`, children: [] }; + } + + let baseName: string; + if (userFolder) baseName = userFolderName(userFolder); + else if (sharedFolder) baseName = sharedFolderName(sharedFolder); + else baseName = sharedFolderFolderName(sharedFolderFolder!); + + let displayName = formatTreeNodeName( + baseName, + folderTreeTag(userFolder, sharedFolder, sharedFolderFolder), + opts.verbose, + folderUid, + ); + + const node: FolderTreeNode = { displayName, children: [] }; + + if (opts.showShares && sharedFolder) { + node.permissions = await collectSharedFolderPermissions( + storage, + sharedFolder, + opts.verbose, + opts.hideSharesKey, + opts.accountUidEmailMap, + ); + } + + const listed = await listFolder(storage, { + folderUid: folderUid, + showFolders: true, + showRecords: opts.showRecords, + }); + + for (const childFolder of listed.folders) { + if (opts.promotedRootSharedUids?.has(childFolder.uid)) continue; + node.children.push( + await buildFolderSubtree(storage, childFolder.uid, opts), + ); + } + + if (opts.showRecords && "records" in listed) { + const records = listed.records; + node.records = records.map((recordRow) => { + const record = storage.getByUid( + VaultObjectKind.Record, + recordRow.uid, + ); + const title = record ? getRecordTitle(record) : recordRow.name; + return { + display: formatTreeRecordName(title, opts.verbose, recordRow.uid), + }; + }); + } + + return node; } -async function buildVaultRootTree(storage: InMemoryStorage, opts: BuildOpts): Promise { - const node: FolderTreeNode = { displayName: '', children: [] } - const { rows, promotedRootSharedUids } = await listVaultRootFolders(storage) - const optsWithPromoted: BuildOpts = { ...opts, promotedRootSharedUids } - for (const folderRow of rows) { - node.children.push(await buildFolderSubtree(storage, folderRow.uid, optsWithPromoted)) - } - const listed = await listFolder(storage, { - folderUid: undefined, - showFolders: true, - showRecords: opts.showRecords, - }) - if (opts.showRecords && listed.records?.length) { - node.records = listed.records.map((recordRow) => { - const record = storage.getByUid(VaultObjectKind.Record, recordRow.uid) - const title = record ? getRecordTitle(record) : recordRow.name - const display = opts.verbose && record ? `${title} (${recordRow.uid}) [Record]` : `${title} [Record]` - return { display } - }) - } - return node +async function buildVaultRootTree( + storage: InMemoryStorage, + opts: BuildOpts, +): Promise { + const node: FolderTreeNode = { + displayName: VAULT_ROOT_DISPLAY_NAME, + children: [], + }; + const { rows, promotedRootSharedUids } = await listVaultRootFolders(storage); + const optsWithPromoted: BuildOpts = { ...opts, promotedRootSharedUids }; + for (const folderRow of rows) { + node.children.push( + await buildFolderSubtree(storage, folderRow.uid, optsWithPromoted), + ); + } + const listed = await listFolder(storage, { + folderUid: undefined, + showFolders: true, + showRecords: opts.showRecords, + }); + if (opts.showRecords && listed.records?.length) { + node.records = listed.records.map((recordRow) => { + const record = storage.getByUid( + VaultObjectKind.Record, + recordRow.uid, + ); + const title = record ? getRecordTitle(record) : recordRow.name; + return { + display: formatTreeRecordName(title, opts.verbose, recordRow.uid), + }; + }); + } + return node; } async function resolveTreeStart( - storage: InMemoryStorage, - session: VaultFolderSession, - folderPath?: string | null + storage: InMemoryStorage, + session: VaultFolderSession, + folderPath?: string | null, ): Promise<{ folderUid: string | null }> { - const trimmedPath = folderPath?.trim() - if (trimmedPath) { - const resolved = await resolveSingleFolder(storage, session, trimmedPath) - return { folderUid: resolved.folderUid } - } - return { folderUid: session.currentFolderUid || null } + const trimmedPath = folderPath?.trim(); + if (trimmedPath) { + const resolved = await resolveSingleFolder(storage, session, trimmedPath); + return { folderUid: resolved.folderUid }; + } + return { folderUid: session.currentFolderUid || null }; } export async function buildFolderTree( - storage: InMemoryStorage, - session: VaultFolderSession, - options: FolderTreeBuildOptions = {} + storage: InMemoryStorage, + session: VaultFolderSession, + options: FolderTreeBuildOptions = {}, ): Promise { - const opts: BuildOpts = { - verbose: options.verbose === true, - showRecords: options.showRecords === true, - showShares: options.showShares === true, - hideSharesKey: options.hideSharesKey === true, - accountUidEmailMap: buildAccountUidEmailMap(storage), - } - - const { folderUid } = await resolveTreeStart(storage, session, options.folderPath) - - const root = - folderUid === null - ? await buildVaultRootTree(storage, opts) - : await buildFolderSubtree(storage, folderUid, opts) - - return { title: options.title || undefined, root } + const opts: BuildOpts = { + verbose: options.verbose === true, + showRecords: options.showRecords === true, + showShares: options.showShares === true, + hideSharesKey: options.hideSharesKey === true, + accountUidEmailMap: buildAccountUidEmailMap(storage), + }; + + const { folderUid } = await resolveTreeStart( + storage, + session, + options.folderPath, + ); + + const root = + folderUid === null + ? await buildVaultRootTree(storage, opts) + : await buildFolderSubtree(storage, folderUid, opts); + + return { title: options.title || undefined, root }; } type TreeItem = - | { kind: TreeItemKind.Permission; display: string } - | { kind: TreeItemKind.Record; display: string } - | { kind: TreeItemKind.Folder; node: FolderTreeNode } + | { kind: TreeItemKind.Permission; display: string } + | { kind: TreeItemKind.Record; display: string } + | { kind: TreeItemKind.Folder; node: FolderTreeNode }; function gatherItems(node: FolderTreeNode): TreeItem[] { - const items: TreeItem[] = [] - if (node.permissions) { - for (const permission of node.permissions) { - items.push({ kind: TreeItemKind.Permission, display: permission.display }) - } + const items: TreeItem[] = []; + if (node.permissions) { + for (const permission of node.permissions) { + items.push({ + kind: TreeItemKind.Permission, + display: permission.display, + }); } - for (const child of node.children) { - items.push({ kind: TreeItemKind.Folder, node: child }) + } + for (const child of node.children) { + items.push({ kind: TreeItemKind.Folder, node: child }); + } + if (node.records) { + for (const record of node.records) { + items.push({ kind: TreeItemKind.Record, display: record.display }); } - if (node.records) { - for (const record of node.records) { - items.push({ kind: TreeItemKind.Record, display: record.display }) - } - } - return items + } + return items; } export function renderFolderTreeAscii(result: FolderTreeResult): string { - const lines: string[] = [] - if (result.title) { - lines.push(result.title) - } - renderNode(result.root, lines, true, '', true) - return lines.join('\n') + const lines: string[] = []; + if (result.title) { + lines.push(result.title); + } + renderNode(result.root, lines, true, "", true); + return lines.join("\n"); } -function renderNode(node: FolderTreeNode, lines: string[], isRoot: boolean, prefix: string, isLast: boolean): void { - if (isRoot) { - if (node.displayName) { - lines.push(node.displayName) - } - } else { - const connector = isLast ? '\\-- ' : '+-- ' - lines.push(prefix + connector + node.displayName) +function renderNode( + node: FolderTreeNode, + lines: string[], + isRoot: boolean, + prefix: string, + isLast: boolean, +): void { + if (isRoot) { + if (node.displayName) { + lines.push(node.displayName); } - - const childBase = isRoot ? ' ' : prefix + (isLast ? ' ' : '| ') - const items = gatherItems(node) - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - const isLastItem = itemIndex === items.length - 1 - const item = items[itemIndex] - if (item.kind === TreeItemKind.Permission || item.kind === TreeItemKind.Record) { - const connector = isLastItem ? '\\-- ' : '+-- ' - lines.push(childBase + connector + item.display) - } else { - renderNode(item.node, lines, false, childBase, isLastItem) - } + } else { + const connector = isLast ? "└── " : "├── "; + lines.push(prefix + connector + node.displayName); + } + + const childBase = isRoot ? "" : prefix + (isLast ? " " : "│ "); + const items = gatherItems(node); + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + const isLastItem = itemIndex === items.length - 1; + const item = items[itemIndex]; + if ( + item.kind === TreeItemKind.Permission || + item.kind === TreeItemKind.Record + ) { + const connector = isLastItem ? "└── " : "├── "; + lines.push(childBase + connector + item.display); + } else { + renderNode(item.node, lines, false, childBase, isLastItem); } + } } export async function folderTreeAscii( - storage: InMemoryStorage, - session: VaultFolderSession, - options?: FolderTreeBuildOptions + storage: InMemoryStorage, + session: VaultFolderSession, + options?: FolderTreeBuildOptions, ): Promise { - const built = await buildFolderTree(storage, session, options || {}) - return renderFolderTreeAscii(built) + const built = await buildFolderTree(storage, session, options || {}); + return renderFolderTreeAscii(built); } diff --git a/KeeperSdk/src/folders/getFolder.ts b/KeeperSdk/src/folders/getFolder.ts index cad1b1c0..6ea72b91 100644 --- a/KeeperSdk/src/folders/getFolder.ts +++ b/KeeperSdk/src/folders/getFolder.ts @@ -1,234 +1,268 @@ import type { - DSharedFolder, - DSharedFolderFolder, - DSharedFolderRecord, - DSharedFolderUser, - DUserFolder, -} from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError } from '../utils' -import { findParentFolderUid } from './changeDirectory' -import { FolderKind, FolderObjectType, VaultObjectKind, sharedFolderFolderName, userFolderName } from './folderHelpers' + DSharedFolder, + DSharedFolderFolder, + DSharedFolderRecord, + DSharedFolderUser, + DUserFolder, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError } from "../utils"; +import { findParentFolderUid } from "./changeDirectory"; +import { + FolderKind, + FolderObjectType, + VaultObjectKind, + sharedFolderFolderName, + userFolderName, +} from "./folderHelpers"; export enum GetFolderFormat { - Detail = 'detail', - JSON = 'json', + Detail = "detail", + JSON = "json", } -export type GetFolderFormatInput = GetFolderFormat | `${GetFolderFormat}` +export type GetFolderFormatInput = GetFolderFormat | `${GetFolderFormat}`; export type GetFolderOptions = { - format?: GetFolderFormatInput -} + format?: GetFolderFormatInput; +}; -export type GetFolderResult = GetFolderResultFolder | GetFolderResultSharedFolder +export type GetFolderResult = + | GetFolderResultFolder + | GetFolderResultSharedFolder; export type GetFolderResultFolder = { - objectType: FolderObjectType.Folder - format: GetFolderFormat - folder_uid: string - folder_type: FolderKind.UserFolder | FolderKind.SharedFolderFolder - name: string - parent_uid: string | null - shared_folder_scope_uid?: string - json?: Record -} + objectType: FolderObjectType.Folder; + format: GetFolderFormat; + folder_uid: string; + folder_type: FolderKind.UserFolder | FolderKind.SharedFolderFolder; + name: string; + parent_uid: string | null; + shared_folder_scope_uid?: string; + json?: Record; +}; export type GetFolderResultSharedFolder = { - objectType: FolderObjectType.SharedFolder - format: GetFolderFormat - shared_folder_uid: string - name: string - default_can_edit: boolean - default_can_share: boolean - default_manage_records: boolean - default_manage_users: boolean - record_permissions?: { - record_uid: string - can_edit: boolean - can_share: boolean - owner: boolean - }[] - user_permissions?: { - account_username?: string - manage_records: boolean - manage_users: boolean - }[] - json?: Record -} + objectType: FolderObjectType.SharedFolder; + format: GetFolderFormat; + shared_folder_uid: string; + name: string; + default_can_edit: boolean; + default_can_share: boolean; + default_manage_records: boolean; + default_manage_users: boolean; + record_permissions?: { + record_uid: string; + can_edit: boolean; + can_share: boolean; + owner: boolean; + }[]; + user_permissions?: { + account_username?: string; + manage_records: boolean; + manage_users: boolean; + }[]; + json?: Record; +}; export type FoundFolder = - | { kind: FolderKind.UserFolder; folder: DUserFolder } - | { kind: FolderKind.SharedFolder; folder: DSharedFolder } - | { kind: FolderKind.SharedFolderFolder; folder: DSharedFolderFolder } + | { kind: FolderKind.UserFolder; folder: DUserFolder } + | { kind: FolderKind.SharedFolder; folder: DSharedFolder } + | { kind: FolderKind.SharedFolderFolder; folder: DSharedFolderFolder }; function findByUidOrName( - items: Iterable, - nameOrUid: string, - getUid: (item: T) => string, - getName: (item: T) => string + items: Iterable, + nameOrUid: string, + getUid: (item: T) => string, + getName: (item: T) => string, ): T | undefined { - const trimmedNameOrUid = nameOrUid.trim() - if (!trimmedNameOrUid) return undefined - const lowerNameOrUid = trimmedNameOrUid.toLowerCase() - let exactNameHit: T | undefined - let lowerNameHit: T | undefined - for (const item of items) { - if (getUid(item) === trimmedNameOrUid) return item - if (!exactNameHit) { - const itemName = getName(item) - if (itemName === trimmedNameOrUid) { - exactNameHit = item - } else if (!lowerNameHit && itemName.toLowerCase() === lowerNameOrUid) { - lowerNameHit = item - } - } + const trimmedNameOrUid = nameOrUid.trim(); + if (!trimmedNameOrUid) return undefined; + const lowerNameOrUid = trimmedNameOrUid.toLowerCase(); + let exactNameHit: T | undefined; + let lowerNameHit: T | undefined; + for (const item of items) { + if (getUid(item) === trimmedNameOrUid) return item; + if (!exactNameHit) { + const itemName = getName(item); + if (itemName === trimmedNameOrUid) { + exactNameHit = item; + } else if (!lowerNameHit && itemName.toLowerCase() === lowerNameOrUid) { + lowerNameHit = item; + } } - return exactNameHit || lowerNameHit + } + return exactNameHit || lowerNameHit; } -function findSharedFolder(storage: InMemoryStorage, nameOrUid: string): DSharedFolder | undefined { - return findByUidOrName( - storage.getAll(FolderKind.SharedFolder), - nameOrUid, - (sharedFolder) => sharedFolder.uid, - (sharedFolder) => (sharedFolder.name || '').trim() - ) +function findSharedFolder( + storage: InMemoryStorage, + nameOrUid: string, +): DSharedFolder | undefined { + return findByUidOrName( + storage.getAll(FolderKind.SharedFolder), + nameOrUid, + (sharedFolder) => sharedFolder.uid, + (sharedFolder) => (sharedFolder.name || "").trim(), + ); } -function findUserFolder(storage: InMemoryStorage, nameOrUid: string): DUserFolder | undefined { - return findByUidOrName( - storage.getAll(FolderKind.UserFolder), - nameOrUid, - (userFolder) => userFolder.uid, - (userFolder) => userFolderName(userFolder) - ) +function findUserFolder( + storage: InMemoryStorage, + nameOrUid: string, +): DUserFolder | undefined { + return findByUidOrName( + storage.getAll(FolderKind.UserFolder), + nameOrUid, + (userFolder) => userFolder.uid, + (userFolder) => userFolderName(userFolder), + ); } -function findSharedFolderFolder(storage: InMemoryStorage, nameOrUid: string): DSharedFolderFolder | undefined { - return findByUidOrName( - storage.getAll(FolderKind.SharedFolderFolder), - nameOrUid, - (sharedFolderFolder) => sharedFolderFolder.uid, - (sharedFolderFolder) => sharedFolderFolderName(sharedFolderFolder) - ) +function findSharedFolderFolder( + storage: InMemoryStorage, + nameOrUid: string, +): DSharedFolderFolder | undefined { + return findByUidOrName( + storage.getAll(FolderKind.SharedFolderFolder), + nameOrUid, + (sharedFolderFolder) => sharedFolderFolder.uid, + (sharedFolderFolder) => sharedFolderFolderName(sharedFolderFolder), + ); } -export function findFolder(storage: InMemoryStorage, nameOrUid: string): FoundFolder | undefined { - const trimmedNameOrUid = nameOrUid.trim() - if (!trimmedNameOrUid) return undefined +export function findFolder( + storage: InMemoryStorage, + nameOrUid: string, +): FoundFolder | undefined { + const trimmedNameOrUid = nameOrUid.trim(); + if (!trimmedNameOrUid) return undefined; - const sharedFolder = findSharedFolder(storage, trimmedNameOrUid) - if (sharedFolder) return { kind: FolderKind.SharedFolder, folder: sharedFolder } + const sharedFolder = findSharedFolder(storage, trimmedNameOrUid); + if (sharedFolder) + return { kind: FolderKind.SharedFolder, folder: sharedFolder }; - const userFolder = findUserFolder(storage, trimmedNameOrUid) - if (userFolder) return { kind: FolderKind.UserFolder, folder: userFolder } + const userFolder = findUserFolder(storage, trimmedNameOrUid); + if (userFolder) return { kind: FolderKind.UserFolder, folder: userFolder }; - const sharedFolderFolder = findSharedFolderFolder(storage, trimmedNameOrUid) - if (sharedFolderFolder) return { kind: FolderKind.SharedFolderFolder, folder: sharedFolderFolder } + const sharedFolderFolder = findSharedFolderFolder(storage, trimmedNameOrUid); + if (sharedFolderFolder) + return { kind: FolderKind.SharedFolderFolder, folder: sharedFolderFolder }; - return undefined + return undefined; } async function formatRegularFolder( - storage: InMemoryStorage, - folder: DUserFolder | DSharedFolderFolder, - format: GetFolderFormat + storage: InMemoryStorage, + folder: DUserFolder | DSharedFolderFolder, + format: GetFolderFormat, ): Promise { - const isUser = folder.kind === FolderKind.UserFolder - const folder_uid = folder.uid - const folder_type: GetFolderResultFolder['folder_type'] = isUser - ? FolderKind.UserFolder - : FolderKind.SharedFolderFolder - const name = isUser ? userFolderName(folder) : sharedFolderFolderName(folder) - const parent_uid = await findParentFolderUid(storage, folder_uid) - - const base: GetFolderResultFolder = { - objectType: FolderObjectType.Folder, - format, - folder_uid, - folder_type, - name, - parent_uid, - } - if (!isUser) { - base.shared_folder_scope_uid = (folder as DSharedFolderFolder).sharedFolderUid - } - if (format === GetFolderFormat.JSON) { - base.json = { ...base, objectType: FolderObjectType.Folder } - } - return base + const isUser = folder.kind === FolderKind.UserFolder; + const folder_uid = folder.uid; + const folder_type: GetFolderResultFolder["folder_type"] = isUser + ? FolderKind.UserFolder + : FolderKind.SharedFolderFolder; + const name = isUser ? userFolderName(folder) : sharedFolderFolderName(folder); + const parent_uid = await findParentFolderUid(storage, folder_uid); + + const base: GetFolderResultFolder = { + objectType: FolderObjectType.Folder, + format, + folder_uid, + folder_type, + name, + parent_uid, + }; + if (!isUser) { + base.shared_folder_scope_uid = ( + folder as DSharedFolderFolder + ).sharedFolderUid; + } + if (format === GetFolderFormat.JSON) { + base.json = { ...base, objectType: FolderObjectType.Folder }; + } + return base; } function formatSharedFolder( - storage: InMemoryStorage, - sharedFolder: DSharedFolder, - format: GetFolderFormat + storage: InMemoryStorage, + sharedFolder: DSharedFolder, + format: GetFolderFormat, ): GetFolderResultSharedFolder { - const sharedFolderUid = sharedFolder.uid - const recordPermissions = storage - .getAll(VaultObjectKind.SharedFolderRecord) - .filter((record) => record.sharedFolderUid === sharedFolderUid) - .map((record) => ({ - record_uid: record.recordUid, - can_edit: record.canEdit, - can_share: record.canShare, - owner: record.owner, - })) - const userPermissions = storage - .getAll(VaultObjectKind.SharedFolderUser) - .filter((user) => user.sharedFolderUid === sharedFolderUid) - .map((user) => ({ - account_username: user.accountUsername, - manage_records: user.manageRecords, - manage_users: user.manageUsers, - })) - - const base: GetFolderResultSharedFolder = { - objectType: FolderObjectType.SharedFolder, - format, - shared_folder_uid: sharedFolder.uid, - name: (sharedFolder.name || '').trim() || sharedFolder.uid, - default_can_edit: sharedFolder.defaultCanEdit, - default_can_share: sharedFolder.defaultCanShare, - default_manage_records: sharedFolder.defaultManageRecords, - default_manage_users: sharedFolder.defaultManageUsers, - record_permissions: recordPermissions.length ? recordPermissions : undefined, - user_permissions: userPermissions.length ? userPermissions : undefined, - } - if (format === GetFolderFormat.JSON) { - base.json = { ...base, objectType: FolderObjectType.SharedFolder } - } - return base + const sharedFolderUid = sharedFolder.uid; + const recordPermissions = storage + .getAll(VaultObjectKind.SharedFolderRecord) + .filter((record) => record.sharedFolderUid === sharedFolderUid) + .map((record) => ({ + record_uid: record.recordUid, + can_edit: record.canEdit, + can_share: record.canShare, + owner: record.owner, + })); + const userPermissions = storage + .getAll(VaultObjectKind.SharedFolderUser) + .filter((user) => user.sharedFolderUid === sharedFolderUid) + .map((user) => ({ + account_username: user.accountUsername, + manage_records: user.manageRecords, + manage_users: user.manageUsers, + })); + + const base: GetFolderResultSharedFolder = { + objectType: FolderObjectType.SharedFolder, + format, + shared_folder_uid: sharedFolder.uid, + name: (sharedFolder.name || "").trim() || sharedFolder.uid, + default_can_edit: sharedFolder.defaultCanEdit, + default_can_share: sharedFolder.defaultCanShare, + default_manage_records: sharedFolder.defaultManageRecords, + default_manage_users: sharedFolder.defaultManageUsers, + record_permissions: recordPermissions.length + ? recordPermissions + : undefined, + user_permissions: userPermissions.length ? userPermissions : undefined, + }; + if (format === GetFolderFormat.JSON) { + base.json = { ...base, objectType: FolderObjectType.SharedFolder }; + } + return base; } -function normalizeFormat(format: GetFolderFormatInput | undefined): GetFolderFormat { - if (format === GetFolderFormat.JSON) return GetFolderFormat.JSON - return GetFolderFormat.Detail +function normalizeFormat( + format: GetFolderFormatInput | undefined, +): GetFolderFormat { + if (format === GetFolderFormat.JSON) return GetFolderFormat.JSON; + return GetFolderFormat.Detail; } export async function getFolder( - storage: InMemoryStorage, - uidOrName: string, - options: GetFolderOptions = {} + storage: InMemoryStorage, + uidOrName: string, + options: GetFolderOptions = {}, ): Promise { - const trimmed = uidOrName.trim() - if (!trimmed) { - throw new KeeperSdkError('Folder UID or name is required.', 'missing_folder_ref') - } + const trimmed = uidOrName.trim(); + if (!trimmed) { + throw new KeeperSdkError( + "Folder UID or name is required.", + "missing_folder_ref", + ); + } - const found = findFolder(storage, trimmed) - if (!found) { - throw new KeeperSdkError(`"${trimmed}" not found as a folder or shared folder`, 'folder_not_found') - } + const found = findFolder(storage, trimmed); + if (!found) { + throw new KeeperSdkError( + `"${trimmed}" not found as a folder or shared folder`, + "folder_not_found", + ); + } - const format: GetFolderFormat = normalizeFormat(options.format) + const format: GetFolderFormat = normalizeFormat(options.format); - switch (found.kind) { - case FolderKind.SharedFolder: - return formatSharedFolder(storage, found.folder, format) - case FolderKind.UserFolder: - case FolderKind.SharedFolderFolder: - return formatRegularFolder(storage, found.folder, format) - } + switch (found.kind) { + case FolderKind.SharedFolder: + return formatSharedFolder(storage, found.folder, format); + case FolderKind.UserFolder: + case FolderKind.SharedFolderFolder: + return formatRegularFolder(storage, found.folder, format); + } } diff --git a/KeeperSdk/src/folders/listFolder.ts b/KeeperSdk/src/folders/listFolder.ts index 619dd4f9..0eb6e69e 100644 --- a/KeeperSdk/src/folders/listFolder.ts +++ b/KeeperSdk/src/folders/listFolder.ts @@ -1,358 +1,488 @@ import type { - DRecord, - DRecordMetadata, - DSharedFolder, - DSharedFolderFolder, - DUserFolder, - VaultStorageData, - VaultStorageKind, -} from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError } from '../utils' -import { getRecordTitle, getRecordType } from '../records/RecordUtils' + DRecord, + DRecordMetadata, + DSharedFolder, + DSharedFolderFolder, + DUserFolder, + VaultStorageData, + VaultStorageKind, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError } from "../utils"; +import { getRecordTitle, getRecordType } from "../records/RecordUtils"; import { - FolderKind, - VaultObjectKind, - getUserFolderParentMap, - globToRegex, - sharedFolderFolderName, - sharedFolderName, - userFolderName, -} from './folderHelpers' + FolderKind, + VaultObjectKind, + getUserFolderParentMap, + globToRegex, + sharedFolderFolderName, + sharedFolderName, + userFolderColor, + userFolderName, +} from "./folderHelpers"; export type ListFolderOptions = { - folderUid?: string | null - pattern?: string | null - showFolders?: boolean - showRecords?: boolean - detail?: boolean -} + folderUid?: string | null; + pattern?: string | null; + showFolders?: boolean; + showRecords?: boolean; + detail?: boolean; + recursive?: boolean; +}; export type ListFolderFolderSimple = { - uid: string - name: string - folderKind: FolderKind -} + uid: string; + name: string; + folderKind: FolderKind; + /** User-folder vault color when set (Commander `ls` colorization). */ + color?: string; +}; export type ListFolderRecordSimple = { - uid: string - name: string - type: string -} + uid: string; + name: string; + type: string; +}; export type ListFolderFolderDetail = ListFolderFolderSimple & { - flags: string - recordCount: number - subfolderCount: number -} + flags: string; + recordCount: number; + subfolderCount: number; +}; export type ListFolderRecordDetail = ListFolderRecordSimple & { - flags: string - version: number - isOwner: boolean - hasAttachments: boolean - isShared: boolean -} + flags: string; + version: number; + isOwner: boolean; + hasAttachments: boolean; + isShared: boolean; +}; export type ListFolderResult = - | { - detail: true - folders: ListFolderFolderDetail[] - records: ListFolderRecordDetail[] - } - | { - detail: false - folders: ListFolderFolderSimple[] - records: ListFolderRecordSimple[] - } + | { + detail: true; + folders: ListFolderFolderDetail[]; + records: ListFolderRecordDetail[]; + } + | { + detail: false; + folders: ListFolderFolderSimple[]; + records: ListFolderRecordSimple[]; + }; function recordHasAttachments(record: DRecord): boolean { - const fileIds = record.udata?.file_ids - if (Array.isArray(fileIds) && fileIds.length > 0) return true - const files = (record.extra as { files?: unknown[] } | undefined)?.files - return Array.isArray(files) && files.length > 0 + const fileIds = record.udata?.file_ids; + if (Array.isArray(fileIds) && fileIds.length > 0) return true; + const files = (record.extra as { files?: unknown[] } | undefined)?.files; + return Array.isArray(files) && files.length > 0; } -function buildFolderFlags(folderKind: ListFolderFolderSimple['folderKind']): string { - const isShared = folderKind !== FolderKind.UserFolder - return `f--${isShared ? 'S' : '-'}` +function buildFolderFlags( + folderKind: ListFolderFolderSimple["folderKind"], +): string { + const isShared = folderKind !== FolderKind.UserFolder; + return `f--${isShared ? "S" : "-"}`; } -function buildRecordFlags(record: DRecord, metadata: DRecordMetadata | undefined): string { - const isOwner = metadata?.owner === true - const hasAttachments = recordHasAttachments(record) - const isShared = !!record.shared - return `r${isOwner ? 'O' : '-'}${hasAttachments ? 'A' : '-'}${isShared ? 'S' : '-'}` +function buildRecordFlags( + record: DRecord, + metadata: DRecordMetadata | undefined, +): string { + const isOwner = metadata?.owner === true; + const hasAttachments = recordHasAttachments(record); + const isShared = !!record.shared; + return `r${isOwner ? "O" : "-"}${hasAttachments ? "A" : "-"}${isShared ? "S" : "-"}`; } -export async function listRootUserFolders(storage: InMemoryStorage): Promise { - const userFolders = storage.getAll(FolderKind.UserFolder) - const childToParent = await getUserFolderParentMap(storage) - return userFolders.filter((userFolder) => !childToParent.has(userFolder.uid)) +export async function listRootUserFolders( + storage: InMemoryStorage, +): Promise { + const userFolders = storage.getAll(FolderKind.UserFolder); + const childToParent = await getUserFolderParentMap(storage); + return userFolders.filter((userFolder) => !childToParent.has(userFolder.uid)); } async function buildFolderParentMap( - storage: InMemoryStorage + storage: InMemoryStorage, ): Promise> { - const childToParent = new Map() - const parentKinds: VaultStorageKind[] = [ - FolderKind.UserFolder, - FolderKind.SharedFolder, - FolderKind.SharedFolderFolder, - ] - for (const kind of parentKinds) { - for (const candidate of storage.getAll<{ uid: string } & VaultStorageData>(kind)) { - const candidateUid = (candidate as { uid: string }).uid - if (!candidateUid) continue - const dependencies = (await storage.getDependencies(candidateUid)) || [] - for (const dependency of dependencies) { - if ( - dependency.kind === FolderKind.UserFolder || - dependency.kind === FolderKind.SharedFolder || - dependency.kind === FolderKind.SharedFolderFolder - ) { - if (!childToParent.has(dependency.uid)) { - childToParent.set(dependency.uid, { uid: candidateUid, kind }) - } - } - } + const childToParent = new Map< + string, + { uid: string; kind: VaultStorageKind } + >(); + const parentKinds: VaultStorageKind[] = [ + FolderKind.UserFolder, + FolderKind.SharedFolder, + FolderKind.SharedFolderFolder, + ]; + for (const kind of parentKinds) { + for (const candidate of storage.getAll<{ uid: string } & VaultStorageData>( + kind, + )) { + const candidateUid = (candidate as { uid: string }).uid; + if (!candidateUid) continue; + const dependencies = (await storage.getDependencies(candidateUid)) || []; + for (const dependency of dependencies) { + if ( + dependency.kind === FolderKind.UserFolder || + dependency.kind === FolderKind.SharedFolder || + dependency.kind === FolderKind.SharedFolderFolder + ) { + if (!childToParent.has(dependency.uid)) { + childToParent.set(dependency.uid, { uid: candidateUid, kind }); + } } + } } - return childToParent + } + return childToParent; } export async function listVaultRootFolders(storage: InMemoryStorage): Promise<{ - rows: ListFolderFolderSimple[] - promotedRootSharedUids: Set + rows: ListFolderFolderSimple[]; + promotedRootSharedUids: Set; }> { - const rows: ListFolderFolderSimple[] = [] - const seen = new Set() - const promotedRootSharedUids = new Set() - - for (const userFolder of await listRootUserFolders(storage)) { - if (seen.has(userFolder.uid)) continue - seen.add(userFolder.uid) - rows.push({ - uid: userFolder.uid, - name: userFolderName(userFolder), - folderKind: FolderKind.UserFolder, - }) - } - - const rootDependencies = (await storage.getDependencies('')) || [] - for (const dependency of rootDependencies) { - if (dependency.kind === FolderKind.SharedFolder) { - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, dependency.uid) - if (!sharedFolder || seen.has(sharedFolder.uid)) continue - seen.add(sharedFolder.uid) - rows.push({ - uid: sharedFolder.uid, - name: sharedFolderName(sharedFolder), - folderKind: FolderKind.SharedFolder, - }) - } else if (dependency.kind === FolderKind.SharedFolderFolder) { - const sharedFolderFolder = storage.getByUid( - FolderKind.SharedFolderFolder, - dependency.uid - ) - if (!sharedFolderFolder || seen.has(sharedFolderFolder.uid)) continue - seen.add(sharedFolderFolder.uid) - rows.push({ - uid: sharedFolderFolder.uid, - name: sharedFolderFolderName(sharedFolderFolder), - folderKind: FolderKind.SharedFolderFolder, - }) - } + const rows: ListFolderFolderSimple[] = []; + const seen = new Set(); + const promotedRootSharedUids = new Set(); + + for (const userFolder of await listRootUserFolders(storage)) { + if (seen.has(userFolder.uid)) continue; + seen.add(userFolder.uid); + const color = userFolderColor(userFolder); + rows.push({ + uid: userFolder.uid, + name: userFolderName(userFolder), + folderKind: FolderKind.UserFolder, + ...(color ? { color } : {}), + }); + } + + const rootDependencies = (await storage.getDependencies("")) || []; + for (const dependency of rootDependencies) { + if (dependency.kind === FolderKind.SharedFolder) { + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + dependency.uid, + ); + if (!sharedFolder || seen.has(sharedFolder.uid)) continue; + seen.add(sharedFolder.uid); + rows.push({ + uid: sharedFolder.uid, + name: sharedFolderName(sharedFolder), + folderKind: FolderKind.SharedFolder, + }); + } else if (dependency.kind === FolderKind.SharedFolderFolder) { + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + dependency.uid, + ); + if (!sharedFolderFolder || seen.has(sharedFolderFolder.uid)) continue; + seen.add(sharedFolderFolder.uid); + rows.push({ + uid: sharedFolderFolder.uid, + name: sharedFolderFolderName(sharedFolderFolder), + folderKind: FolderKind.SharedFolderFolder, + }); } - - const parentMap = await buildFolderParentMap(storage) - for (const sharedFolder of storage.getAll(FolderKind.SharedFolder)) { - if (seen.has(sharedFolder.uid)) continue - const parent = parentMap.get(sharedFolder.uid) - if (parent && (parent.kind === FolderKind.SharedFolder || parent.kind === FolderKind.SharedFolderFolder)) { - continue - } - seen.add(sharedFolder.uid) - promotedRootSharedUids.add(sharedFolder.uid) - rows.push({ - uid: sharedFolder.uid, - name: sharedFolderName(sharedFolder), - folderKind: FolderKind.SharedFolder, - }) + } + + const parentMap = await buildFolderParentMap(storage); + for (const sharedFolder of storage.getAll( + FolderKind.SharedFolder, + )) { + if (seen.has(sharedFolder.uid)) continue; + const parent = parentMap.get(sharedFolder.uid); + if ( + parent && + (parent.kind === FolderKind.SharedFolder || + parent.kind === FolderKind.SharedFolderFolder) + ) { + continue; } - - rows.sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) - - return { rows, promotedRootSharedUids } + seen.add(sharedFolder.uid); + promotedRootSharedUids.add(sharedFolder.uid); + rows.push({ + uid: sharedFolder.uid, + name: sharedFolderName(sharedFolder), + folderKind: FolderKind.SharedFolder, + }); + } + + rows.sort((rowA, rowB) => + rowA.name.localeCompare(rowB.name, undefined, { sensitivity: "base" }), + ); + + return { rows, promotedRootSharedUids }; } -function resolveFolderContainer(storage: InMemoryStorage, folderUid: string): { kind: VaultStorageKind; uid: string } { - if (storage.getByUid(FolderKind.UserFolder, folderUid)) { - return { kind: FolderKind.UserFolder, uid: folderUid } - } - if (storage.getByUid(FolderKind.SharedFolder, folderUid)) { - return { kind: FolderKind.SharedFolder, uid: folderUid } - } - if (storage.getByUid(FolderKind.SharedFolderFolder, folderUid)) { - return { kind: FolderKind.SharedFolderFolder, uid: folderUid } - } - throw new KeeperSdkError(`Folder "${folderUid}" not found`, 'folder_not_found') +function resolveFolderContainer( + storage: InMemoryStorage, + folderUid: string, +): { kind: VaultStorageKind; uid: string } { + if (storage.getByUid(FolderKind.UserFolder, folderUid)) { + return { kind: FolderKind.UserFolder, uid: folderUid }; + } + if (storage.getByUid(FolderKind.SharedFolder, folderUid)) { + return { kind: FolderKind.SharedFolder, uid: folderUid }; + } + if ( + storage.getByUid( + FolderKind.SharedFolderFolder, + folderUid, + ) + ) { + return { kind: FolderKind.SharedFolderFolder, uid: folderUid }; + } + throw new KeeperSdkError( + `Folder "${folderUid}" not found`, + "folder_not_found", + ); } -function getRecordMetadata(storage: InMemoryStorage, recordUid: string): DRecordMetadata | undefined { - return storage.getByUid(VaultObjectKind.Metadata, recordUid) +function getRecordMetadata( + storage: InMemoryStorage, + recordUid: string, +): DRecordMetadata | undefined { + return storage.getByUid(VaultObjectKind.Metadata, recordUid); } -export function findFolderUidByNameOrUid(storage: InMemoryStorage, nameOrUid: string): string | undefined { - const trimmedNameOrUid = nameOrUid.trim() - if (!trimmedNameOrUid) return undefined - +export function findFolderUidByNameOrUid( + storage: InMemoryStorage, + nameOrUid: string, +): string | undefined { + const trimmedNameOrUid = nameOrUid.trim(); + if (!trimmedNameOrUid) return undefined; + + if ( + storage.getByUid(FolderKind.UserFolder, trimmedNameOrUid) || + storage.getByUid( + FolderKind.SharedFolder, + trimmedNameOrUid, + ) || + storage.getByUid( + FolderKind.SharedFolderFolder, + trimmedNameOrUid, + ) + ) { + return trimmedNameOrUid; + } + + const lowerNameOrUid = trimmedNameOrUid.toLowerCase(); + for (const userFolder of storage.getAll(FolderKind.UserFolder)) { + if (userFolderName(userFolder).toLowerCase() === lowerNameOrUid) + return userFolder.uid; + } + for (const sharedFolder of storage.getAll( + FolderKind.SharedFolder, + )) { + if (sharedFolderName(sharedFolder).toLowerCase() === lowerNameOrUid) + return sharedFolder.uid; + } + for (const sharedFolderFolder of storage.getAll( + FolderKind.SharedFolderFolder, + )) { if ( - storage.getByUid(FolderKind.UserFolder, trimmedNameOrUid) || - storage.getByUid(FolderKind.SharedFolder, trimmedNameOrUid) || - storage.getByUid(FolderKind.SharedFolderFolder, trimmedNameOrUid) - ) { - return trimmedNameOrUid - } - - const lowerNameOrUid = trimmedNameOrUid.toLowerCase() - for (const userFolder of storage.getAll(FolderKind.UserFolder)) { - if (userFolderName(userFolder).toLowerCase() === lowerNameOrUid) return userFolder.uid - } - for (const sharedFolder of storage.getAll(FolderKind.SharedFolder)) { - if (sharedFolderName(sharedFolder).toLowerCase() === lowerNameOrUid) return sharedFolder.uid - } - for (const sharedFolderFolder of storage.getAll(FolderKind.SharedFolderFolder)) { - if (sharedFolderFolderName(sharedFolderFolder).toLowerCase() === lowerNameOrUid) return sharedFolderFolder.uid - } - return undefined + sharedFolderFolderName(sharedFolderFolder).toLowerCase() === + lowerNameOrUid + ) + return sharedFolderFolder.uid; + } + return undefined; } async function countFolderChildren( - storage: InMemoryStorage, - uid: string + storage: InMemoryStorage, + uid: string, ): Promise<{ records: number; subfolders: number }> { - const dependencies = (await storage.getDependencies(uid)) || [] - let records = 0 - let subfolders = 0 - for (const dependency of dependencies) { - if (dependency.kind === VaultObjectKind.Record) records++ - else if ( - dependency.kind === FolderKind.UserFolder || - dependency.kind === FolderKind.SharedFolder || - dependency.kind === FolderKind.SharedFolderFolder - ) { - subfolders++ - } + const dependencies = (await storage.getDependencies(uid)) || []; + let records = 0; + let subfolders = 0; + for (const dependency of dependencies) { + if (dependency.kind === VaultObjectKind.Record) records++; + else if ( + dependency.kind === FolderKind.UserFolder || + dependency.kind === FolderKind.SharedFolder || + dependency.kind === FolderKind.SharedFolderFolder + ) { + subfolders++; } - return { records, subfolders } + } + return { records, subfolders }; } -export async function listFolder(storage: InMemoryStorage, options: ListFolderOptions = {}): Promise { - const showFolders = options.showFolders !== false - const showRecords = options.showRecords !== false - const detail = options.detail === true - const patternRaw = options.pattern?.trim() || null - const regex = patternRaw ? globToRegex(patternRaw) : null - - const folderUidOpt = options.folderUid - let parentKey: string | null = null - - if (folderUidOpt !== undefined && folderUidOpt !== null && folderUidOpt !== '') { - parentKey = resolveFolderContainer(storage, folderUidOpt).uid +export async function listFolder( + storage: InMemoryStorage, + options: ListFolderOptions = {}, +): Promise { + const showFolders = options.showFolders !== false; + const showRecords = options.showRecords !== false; + const detail = options.detail === true; + const patternRaw = options.pattern?.trim() || null; + const regex = patternRaw ? globToRegex(patternRaw) : null; + + const folderUidOpt = options.folderUid; + let parentKey: string | null = null; + + if ( + folderUidOpt !== undefined && + folderUidOpt !== null && + folderUidOpt !== "" + ) { + parentKey = resolveFolderContainer(storage, folderUidOpt).uid; + } + + const dependencies = + parentKey === null + ? (await storage.getDependencies("")) || [] + : (await storage.getDependencies(parentKey)) || []; + + const folderRows: ListFolderFolderSimple[] = []; + const recordRows: ListFolderRecordSimple[] = []; + + const matches = (name: string, uid: string): boolean => { + if (!regex) return true; + return regex.test(name) || regex.test(uid); + }; + + if (showFolders && parentKey === null) { + const { rows } = await listVaultRootFolders(storage); + for (const row of rows) { + if (!matches(row.name, row.uid)) continue; + folderRows.push(row); } + } - const dependencies = - parentKey === null - ? (await storage.getDependencies('')) || [] - : (await storage.getDependencies(parentKey)) || [] - - const folderRows: ListFolderFolderSimple[] = [] - const recordRows: ListFolderRecordSimple[] = [] - - const matches = (name: string, uid: string): boolean => { - if (!regex) return true - return regex.test(name) || regex.test(uid) - } - - if (showFolders && parentKey === null) { - const { rows } = await listVaultRootFolders(storage) - for (const row of rows) { - if (!matches(row.name, row.uid)) continue - folderRows.push(row) - } - } - - for (const dependency of dependencies) { - if (dependency.kind === FolderKind.UserFolder && showFolders && parentKey !== null) { - const userFolder = storage.getByUid(FolderKind.UserFolder, dependency.uid) - if (!userFolder) continue - const name = userFolderName(userFolder) - if (!matches(name, userFolder.uid)) continue - folderRows.push({ uid: userFolder.uid, name, folderKind: FolderKind.UserFolder }) - } else if (dependency.kind === FolderKind.SharedFolder && showFolders && parentKey !== null) { - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, dependency.uid) - if (!sharedFolder) continue - const name = sharedFolderName(sharedFolder) - if (!matches(name, sharedFolder.uid)) continue - folderRows.push({ uid: sharedFolder.uid, name, folderKind: FolderKind.SharedFolder }) - } else if (dependency.kind === FolderKind.SharedFolderFolder && showFolders && parentKey !== null) { - const sharedFolderFolder = storage.getByUid( - FolderKind.SharedFolderFolder, - dependency.uid - ) - if (!sharedFolderFolder) continue - const name = sharedFolderFolderName(sharedFolderFolder) - if (!matches(name, sharedFolderFolder.uid)) continue - folderRows.push({ - uid: sharedFolderFolder.uid, - name, - folderKind: FolderKind.SharedFolderFolder, - }) - } else if (dependency.kind === VaultObjectKind.Record && showRecords) { - const record = storage.getByUid(VaultObjectKind.Record, dependency.uid) - if (!record || (record.version !== 2 && record.version !== 3)) continue - const title = getRecordTitle(record) - if (!matches(title, record.uid)) continue - recordRows.push({ - uid: record.uid, - name: title, - type: getRecordType(record), - }) - } + for (const dependency of dependencies) { + if ( + dependency.kind === FolderKind.UserFolder && + showFolders && + parentKey !== null + ) { + const userFolder = storage.getByUid( + FolderKind.UserFolder, + dependency.uid, + ); + if (!userFolder) continue; + const name = userFolderName(userFolder); + if (!matches(name, userFolder.uid)) continue; + const color = userFolderColor(userFolder); + folderRows.push({ + uid: userFolder.uid, + name, + folderKind: FolderKind.UserFolder, + ...(color ? { color } : {}), + }); + } else if ( + dependency.kind === FolderKind.SharedFolder && + showFolders && + parentKey !== null + ) { + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + dependency.uid, + ); + if (!sharedFolder) continue; + const name = sharedFolderName(sharedFolder); + if (!matches(name, sharedFolder.uid)) continue; + folderRows.push({ + uid: sharedFolder.uid, + name, + folderKind: FolderKind.SharedFolder, + }); + } else if ( + dependency.kind === FolderKind.SharedFolderFolder && + showFolders && + parentKey !== null + ) { + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + dependency.uid, + ); + if (!sharedFolderFolder) continue; + const name = sharedFolderFolderName(sharedFolderFolder); + if (!matches(name, sharedFolderFolder.uid)) continue; + folderRows.push({ + uid: sharedFolderFolder.uid, + name, + folderKind: FolderKind.SharedFolderFolder, + }); + } else if (dependency.kind === VaultObjectKind.Record && showRecords) { + const record = storage.getByUid( + VaultObjectKind.Record, + dependency.uid, + ); + if (!record || (record.version !== 2 && record.version !== 3)) continue; + const title = getRecordTitle(record); + if (!matches(title, record.uid)) continue; + recordRows.push({ + uid: record.uid, + name: title, + type: getRecordType(record), + }); } - - folderRows.sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) - recordRows.sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) - - if (!detail) { - return { detail: false, folders: folderRows, records: recordRows } + } + + if (options.recursive === true && folderRows.length > 0) { + const seenFolderUids = new Set(folderRows.map((row) => row.uid)); + const seenRecordUids = new Set(recordRows.map((row) => row.uid)); + for (const childUid of folderRows.map((row) => row.uid)) { + const sub = await listFolder(storage, { + folderUid: childUid, + showFolders, + showRecords, + detail: false, + pattern: null, + recursive: true, + }); + if (sub.detail) continue; + for (const folder of sub.folders) { + if (seenFolderUids.has(folder.uid)) continue; + seenFolderUids.add(folder.uid); + folderRows.push(folder); + } + for (const record of sub.records) { + if (seenRecordUids.has(record.uid)) continue; + seenRecordUids.add(record.uid); + recordRows.push(record); + } } - - const folderDetails: ListFolderFolderDetail[] = await Promise.all( - folderRows.map(async (row) => { - const counts = await countFolderChildren(storage, row.uid) - return { - ...row, - flags: buildFolderFlags(row.folderKind), - recordCount: counts.records, - subfolderCount: counts.subfolders, - } - }) - ) - - const recordDetails: ListFolderRecordDetail[] = recordRows.map((row) => { - const record = storage.getByUid(VaultObjectKind.Record, row.uid)! - const metadata = getRecordMetadata(storage, row.uid) - return { - ...row, - flags: buildRecordFlags(record, metadata), - version: record.version, - isOwner: metadata?.owner === true, - hasAttachments: recordHasAttachments(record), - isShared: !!record.shared, - } - }) - - return { detail: true, folders: folderDetails, records: recordDetails } + } + + folderRows.sort((rowA, rowB) => + rowA.name.localeCompare(rowB.name, undefined, { sensitivity: "base" }), + ); + recordRows.sort((rowA, rowB) => + rowA.name.localeCompare(rowB.name, undefined, { sensitivity: "base" }), + ); + + if (!detail) { + return { detail: false, folders: folderRows, records: recordRows }; + } + + const folderDetails: ListFolderFolderDetail[] = await Promise.all( + folderRows.map(async (row) => { + const counts = await countFolderChildren(storage, row.uid); + return { + ...row, + flags: buildFolderFlags(row.folderKind), + recordCount: counts.records, + subfolderCount: counts.subfolders, + }; + }), + ); + + const recordDetails: ListFolderRecordDetail[] = recordRows.map((row) => { + const record = storage.getByUid(VaultObjectKind.Record, row.uid)!; + const metadata = getRecordMetadata(storage, row.uid); + return { + ...row, + flags: buildRecordFlags(record, metadata), + version: record.version, + isOwner: metadata?.owner === true, + hasAttachments: recordHasAttachments(record), + isShared: !!record.shared, + }; + }); + + return { detail: true, folders: folderDetails, records: recordDetails }; } diff --git a/KeeperSdk/src/folders/updateFolder.ts b/KeeperSdk/src/folders/updateFolder.ts index d444c56f..32f1a512 100644 --- a/KeeperSdk/src/folders/updateFolder.ts +++ b/KeeperSdk/src/folders/updateFolder.ts @@ -1,211 +1,280 @@ -import type { Auth, FolderUpdateRequest } from '@keeper-security/keeperapi' +import type { Auth, FolderUpdateRequest } from "@keeper-security/keeperapi"; import { - encryptForStorage, - encryptObjectForStorage, - folderUpdateCommand, - platform, -} from '@keeper-security/keeperapi' -import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { anyIsBoolean, isBoolean, isObject, KeeperSdkError, extractErrorMessage } from '../utils' -import { resolveSingleFolder, type VaultFolderSession } from './changeDirectory' -import { FolderKind, FolderResultStatus, validateFolderName } from './folderHelpers' + encryptForStorage, + encryptObjectForStorage, + folderUpdateCommand, + platform, +} from "@keeper-security/keeperapi"; +import type { + DSharedFolder, + DSharedFolderFolder, + DUserFolder, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { + anyIsBoolean, + isBoolean, + isObject, + KeeperSdkError, + extractErrorMessage, +} from "../utils"; +import { + resolveSingleFolder, + type VaultFolderSession, +} from "./changeDirectory"; +import { + FolderKind, + FolderResultStatus, + validateFolderName, +} from "./folderHelpers"; export type UpdateFolderInput = { - folderUid: string - folderName?: string | null - manageUsers?: boolean | null - manageRecords?: boolean | null - canShare?: boolean | null - canEdit?: boolean | null -} + folderUid: string; + folderName?: string | null; + manageUsers?: boolean | null; + manageRecords?: boolean | null; + canShare?: boolean | null; + canEdit?: boolean | null; +}; export type UpdateFolderResult = { - folderUid: string - success: boolean - message?: string -} + folderUid: string; + success: boolean; + message?: string; +}; export type RenameFolderResult = { - folderUid: string - oldName: string - newName: string - success: boolean - message?: string -} + folderUid: string; + oldName: string; + newName: string; + success: boolean; + message?: string; +}; type ResolvedFolder = - | { kind: FolderKind.UserFolder; folder: DUserFolder } - | { kind: FolderKind.SharedFolder; folder: DSharedFolder } - | { kind: FolderKind.SharedFolderFolder; folder: DSharedFolderFolder } - -function resolveFolderEntity(storage: InMemoryStorage, folderUid: string): ResolvedFolder | undefined { - const userFolder = storage.getByUid(FolderKind.UserFolder, folderUid) - if (userFolder) return { kind: FolderKind.UserFolder, folder: userFolder } - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, folderUid) - if (sharedFolder) return { kind: FolderKind.SharedFolder, folder: sharedFolder } - const sharedFolderFolder = storage.getByUid(FolderKind.SharedFolderFolder, folderUid) - if (sharedFolderFolder) return { kind: FolderKind.SharedFolderFolder, folder: sharedFolderFolder } - return undefined + | { kind: FolderKind.UserFolder; folder: DUserFolder } + | { kind: FolderKind.SharedFolder; folder: DSharedFolder } + | { kind: FolderKind.SharedFolderFolder; folder: DSharedFolderFolder }; + +function resolveFolderEntity( + storage: InMemoryStorage, + folderUid: string, +): ResolvedFolder | undefined { + const userFolder = storage.getByUid( + FolderKind.UserFolder, + folderUid, + ); + if (userFolder) return { kind: FolderKind.UserFolder, folder: userFolder }; + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + folderUid, + ); + if (sharedFolder) + return { kind: FolderKind.SharedFolder, folder: sharedFolder }; + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + folderUid, + ); + if (sharedFolderFolder) + return { kind: FolderKind.SharedFolderFolder, folder: sharedFolderFolder }; + return undefined; } -function mergeFolderData(existing: unknown, folderName: string | null | undefined): Record { - const base = isObject(existing) ? { ...existing } : {} - const trimmed = folderName?.trim() - if (trimmed) { - base.title = trimmed - base.name = trimmed - } - return base +function mergeFolderData( + existing: unknown, + folderName: string | null | undefined, +): Record { + const base = isObject(existing) ? { ...existing } : {}; + const trimmed = folderName?.trim(); + if (trimmed) { + base.title = trimmed; + base.name = trimmed; + } + return base; } export async function updateFolder( - auth: Auth, - storage: InMemoryStorage, - input: UpdateFolderInput + auth: Auth, + storage: InMemoryStorage, + input: UpdateFolderInput, ): Promise { - const folderUid = input.folderUid - const resolved = resolveFolderEntity(storage, folderUid) - if (!resolved) { - throw new KeeperSdkError(`Folder "${folderUid}" does not exist.`, 'folder_not_found') - } + const folderUid = input.folderUid; + const resolved = resolveFolderEntity(storage, folderUid); + if (!resolved) { + throw new KeeperSdkError( + `Folder "${folderUid}" does not exist.`, + "folder_not_found", + ); + } - const folderKey = await storage.getKeyBytes(folderUid) - if (!folderKey) { - throw new KeeperSdkError( - 'Folder encryption key not available. Sync the vault and try again.', - 'folder_key_missing' - ) - } + const folderKey = await storage.getKeyBytes(folderUid); + if (!folderKey) { + throw new KeeperSdkError( + "Folder encryption key not available. Sync the vault and try again.", + "folder_key_missing", + ); + } - const trimmedName = input.folderName?.trim() || '' - if (trimmedName) validateFolderName(trimmedName) - - const hasPermissionUpdate = anyIsBoolean( - input.manageUsers, - input.manageRecords, - input.canShare, - input.canEdit - ) - - if (resolved.kind === FolderKind.UserFolder || resolved.kind === FolderKind.SharedFolderFolder) { - if (!trimmedName) { - throw new KeeperSdkError('Folder name is required.', 'folder_name_required') - } - } else if (resolved.kind === FolderKind.SharedFolder) { - if (!trimmedName && !hasPermissionUpdate) { - throw new KeeperSdkError( - 'Provide a new name or at least one permission to update.', - 'shared_folder_update_empty' - ) - } - } + const trimmedName = input.folderName?.trim() || ""; + if (trimmedName) validateFolderName(trimmedName); - const effectiveName = - resolved.kind === FolderKind.SharedFolder ? trimmedName || resolved.folder.name || undefined : trimmedName - const mergedData = mergeFolderData(resolved.folder.data, effectiveName) + const hasPermissionUpdate = anyIsBoolean( + input.manageUsers, + input.manageRecords, + input.canShare, + input.canEdit, + ); - const request: FolderUpdateRequest = { - folder_uid: folderUid, - folder_type: resolved.kind, - data: await encryptObjectForStorage(mergedData, folderKey), + if ( + resolved.kind === FolderKind.UserFolder || + resolved.kind === FolderKind.SharedFolderFolder + ) { + if (!trimmedName) { + throw new KeeperSdkError( + "Folder name is required.", + "folder_name_required", + ); } - - if (resolved.kind === FolderKind.SharedFolder) { - const sharedFolder = resolved.folder - const displayName = trimmedName || sharedFolder.name || folderUid - request.shared_folder_uid = folderUid - request.name = await encryptForStorage(platform.stringToBytes(displayName), folderKey) - request.manage_users = isBoolean(input.manageUsers) ? input.manageUsers : sharedFolder.defaultManageUsers - request.manage_records = isBoolean(input.manageRecords) - ? input.manageRecords - : sharedFolder.defaultManageRecords - request.can_edit = isBoolean(input.canEdit) ? input.canEdit : sharedFolder.defaultCanEdit - request.can_share = isBoolean(input.canShare) ? input.canShare : sharedFolder.defaultCanShare - } else if (resolved.kind === FolderKind.SharedFolderFolder) { - request.shared_folder_uid = resolved.folder.sharedFolderUid + } else if (resolved.kind === FolderKind.SharedFolder) { + if (!trimmedName && !hasPermissionUpdate) { + throw new KeeperSdkError( + "Provide a new name or at least one permission to update.", + "shared_folder_update_empty", + ); } + } - const folderLabel = effectiveName || folderUid - - try { - const response = await auth.executeRestCommand(folderUpdateCommand(request)) - const succeeded = - response.result === FolderResultStatus.Success || response.result_code === FolderResultStatus.Success - if (!succeeded) { - const reason = - response.message || - response.result_code || - `folder_update failed for "${folderLabel}" (uid=${folderUid}, type=${resolved.kind}): server returned no message or result_code` - return { - folderUid, - success: false, - message: reason, - } - } - return { folderUid, success: true } - } catch (err) { - return { - folderUid, - success: false, - message: `folder_update failed for "${folderLabel}" (uid=${folderUid}, type=${resolved.kind}): ${extractErrorMessage(err)}`, - } + const effectiveName = + resolved.kind === FolderKind.SharedFolder + ? trimmedName || resolved.folder.name || undefined + : trimmedName; + const mergedData = mergeFolderData(resolved.folder.data, effectiveName); + + const request: FolderUpdateRequest = { + folder_uid: folderUid, + folder_type: resolved.kind, + data: await encryptObjectForStorage(mergedData, folderKey), + }; + + if (resolved.kind === FolderKind.SharedFolder) { + const sharedFolder = resolved.folder; + const displayName = trimmedName || sharedFolder.name || folderUid; + request.shared_folder_uid = folderUid; + request.name = await encryptForStorage( + platform.stringToBytes(displayName), + folderKey, + ); + request.manage_users = isBoolean(input.manageUsers) + ? input.manageUsers + : sharedFolder.defaultManageUsers; + request.manage_records = isBoolean(input.manageRecords) + ? input.manageRecords + : sharedFolder.defaultManageRecords; + request.can_edit = isBoolean(input.canEdit) + ? input.canEdit + : sharedFolder.defaultCanEdit; + request.can_share = isBoolean(input.canShare) + ? input.canShare + : sharedFolder.defaultCanShare; + } else if (resolved.kind === FolderKind.SharedFolderFolder) { + request.shared_folder_uid = resolved.folder.sharedFolderUid; + } + + const folderLabel = effectiveName || folderUid; + + try { + const response = await auth.executeRestCommand( + folderUpdateCommand(request), + ); + const succeeded = + response.result === FolderResultStatus.Success || + response.result_code === FolderResultStatus.Success; + if (!succeeded) { + const reason = + response.message || + response.result_code || + `folder_update failed for "${folderLabel}" (uid=${folderUid}, type=${resolved.kind}): server returned no message or result_code`; + return { + folderUid, + success: false, + message: reason, + }; } + return { folderUid, success: true }; + } catch (err) { + return { + folderUid, + success: false, + message: `folder_update failed for "${folderLabel}" (uid=${folderUid}, type=${resolved.kind}): ${extractErrorMessage(err)}`, + }; + } } export async function renameFolder( - auth: Auth, - storage: InMemoryStorage, - session: VaultFolderSession, - folderPath: string, - newName: string + auth: Auth, + storage: InMemoryStorage, + session: VaultFolderSession, + folderPath: string, + newName: string, ): Promise { - const trimmedPath = folderPath.trim() - if (!trimmedPath) { - throw new KeeperSdkError('Folder cannot be empty.', 'folder_required') - } - const trimmedName = newName.trim() - if (!trimmedName) { - throw new KeeperSdkError('New folder name is required.', 'folder_name_required') - } + const trimmedPath = folderPath.trim(); + if (!trimmedPath) { + throw new KeeperSdkError("Folder cannot be empty.", "folder_required"); + } + const trimmedName = newName.trim(); + if (!trimmedName) { + throw new KeeperSdkError( + "New folder name is required.", + "folder_name_required", + ); + } - const resolved = await resolveSingleFolder(storage, session, trimmedPath) - if (resolved.folderUid === null) { - throw new KeeperSdkError('Cannot rename the root folder.', 'folder_root_rename') - } + const resolved = await resolveSingleFolder(storage, session, trimmedPath); + if (resolved.folderUid === null) { + throw new KeeperSdkError( + "Cannot rename the root folder.", + "folder_root_rename", + ); + } - const result = await updateFolder(auth, storage, { - folderUid: resolved.folderUid, - folderName: trimmedName, - }) + const result = await updateFolder(auth, storage, { + folderUid: resolved.folderUid, + folderName: trimmedName, + }); - return { - folderUid: resolved.folderUid, - oldName: resolved.name, - newName: trimmedName, - success: result.success, - message: result.message, - } + return { + folderUid: resolved.folderUid, + oldName: resolved.name, + newName: trimmedName, + success: result.success, + message: result.message, + }; } export async function updateSharedFolderPermissions( - auth: Auth, - storage: InMemoryStorage, - sharedFolderUid: string, - permissions: { - manageUsers?: boolean | null - manageRecords?: boolean | null - canShare?: boolean | null - canEdit?: boolean | null - } + auth: Auth, + storage: InMemoryStorage, + sharedFolderUid: string, + permissions: { + manageUsers?: boolean | null; + manageRecords?: boolean | null; + canShare?: boolean | null; + canEdit?: boolean | null; + }, ): Promise { - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, sharedFolderUid) - if (!sharedFolder) { - throw new KeeperSdkError(`"${sharedFolderUid}" is not a shared folder.`, 'not_shared_folder') - } - return updateFolder(auth, storage, { - folderUid: sharedFolderUid, - folderName: null, - ...permissions, - }) + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + sharedFolderUid, + ); + if (!sharedFolder) { + throw new KeeperSdkError( + `"${sharedFolderUid}" is not a shared folder.`, + "not_shared_folder", + ); + } + return updateFolder(auth, storage, { + folderUid: sharedFolderUid, + folderName: null, + ...permissions, + }); } diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index f6dc11de..6e8ecff9 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -1,627 +1,836 @@ -export { ConsoleAuthUI } from './auth/ConsoleAuthUI' -export { SessionManager, FileConfigLoader } from './auth/SessionManager' -export type { - KeeperJsonConfig, - ConfigLoader, - ConfigurationUser, - ConfigurationServerConfig, - ConfigurationDeviceConfig, -} from './auth/SessionManager' -export { login, cleanup, prompt, suppressLogs, loadKeeperConfig, resolveServer } from './auth/ConsoleLogin' +import { connectSdkPlatform } from "./platform"; +import { nodeSdkPlatform } from "./platform/node/platform"; + +connectSdkPlatform(nodeSdkPlatform); -export { InMemoryStorage } from './storage/InMemoryStorage' +export * from "./api"; +/** Node.js Commander / CLI helpers (readline, ~/.keeper config). */ +export { ConsoleAuthUI } from "./auth/ConsoleAuthUI"; +export { FileConfigLoader } from "./auth/node/FileConfigLoader"; export { - Logger, - ConsoleLogger, - LogLevel, - logger, - setLogger, - getLogger, - resetLogger, - writeOutput, - KeeperSdkError, - isKeeperError, - extractErrorMessage, - extractResultCode, - SdkDefaults, - AuthDefaults, - NsfErrorCode, - ResultCodes, - AuthErrorCode, - SessionErrorCode, - ValidationErrorCode, - RoleErrorCode, - TeamErrorCode, - UserErrorCode, - AuditReportErrorCode, - ActionReportErrorCode, - PasswordReportErrorCode, - KEEPER_PUBLIC_HOSTS, - isBoolean, - isString, - isNonEmptyString, - isNumber, - isObject, - anyIsBoolean, - EMAIL_PATTERN, - EMAIL_LIST_SEPARATOR_PATTERN, - isValidEmail, - resolveSearchPattern, -} from './utils' -export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' + login, + cleanup, + prompt, + suppressLogs, + loadKeeperConfig, + resolveServer, +} from "./auth/ConsoleLogin"; + +export { InMemoryStorage } from "./storage/InMemoryStorage"; export { - searchRecords, - formatRecord, - getRecordTitle, - getRecordType, - getRecordFields, - getRecordSummary, - getRecordPassword, - getRecordLogin, - getRecordUrl, - getRecordTotpUrl, - RecordVersion, -} from './records/RecordUtils' -export type { RecordSummary } from './records/RecordUtils' -export { parseTotpUrl, getTotpCode } from './records/Totp' -export type { TotpAlgorithm, TotpParams, TotpCode } from './records/Totp' -export { addRecord, updateRecord, deleteRecord, getRecordHistory, moveRecord } from './records/RecordOperations' + ConsoleLogger, + LogLevel, + logger, + setLogger, + getLogger, + resetLogger, + writeOutput, + KeeperSdkError, + isKeeperError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + AuthDefaults, + NsfErrorCode, + ResultCodes, + AuthErrorCode, + SessionErrorCode, + ValidationErrorCode, + RoleErrorCode, + TeamErrorCode, + UserErrorCode, + AuditReportErrorCode, + ActionReportErrorCode, + PasswordReportErrorCode, + KEEPER_PUBLIC_HOSTS, + isBoolean, + isString, + isNonEmptyString, + isNumber, + isObject, + anyIsBoolean, + EMAIL_PATTERN, + EMAIL_LIST_SEPARATOR_PATTERN, + isValidEmail, + resolveSearchPattern, + DEFAULT_PASSWORD_LENGTH, + PW_SPECIAL_CHARACTERS, + GEN_PASSWORD_ALGORITHMS, + KeeperPasswordGenerator, + generatePasswordFromOptions, + resolveGenPasswordAlgorithm, + generatePassword, + parseGenParametersFromValue, + isGenerateFieldValue, + parseGeneratePasswordFlag, +} from "./utils"; export type { - PasswordRecordData, - TypedRecordData, - RecordFieldInput, - NewRecordInput, - AddRecordResult, - UpdateRecordResult, - DeleteRecordResult, - HistoryEntry, - RecordHistoryResult, - MoveRecordInput, - MoveRecordResult, -} from './records/RecordOperations' - -export { shareRecord, removeRecordShare, getRecordShareInfo } from './sharing/Sharing' + ILogger, + Nullable, + Optional, + DeepPartial, + Immutable, + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, +} from "./utils"; + +export { + addRecord, + updateRecord, + deleteRecord, + getRecordHistory, + moveRecord, +} from "./records/RecordOperations"; export type { - ShareRecordInput, - ShareRecordResult, - RemoveShareInput, - RemoveShareResult, - RecordShareInfo, - RecordUserPermission, - RecordSharedFolderPermission, -} from './sharing/Sharing' - -export { KeeperVault } from './vault/KeeperVault' -export type { KeeperVaultConfig, VaultSummary } from './vault/KeeperVault' - -export { getFolder, findFolder, GetFolderFormat } from './folders/getFolder' + PasswordRecordData, + TypedRecordData, + RecordFieldInput, + NewRecordInput, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + HistoryEntry, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from "./records/RecordOperations"; + +export { + shareRecord, + removeRecordShare, + getRecordShareInfo, +} from "./sharing/Sharing"; export type { - GetFolderOptions, - GetFolderResult, - GetFolderResultFolder, - GetFolderResultSharedFolder, - GetFolderFormatInput, - FoundFolder, -} from './folders/getFolder' + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, + RecordShareInfo, + RecordUserPermission, + RecordSharedFolderPermission, +} from "./sharing/Sharing"; + +export { KeeperVault } from "./vault/KeeperVault"; +export type { KeeperVaultConfig, VaultSummary } from "./vault/KeeperVault"; + +export { getFolder, findFolder, GetFolderFormat } from "./folders/getFolder"; +export type { + GetFolderOptions, + GetFolderResult, + GetFolderResultFolder, + GetFolderResultSharedFolder, + GetFolderFormatInput, + FoundFolder, +} from "./folders/getFolder"; + +export { + FolderKind, + ParentFolderKind, + FolderObjectType, + FolderResultStatus, + DeleteResolution, + DeleteObjectType, + folderKindFromString, +} from "./folders/folderHelpers"; +export type { FolderKindOrLiteral } from "./folders/folderHelpers"; export { - FolderKind, - ParentFolderKind, - FolderObjectType, - FolderResultStatus, - DeleteResolution, - DeleteObjectType, - folderKindFromString, -} from './folders/folderHelpers' -export type { FolderKindOrLiteral } from './folders/folderHelpers' - -export { listFolder, findFolderUidByNameOrUid, listRootUserFolders } from './folders/listFolder' + listFolder, + findFolderUidByNameOrUid, + listRootUserFolders, +} from "./folders/listFolder"; export type { - ListFolderOptions, - ListFolderResult, - ListFolderFolderSimple, - ListFolderRecordSimple, - ListFolderFolderDetail, - ListFolderRecordDetail, -} from './folders/listFolder' + ListFolderOptions, + ListFolderResult, + ListFolderFolderSimple, + ListFolderRecordSimple, + ListFolderFolderDetail, + ListFolderRecordDetail, +} from "./folders/listFolder"; export { - listSharedFolders, - formatSharedFoldersTable, - renderSharedFoldersAsciiTable, -} from './sharedFolders/listSharedFolders' + listSharedFolders, + formatSharedFoldersTable, + renderSharedFoldersAsciiTable, +} from "./sharedFolders/listSharedFolders"; export type { - ListSharedFoldersOptions, - ListSharedFolderRow, - FormattedSharedFoldersTable, -} from './sharedFolders/listSharedFolders' + ListSharedFoldersOptions, + ListSharedFolderRow, + FormattedSharedFoldersTable, +} from "./sharedFolders/listSharedFolders"; -export { shareFolder, ShareFolderAction, ShareFolderUserResultStatus } from './sharedFolders/shareFolder' +export { + shareFolder, + updateSharedFolderMembership, + ShareFolderAction, + ShareFolderUserResultStatus, +} from "./sharedFolders/shareFolder"; export type { - ShareFolderActionInput, - ShareFolderInput, - ShareFolderResult, - ShareFolderUserStatus, -} from './sharedFolders/shareFolder' + ShareFolderActionInput, + ShareFolderInput, + ShareFolderResult, + ShareFolderUserStatus, + SharedFolderMembershipUserGrant, + SharedFolderMembershipTeamGrant, + UpdateSharedFolderMembershipInput, + SharedFolderMembershipUpdateResult, +} from "./sharedFolders/shareFolder"; export { - changeDirectory, - createVaultFolderSession, - tryResolvePath, - resolveSingleFolder, - getWorkingFolderDisplayName, - findParentFolderUid, - splitPathComponents, -} from './folders/changeDirectory' -export type { VaultFolderSession, ChangeDirectoryResult, TryResolvePathResult } from './folders/changeDirectory' - -export { addFolder, mkdir } from './folders/addFolder' -export type { AddFolderInput, AddFolderResult, MkdirOptions } from './folders/addFolder' - -export { updateFolder, renameFolder, updateSharedFolderPermissions } from './folders/updateFolder' -export type { UpdateFolderInput, UpdateFolderResult, RenameFolderResult } from './folders/updateFolder' + downloadMembership, + buildAccountUidEmailMap, + resolveUserEmail, +} from "./sharedFolders/downloadMembership"; +export type { + MembershipPermission, + MembershipSharedFolder, + MembershipTeam, + MembershipData, + DownloadMembershipOptions, +} from "./sharedFolders/downloadMembership"; + +export { applyMembership } from "./sharedFolders/applyMembership"; +export type { + ApplyMembershipInput, + ApplyMembershipOptions, + ApplyMembershipCounts, + ApplyMembershipFolderResult, + ApplyMembershipTeamMembershipResult, + ApplyMembershipResult, +} from "./sharedFolders/applyMembership"; export { - deleteFolder, - rmdir, - resolveRmdirPatternsToFolderUids, - buildFolderDeleteObject, -} from './folders/deleteFolder' -export type { DeleteFolderResult, RmdirOptions } from './folders/deleteFolder' + changeDirectory, + createVaultFolderSession, + tryResolvePath, + resolveSingleFolder, + getWorkingFolderDisplayName, + findParentFolderUid, + splitPathComponents, +} from "./folders/changeDirectory"; +export type { + VaultFolderSession, + ChangeDirectoryResult, + TryResolvePathResult, +} from "./folders/changeDirectory"; + +export { addFolder, mkdir } from "./folders/addFolder"; +export type { + AddFolderInput, + AddFolderResult, + MkdirOptions, +} from "./folders/addFolder"; export { - buildFolderTree, - renderFolderTreeAscii, - folderTreeAscii, - userPermissionToText, - recordPermissionToText, -} from './folders/folderTree' -export type { FolderTreeBuildOptions, FolderTreeNode, FolderTreeResult } from './folders/folderTree' + updateFolder, + renameFolder, + updateSharedFolderPermissions, +} from "./folders/updateFolder"; +export type { + UpdateFolderInput, + UpdateFolderResult, + RenameFolderResult, +} from "./folders/updateFolder"; -export { FolderManager } from './folders/FolderManager' -export type { AuthProvider, SharedFolderPermissionsInput } from './folders/FolderManager' +export { + deleteFolder, + rmdir, + resolveRmdirPatternsToFolderUids, + buildFolderDeleteObject, +} from "./folders/deleteFolder"; +export type { DeleteFolderResult, RmdirOptions } from "./folders/deleteFolder"; -export { SharedFolderManager } from './sharedFolders/SharedFolderManager' +export { + buildFolderTree, + renderFolderTreeAscii, + folderTreeAscii, + userPermissionToText, + recordPermissionToText, +} from "./folders/folderTree"; export { - listTeams, - formatTeamsTable, - renderTeamsAsciiTable, - formatTeamRestricts, - TeamColumn, - SUPPORTED_TEAM_COLUMNS, - DEFAULT_TEAM_COLUMNS, -} from './teams/listTeams' + runAuditReport, + runActionReport, + runPasswordReport, + getPasswordStrength, + calculatePasswordScore, + parsePasswordPolicy, + isPasswordCompliant, + buildPasswordPolicySummary, + getAllowedActions, + getDefaultDaysSince, + AuditReportManager, + ActionReportManager, + EnterpriseReportManager, + AuditReportOrder, + AuditReportFormat, + AuditOutputFormat, + AuditAggregate, + SUMMARY_REPORT_TYPES, + CREATED_PRESETS, + TargetUserStatus, + AdminAction, + ActionReportColumn, + DEFAULT_ACTION_REPORT_COLUMNS, + SUPPORTED_ACTION_REPORT_COLUMNS, + DEFAULT_TRUNCATION_LENGTH, + SUPPORTED_RECORD_VERSIONS, +} from "./enterpriseReport"; export type { - ListTeamsOptions, - ListTeamRow, - TeamColumnInput, - FormattedTeamsTable, - FormatTeamsTableOptions, -} from './teams/listTeams' + AuditReportOptions, + AuditReportResult, + AuditReportFilter, + CreatedFilterCriteria, + CreatedPreset, + AuditEventOverviewReportRow, + AuditReportType, + AuditSummaryReportType, + ActionReportEntry, + ActionReportOptions, + ActionReportResult, + ActionResult, + PasswordPolicy, + PasswordStrength, + PasswordReportRow, + PasswordReportOptions, + PasswordReportResult, +} from "./enterpriseReport"; + +export type { + FolderTreeBuildOptions, + FolderTreeNode, + FolderTreeResult, +} from "./folders/folderTree"; + +export { FolderManager } from "./folders/FolderManager"; +export type { + AuthProvider, + SharedFolderPermissionsInput, +} from "./folders/FolderManager"; + +export { SharedFolderManager } from "./sharedFolders/SharedFolderManager"; export { - EnterpriseDataInclude, - EnterpriseDataManager, -} from './teams/enterpriseData' + listTeams, + formatTeamsTable, + renderTeamsAsciiTable, + formatTeamRestricts, + TeamColumn, + SUPPORTED_TEAM_COLUMNS, + DEFAULT_TEAM_COLUMNS, +} from "./teams/listTeams"; export type { - EnterpriseDataManagerApi, - GetEnterpriseDataResponse, - EnterpriseTeamRecord, - EnterpriseTeamUserLink, - EnterpriseRoleUserLink, - EnterpriseRoleTeamLink, - EnterpriseRolePrivilegeLink, - EnterpriseRoleManagedNodeLink, - EnterpriseRoleEnforcementLink, - EnterpriseQueuedTeamRecord, - EnterpriseQueuedTeamUserLink, - EnterpriseUserAliasLink, - EnterpriseUser, - EnterpriseRole, - EnterpriseNode, - DecryptedNodeNames, - DecryptedRoleNames, - EnterpriseDisplayNames, - NodePathOptions, -} from './teams/enterpriseData' + ListTeamsOptions, + ListTeamRow, + TeamColumnInput, + FormattedTeamsTable, + FormatTeamsTableOptions, +} from "./teams/listTeams"; export { - listRoles, - formatRolesTable, - renderRolesAsciiTable, - RoleColumn, - SUPPORTED_ROLE_COLUMNS, - DEFAULT_ROLE_COLUMNS, - ALL_COLUMNS_WILDCARD, - viewRole, - formatRoleView, - roleViewTable, - RoleManager, - addRoles, - formatAddRoleResult, - renderAddRoleAsciiTable, - AddRoleStatus, - AddRoleSkipReason, - updateRoles, - formatUpdateRoleResult, - renderUpdateRoleAsciiTable, - UpdateRoleStatus, - deleteRoles, - formatDeleteRoleResult, - renderDeleteRoleAsciiTable, - DeleteRoleStatus, -} from './roles' + EnterpriseDataInclude, + EnterpriseDataManager, +} from "./teams/enterpriseData"; export type { - ListRolesOptions, - ListRoleRow, - RoleColumnInput, - FormattedRolesTable, - FormatRolesTableOptions, - RoleView, - RoleTeamInfo, - RoleUserInfo, - RoleManagedNodeInfo, - RoleEnforcementInfo, - FormatRoleViewOptions, - FormattedRoleViewTable, - FormattedManagedNodePrivilegeTable, - RoleViewTableRow, - AddRoleInput, - AddRoleResult, - AddRoleItemResult, - AddRoleConfirm, - AddRoleConflictPrompt, - FormattedAddRoleTable, - UpdateRoleInput, - UpdateRoleResult, - UpdateRoleItemResult, - FormattedUpdateRoleTable, - DeleteRoleInput, - DeleteRoleResult, - DeleteRoleItemResult, - FormattedDeleteRoleTable, - RoleToggleInput, - RoleToggle, - EnforcementPair, -} from './roles' + EnterpriseDataManagerApi, + GetEnterpriseDataResponse, + EnterpriseTeamRecord, + EnterpriseTeamUserLink, + EnterpriseRoleUserLink, + EnterpriseRoleTeamLink, + EnterpriseRolePrivilegeLink, + EnterpriseRoleManagedNodeLink, + EnterpriseRoleEnforcementLink, + EnterpriseQueuedTeamRecord, + EnterpriseQueuedTeamUserLink, + EnterpriseUserAliasLink, + EnterpriseUser, + EnterpriseRole, + EnterpriseNode, + DecryptedNodeNames, + DecryptedRoleNames, + EnterpriseDisplayNames, + NodePathOptions, +} from "./teams/enterpriseData"; export { - runAuditReport, - runActionReport, - runPasswordReport, - getPasswordStrength, - calculatePasswordScore, - parsePasswordPolicy, - isPasswordCompliant, - buildPasswordPolicySummary, - getAllowedActions, - getDefaultDaysSince, - AuditReportManager, - ActionReportManager, - EnterpriseReportManager, - AuditReportOrder, - AuditReportFormat, - AuditOutputFormat, - AuditAggregate, - SUMMARY_REPORT_TYPES, - CREATED_PRESETS, - TargetUserStatus, - AdminAction, - ActionReportColumn, - DEFAULT_ACTION_REPORT_COLUMNS, - SUPPORTED_ACTION_REPORT_COLUMNS, - PW_SPECIAL_CHARACTERS, - DEFAULT_TRUNCATION_LENGTH, - SUPPORTED_RECORD_VERSIONS, -} from './enterpriseReport' + listRoles, + formatRolesTable, + renderRolesAsciiTable, + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + DEFAULT_ROLE_COLUMNS, + ALL_COLUMNS_WILDCARD, + viewRole, + formatRoleView, + roleViewTable, + RoleManager, + addRoles, + formatAddRoleResult, + renderAddRoleAsciiTable, + AddRoleStatus, + AddRoleSkipReason, + updateRoles, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + UpdateRoleStatus, + deleteRoles, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + DeleteRoleStatus, + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + RoleEnforcementStatus, + copyRoles, + formatCopyRoleResult, + renderCopyRoleAsciiTable, + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + RoleUserStatus, + RoleUserSkipReason, + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + RoleManagedNodeStatus, + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + RolePrivilegeStatus, +} from "./roles"; export type { - AuditReportOptions, - AuditReportResult, - AuditReportFilter, - CreatedFilterCriteria, - CreatedPreset, - AuditEventOverviewReportRow, - AuditReportType, - AuditSummaryReportType, - ActionReportEntry, - ActionReportOptions, - ActionReportResult, - ActionResult, - PasswordPolicy, - PasswordStrength, - PasswordReportRow, - PasswordReportOptions, - PasswordReportResult, -} from './enterpriseReport' - -export { viewTeam, formatTeamView, teamViewTable } from './teams/viewTeam' + ListRolesOptions, + ListRoleRow, + RoleColumnInput, + FormattedRolesTable, + FormatRolesTableOptions, + RoleView, + RoleTeamInfo, + RoleUserInfo, + RoleManagedNodeInfo, + RoleEnforcementInfo, + FormatRoleViewOptions, + FormattedRoleViewTable, + FormattedManagedNodePrivilegeTable, + RoleViewTableRow, + AddRoleInput, + AddRoleResult, + AddRoleItemResult, + AddRoleConfirm, + AddRoleConflictPrompt, + FormattedAddRoleTable, + UpdateRoleInput, + UpdateRoleResult, + UpdateRoleItemResult, + FormattedUpdateRoleTable, + DeleteRoleInput, + DeleteRoleResult, + DeleteRoleItemResult, + FormattedDeleteRoleTable, + RoleToggleInput, + RoleToggle, + EnforcementPair, + SetRoleEnforcementsInput, + RoleEnforcementItemResult, + SetRoleEnforcementsResult, + FormattedRoleEnforcementTable, + CopyRoleInput, + CopyRoleResult, + FormattedCopyRoleTable, + AddUsersToRolesInput, + RemoveUsersFromRolesInput, + RoleUserItemResult, + RoleUserResult, + FormattedRoleUserTable, + ManageRoleNodesAction, + ManageRoleNodesInput, + RoleManagedNodeItemResult, + ManageRoleNodesResult, + FormattedManageRoleNodesTable, + ChangeRolePrivilegesInput, + RolePrivilegeItemResult, + ChangeRolePrivilegesResult, + FormattedRolePrivilegeTable, +} from "./roles"; + +export { viewTeam, formatTeamView, teamViewTable } from "./teams/viewTeam"; export type { - TeamView, - TeamRoleInfo, - TeamUserInfo, - FormatTeamViewOptions, - FormattedTeamViewTable, - TeamViewTableRow, -} from './teams/viewTeam' + TeamView, + TeamRoleInfo, + TeamUserInfo, + FormatTeamViewOptions, + FormattedTeamViewTable, + TeamViewTableRow, +} from "./teams/viewTeam"; export { - addTeams, - formatAddTeamResult, - renderAddTeamAsciiTable, - AddTeamSourceKind, - AddTeamSkipReason, - AddTeamStatus, - TeamRestriction, -} from './teams/addTeam' + addTeams, + formatAddTeamResult, + renderAddTeamAsciiTable, + AddTeamSourceKind, + AddTeamSkipReason, + AddTeamStatus, + TeamRestriction, +} from "./teams/addTeam"; export type { - AddTeamInput, - AddTeamResult, - AddTeamItemResult, - AddTeamConfirm, - AddTeamConflictPrompt, - TeamRestrictionInput, - FormatAddTeamResultOptions, - FormattedAddTeamTable, - FormattedAddTeamRow, -} from './teams/addTeam' + AddTeamInput, + AddTeamResult, + AddTeamItemResult, + AddTeamConfirm, + AddTeamConflictPrompt, + TeamRestrictionInput, + FormatAddTeamResultOptions, + FormattedAddTeamTable, + FormattedAddTeamRow, +} from "./teams/addTeam"; export { - updateTeams, - formatUpdateTeamResult, - renderUpdateTeamAsciiTable, - UpdateTeamStatus, -} from './teams/updateTeam' + updateTeams, + formatUpdateTeamResult, + renderUpdateTeamAsciiTable, + UpdateTeamStatus, +} from "./teams/updateTeam"; export type { - UpdateTeamInput, - UpdateTeamResult, - UpdateTeamItemResult, - FormattedUpdateTeamTable, - FormattedUpdateTeamRow, -} from './teams/updateTeam' + UpdateTeamInput, + UpdateTeamResult, + UpdateTeamItemResult, + FormattedUpdateTeamTable, + FormattedUpdateTeamRow, +} from "./teams/updateTeam"; export { - deleteTeams, - formatDeleteTeamResult, - renderDeleteTeamAsciiTable, - DeleteTeamStatus, -} from './teams/deleteTeam' + deleteTeams, + formatDeleteTeamResult, + renderDeleteTeamAsciiTable, + DeleteTeamStatus, +} from "./teams/deleteTeam"; +export type { + DeleteTeamInput, + DeleteTeamResult, + DeleteTeamItemResult, + FormattedDeleteTeamTable, + FormattedDeleteTeamRow, +} from "./teams/deleteTeam"; + +export { changeTeamRoles, TeamRoleStatus } from "./teams/teamRole"; export type { - DeleteTeamInput, - DeleteTeamResult, - DeleteTeamItemResult, - FormattedDeleteTeamTable, - FormattedDeleteTeamRow, -} from './teams/deleteTeam' + ChangeTeamRolesInput, + TeamRoleResult, + TeamRoleItemResult, +} from "./teams/teamRole"; -export { TeamManager } from './teams/TeamManager' +export { TeamManager } from "./teams/TeamManager"; export { - listUsers, - formatUsersTable, - renderUsersAsciiTable, - UserColumn, - SUPPORTED_USER_COLUMNS, - DEFAULT_USER_COLUMNS, -} from './users/listUsers' + listUsers, + formatUsersTable, + renderUsersAsciiTable, + UserColumn, + SUPPORTED_USER_COLUMNS, + DEFAULT_USER_COLUMNS, +} from "./users/listUsers"; -export { viewUser, formatUserView, userViewTable } from './users/viewUser' +export { viewUser, formatUserView, userViewTable } from "./users/viewUser"; export { - addUsers, - formatAddUserResult, - renderAddUserAsciiTable, - AddUserStatus, - AddUserSkipReason, -} from './users/addUser' + addUsers, + formatAddUserResult, + renderAddUserAsciiTable, + AddUserStatus, + AddUserSkipReason, +} from "./users/addUser"; export { - updateUsers, - formatUpdateUserResult, - renderUpdateUserAsciiTable, - UpdateUserStatus, -} from './users/updateUser' + updateUsers, + formatUpdateUserResult, + renderUpdateUserAsciiTable, + UpdateUserStatus, +} from "./users/updateUser"; export { - deleteUsers, - formatDeleteUserResult, - renderDeleteUserAsciiTable, - DeleteUserStatus, -} from './users/deleteUser' + deleteUsers, + formatDeleteUserResult, + renderDeleteUserAsciiTable, + DeleteUserStatus, +} from "./users/deleteUser"; export { - actionUsers, - formatUserActionResult, - renderUserActionAsciiTable, - UserAction, - UserActionStatus, - UserActionSkipReason, -} from './users/actionUser' + actionUsers, + formatUserActionResult, + renderUserActionAsciiTable, + UserAction, + UserActionStatus, + UserActionSkipReason, +} from "./users/actionUser"; export type { - UserColumnInput, - ListUsersOptions, - ListUserRow, - FormattedUsersTable, - FormatUsersTableOptions, - UserTeamInfo, - UserRoleInfo, - UserView, - FormatUserViewOptions, - FormattedUserViewTable, - UserViewTableRow, - AddUserInput, - AddUserItemResult, - AddUserResult, - FormatAddUserResultOptions, - FormattedAddUserTable, - UpdateUserInput, - UpdateUserItemResult, - UpdateUserResult, - FormattedUpdateUserTable, - DeleteUserInput, - DeleteUserItemResult, - DeleteUserResult, - FormattedDeleteUserTable, - UserActionInput, - UserActionItemResult, - UserActionResult, - FormattedUserActionTable, - AliasUserInput, - AliasUserResult, - FormattedUserStatus, - AddUsersToTeamsInput, - RemoveUsersFromTeamsInput, - TeamUserItemResult, - TeamUserResult, - FormattedTeamUserTable, -} from './users/userTypes' - -export { EnterpriseUserStatus } from './users/userTypes' + UserColumnInput, + ListUsersOptions, + ListUserRow, + FormattedUsersTable, + FormatUsersTableOptions, + UserTeamInfo, + UserRoleInfo, + UserView, + FormatUserViewOptions, + FormattedUserViewTable, + UserViewTableRow, + AddUserInput, + AddUserItemResult, + AddUserResult, + FormatAddUserResultOptions, + FormattedAddUserTable, + UpdateUserInput, + UpdateUserItemResult, + UpdateUserResult, + FormattedUpdateUserTable, + DeleteUserInput, + DeleteUserItemResult, + DeleteUserResult, + FormattedDeleteUserTable, + UserActionInput, + UserActionItemResult, + UserActionResult, + FormattedUserActionTable, + AliasUserInput, + AliasUserResult, + FormattedUserStatus, + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserItemResult, + TeamUserResult, + FormattedTeamUserTable, +} from "./users/userTypes"; + +export { EnterpriseUserStatus } from "./users/userTypes"; + +export { aliasUser, AliasOperation } from "./users/aliasUser"; export { - aliasUser, - AliasOperation, -} from './users/aliasUser' + addUsersToTeams, + removeUsersFromTeams, + formatTeamUserResult, + renderTeamUserAsciiTable, + TeamUserStatus, + TeamUserSkipReason, +} from "./users/teamUser"; export { - addUsersToTeams, - removeUsersFromTeams, - formatTeamUserResult, - renderTeamUserAsciiTable, - TeamUserStatus, - TeamUserSkipReason, -} from './users/teamUser' + updateUsersOnTeams, + formatUpdateTeamUserResult, + renderUpdateTeamUserAsciiTable, + UpdateTeamUserStatus, + UpdateTeamUserSkipReason, +} from "./users/updateTeamUser"; +export type { + UpdateUsersOnTeamsInput, + UpdateTeamUserItemResult, + UpdateUsersOnTeamsResult, + FormattedUpdateTeamUserTable, +} from "./users/updateTeamUser"; -export { UserManager } from './users/UserManager' +export { UserManager } from "./users/UserManager"; export { - KeeperDriveKind, - NsfItemType, - formatAccessRoleType, - formatAccessType, - normalizeParentUid, - isRootFolderUid, - resolveKeeperDriveRootParentUid, - getKeeperDriveFolders, - getKeeperDriveRecords, - findRecordFolderLocation, - buildFolderPath, - isSensitiveFieldType, - ListNsfFormat, - listNestedShareFolders, - formatListNsfTable, - renderListNsfAsciiTable, - formatListNsfCsv, - formatListNsfJson, - formatListNsfOutput, - GetNsfFormat, - NsfAccessRoleLabel, - NsfObjectKind, - NSF_ACCESS_ROLE_LABELS, - NSF_MAX_RECORD_BATCH, - resolveRecordPermissionRole, - toNsfAccessRoleLabel, - resolveNsfFolder, - resolveNsfRecord, - getNestedShareFolder, - formatNsfFolderDetail, - formatNsfRecordDetail, - formatNsfDetail, - formatNsfJson, - formatNsfRecordJson, - toNsfRecordJsonView, - linkNestedShareRecord, - NsfRemoveOperation, - removeNestedShareRecords, - formatRemoveNsfPreview, - collectRemoveNsfWarnings, - mkdirNestedShareFolder, - NSF_FOLDER_COLORS, - NsfRemoveFolderOperation, - removeNestedShareFolders, - formatRemoveNsfFolderPreview, - GetNsfRecordDetailsFormat, - getNestedShareRecordDetails, - formatNsfRecordDetailsTable, - formatNsfRecordDetailsOutput, - updateNestedShareRecords, - updateNestedShareRecord, - addNestedShareRecord, - addNestedShareRecords, - buildNsfRecordData, - parseNsfFieldInput, - parseNsfFieldSpaceInput, - resolveNsfFieldValue, - clearNsfRecordTypeCache, - checkRecordEditPermission, - NestedShareFolderManager, -} from './nestedShareFolders' + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + resolveKeeperDriveRootParentUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + ListNsfFormat, + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, + GetNsfFormat, + NsfAccessRoleLabel, + NsfObjectKind, + NSF_ACCESS_ROLE_LABELS, + NSF_MAX_RECORD_BATCH, + resolveRecordPermissionRole, + toNsfAccessRoleLabel, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, + formatNsfJson, + formatNsfRecordJson, + formatNsfFolderJson, + toNsfRecordJsonView, + toNsfFolderJsonView, + NSF_UNMASK_WARNING, + linkNestedShareRecord, + NsfRemoveOperation, + NsfRemoveFolderOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, + collectRemoveNsfWarnings, + mkdirNestedShareFolder, + NSF_FOLDER_COLORS, + removeNestedShareFolders, + formatRemoveNsfFolderPreview, + GetNsfRecordDetailsFormat, + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, + updateNestedShareRecords, + updateNestedShareRecord, + addNestedShareRecord, + addNestedShareRecords, + buildNsfRecordData, + parseNsfFieldInput, + parseNsfFieldSpaceInput, + resolveNsfFieldValue, + clearNsfRecordTypeCache, + checkRecordEditPermission, + checkFolderEditPermission, + checkFolderSharePermission, + checkRecordSharePermission, + ROOT_FOLDER_UID, + NSF_RECORD_PERMISSION_ROLES, + NSF_MAX_FOLDER_UPDATES, + getFolderPermissionsForRole, + updateNestedShareFolder, + updateNestedShareFolders, + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, + listNsfShortcuts, + getNsfRecordShortcuts, + formatNsfShortcutOutput, + keepNsfShortcut, + formatKeepNsfShortcutPlan, + transferNestedShareRecords, + formatTransferNestedShareRecordResults, + NsfRecordPermissionAction, + NsfFolderShareAction, + NsfRecordShareAction, + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + parseShareExpiration, + parseShareExpirationValue, + NestedShareFolderManager, +} from "./nestedShareFolders"; export type { - ListNsfFormatInput, - ListNsfOptions, - ListNsfRow, - FormattedListNsfTable, - GetNsfFormatInput, - GetNsfOptions, - GetNsfResult, - NsfFolderView, - NsfRecordView, - NsfRecordFieldView, - NsfRecordFolderView, - NsfRecordJsonView, - NsfRecordJsonUserPermission, - NsfFolderPermission, - NsfFolderAccessRow, - NsfRecordPermission, - LinkNsfRecordResult, - NsfRemoveOperationInput, - RemoveNsfRecordInput, - NsfRemovePreviewItem, - RemoveNsfRecordResult, - MkdirNsfInput, - MkdirNsfResult, - NsfFolderColorInput, - NsfFolderColor, - NsfRemoveFolderOperationInput, - RemoveNsfFolderInput, - NsfRemoveFolderPreviewItem, - RemoveNsfFolderResult, - GetNsfRecordDetailsFormatInput, - GetNsfRecordDetailsInput, - GetNsfRecordDetailsResult, - NsfRecordDetailsItem, - UpdateNsfRecordInput, - UpdateNsfRecordItemInput, - UpdateNsfRecordsInput, - UpdateNsfRecordResult, - UpdateNsfRecordResultItem, - AddNsfRecordInput, - AddNsfRecordResult, - AddNsfRecordsInput, - AddNsfRecordsResult, - RecordAddPayload, - RecordUpdatePayload, - FolderCreatePayload, - ParsedNsfFields, - RecordFieldEntry, -} from './nestedShareFolders' + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfRecordFieldView, + NsfRecordFolderView, + NsfRecordJsonView, + NsfRecordJsonUserPermission, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, + LinkNsfRecordResult, + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, + NsfFolderColor, + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + RecordAddPayload, + RecordUpdatePayload, + FolderCreatePayload, + ParsedNsfFields, + RecordFieldEntry, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + UpdateNsfFolderBatchItem, + UpdateNsfFolderBatchResultItem, + UpdateNsfFoldersResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfFolderShareActionInput, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, + NsfRecordPermissionRole, + NsfRecordPermissionRoleInput, +} from "./nestedShareFolders"; export type { - DRecord, - DRecordMetadata, - DSharedFolder, - DTeam, - DUserFolder, - VaultStorage, - SyncResult, - SyncDownOptions, - ClientConfiguration, - DeviceConfig, - SessionStorage, - AuthUI3, - KeeperError, - LoginError, -} from '@keeper-security/keeperapi' \ No newline at end of file + DRecord, + DRecordMetadata, + DSharedFolder, + DTeam, + DUserFolder, + VaultStorage, + SyncResult, + SyncDownOptions, + ClientConfiguration, + DeviceConfig, + SessionStorage, + AuthUI3, + KeeperError, + LoginError, +} from "@keeper-security/keeperapi"; diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index c8e75fa3..df011b31 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -1,107 +1,358 @@ -import type { Auth } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes } from '../utils' -import { listNestedShareFolders } from './listNsf' -import { getNestedShareFolder } from './getNsf' -import { linkNestedShareRecord } from './linkNsfRecord' -import { removeNestedShareRecords } from './removeNsfRecord' -import { mkdirNestedShareFolder } from './mkdirNsf' -import { removeNestedShareFolders } from './removeNsfFolder' -import { getNestedShareRecordDetails } from './getNsfRecordDetails' -import { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' -import { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' +import type { Auth } from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes } from "../utils"; +import { + formatListNsfOutput, + formatListNsfTable, + listNestedShareFolders, + renderListNsfAsciiTable, +} from "./listNsf"; +import { + formatNsfDetail as renderNsfDetail, + formatNsfFolderDetail as renderNsfFolderDetail, + formatNsfRecordDetail as renderNsfRecordDetail, + getNestedShareFolder, +} from "./getNsf"; +import { linkNestedShareRecord } from "./linkNsfRecord"; +import { + formatRemoveNsfPreview, + removeNestedShareRecords, +} from "./removeNsfRecord"; +import { mkdirNestedShareFolder } from "./mkdirNsf"; +import { + updateNestedShareFolder, + updateNestedShareFolders, +} from "./updateNsfFolder"; +import { + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, +} from "./nsfShare"; +import { + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, +} from "./nsfShortcut"; +import { + transferNestedShareRecords, + formatTransferNestedShareRecordResults, +} from "./nsfTransferRecord"; +import { + formatRemoveNsfFolderPreview, + removeNestedShareFolders, +} from "./removeNsfFolder"; +import { + getNestedShareRecordDetails, + formatNsfRecordDetailsOutput, +} from "./getNsfRecordDetails"; +import { + updateNestedShareRecords, + updateNestedShareRecord, +} from "./updateNsfRecord"; +import { addNestedShareRecord, addNestedShareRecords } from "./addNsfRecord"; +import { + updateNestedShareRecordPermissions, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionFailures, +} from "./nsfRecordPermission"; import type { - AddNsfRecordInput, - AddNsfRecordResult, - AddNsfRecordsInput, - AddNsfRecordsResult, - GetNsfOptions, - GetNsfResult, - GetNsfRecordDetailsInput, - GetNsfRecordDetailsResult, - LinkNsfRecordResult, - ListNsfOptions, - ListNsfRow, - MkdirNsfInput, - MkdirNsfResult, - RemoveNsfFolderInput, - RemoveNsfFolderResult, - RemoveNsfRecordInput, - RemoveNsfRecordResult, - UpdateNsfRecordInput, - UpdateNsfRecordItemInput, - UpdateNsfRecordsInput, - UpdateNsfRecordResult, - UpdateNsfRecordResultItem, -} from './nsfTypes' - -export type AuthProvider = () => Auth + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + FormattedListNsfTable, + GetNsfOptions, + GetNsfResult, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + KeepNsfShortcutInput, + KeepNsfShortcutResult, + LinkNsfRecordResult, + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + ListNsfShortcutsOptions, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderView, + NsfRecordView, + NsfShortcutRow, + RemoveNsfFolderInput, + RemoveNsfFolderResult, + RemoveNsfRecordInput, + RemoveNsfRecordResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, +} from "./nsfTypes"; +import type { + UpdateNsfFolderBatchItem, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + UpdateNsfFoldersResult, +} from "./updateNsfFolder"; + +export type AuthProvider = () => Auth; export class NestedShareFolderManager { - private readonly storage: InMemoryStorage - private readonly authProvider: AuthProvider + private readonly storage: InMemoryStorage; + private readonly authProvider: AuthProvider; - constructor(storage: InMemoryStorage, authProvider: AuthProvider) { - this.storage = storage - this.authProvider = authProvider - } + constructor(storage: InMemoryStorage, authProvider: AuthProvider) { + this.storage = storage; + this.authProvider = authProvider; + } - private requireAuth(): Auth { - const auth = this.authProvider() - if (!auth?.sessionToken) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } - return auth + private requireAuth(): Auth { + const auth = this.authProvider(); + if (!auth?.sessionToken) { + throw new KeeperSdkError( + "Not logged in. Call login() first.", + ResultCodes.NOT_LOGGED_IN, + ); } + return auth; + } - public listNestedShareFolders(options: ListNsfOptions = {}): ListNsfRow[] { - return listNestedShareFolders(this.storage, options) - } + public listNestedShareFolders(options: ListNsfOptions = {}): ListNsfRow[] { + return listNestedShareFolders(this.storage, options); + } - public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { - return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) - } + public formatListNsfTable( + rows: ListNsfRow[], + options: { columnWidth?: number } = {}, + ): FormattedListNsfTable { + return formatListNsfTable(rows, options); + } - public async linkNestedShareRecord( - recordIdentifier: string, - folderIdentifier: string - ): Promise { - return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) - } + public renderListNsfAsciiTable( + table: FormattedListNsfTable, + options: { minColWidth?: number } = {}, + ): string { + return renderListNsfAsciiTable(table, options); + } - public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { - return removeNestedShareRecords(this.storage, this.requireAuth(), input) - } + public formatListNsfOutput( + rows: ListNsfRow[], + format: ListNsfFormatInput = "table", + ): string { + return formatListNsfOutput(rows, format); + } - public async mkdirNestedShareFolder(input: MkdirNsfInput): Promise { - return mkdirNestedShareFolder(this.storage, this.requireAuth(), input) - } + public async getNestedShareFolder( + identifier: string, + options: GetNsfOptions = {}, + ): Promise { + return getNestedShareFolder( + this.storage, + this.requireAuth(), + identifier, + options, + ); + } - public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { - return removeNestedShareFolders(this.storage, this.requireAuth(), input) - } + public formatNsfDetail(result: GetNsfResult, verbose = false): string { + return renderNsfDetail(result, verbose); + } - public async getNestedShareRecordDetails( - input: GetNsfRecordDetailsInput - ): Promise { - return getNestedShareRecordDetails(this.storage, this.requireAuth(), input) - } + public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { + return renderNsfFolderDetail(view, verbose); + } - public async updateNestedShareRecords( - input: UpdateNsfRecordInput | UpdateNsfRecordsInput - ): Promise { - return updateNestedShareRecords(this.storage, this.requireAuth(), input) - } + public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { + return renderNsfRecordDetail(view, verbose); + } - public async updateNestedShareRecord(input: UpdateNsfRecordItemInput): Promise { - return updateNestedShareRecord(this.storage, this.requireAuth(), input) - } + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string, + ): Promise { + return linkNestedShareRecord( + this.storage, + this.requireAuth(), + recordIdentifier, + folderIdentifier, + ); + } - public async addNestedShareRecords(input: AddNsfRecordsInput): Promise { - return addNestedShareRecords(this.storage, this.requireAuth(), input) - } + public async removeNestedShareRecords( + input: RemoveNsfRecordInput, + ): Promise { + return removeNestedShareRecords(this.storage, this.requireAuth(), input); + } - public async addNestedShareRecord(input: AddNsfRecordInput): Promise { - return addNestedShareRecord(this.storage, this.requireAuth(), input) - } + public formatRemoveNsfPreview( + preview: RemoveNsfRecordResult["preview"], + ): string { + return formatRemoveNsfPreview(preview); + } + + public async mkdirNestedShareFolder( + input: MkdirNsfInput, + ): Promise { + return mkdirNestedShareFolder(this.storage, this.requireAuth(), input); + } + + public async updateNestedShareFolder( + input: UpdateNsfFolderInput, + ): Promise { + return updateNestedShareFolder(this.storage, this.requireAuth(), input); + } + + public async updateNestedShareFolders( + updates: UpdateNsfFolderBatchItem[], + ): Promise { + return updateNestedShareFolders(this.storage, this.requireAuth(), updates); + } + + public async shareNestedShareFolder( + input: ShareNestedShareFolderInput, + ): Promise { + return shareNestedShareFolder(this.storage, this.requireAuth(), input); + } + + public async shareNestedShareRecord( + input: ShareNestedShareRecordInput, + ): Promise { + return shareNestedShareRecord(this.storage, this.requireAuth(), input); + } + + public formatNsfRecordSharePlan( + result: ShareNestedShareRecordResult, + ): string { + return formatNsfRecordSharePlan(result.plan); + } + + public formatNsfRecordShareResults( + results: ShareNestedShareRecordResult["results"], + ): string { + return formatNsfRecordShareResults(results); + } + + public listNsfShortcuts( + options: ListNsfShortcutsOptions = {}, + ): NsfShortcutRow[] { + return listNsfShortcuts(this.storage, options); + } + + public formatNsfShortcutOutput( + rows: NsfShortcutRow[], + format?: ListNsfShortcutsOptions["format"], + ): string { + return formatNsfShortcutOutput(rows, format); + } + + public async keepNsfShortcut( + input: KeepNsfShortcutInput, + defaultFolderUid?: string, + ): Promise { + return keepNsfShortcut( + this.storage, + this.requireAuth(), + input, + defaultFolderUid, + ); + } + + public formatKeepNsfShortcutPlan(result: KeepNsfShortcutResult): string { + return formatKeepNsfShortcutPlan(result.plan); + } + + public async transferNestedShareRecords( + input: TransferNestedShareRecordInput, + ): Promise { + return transferNestedShareRecords(this.storage, this.requireAuth(), input); + } + + public formatTransferNestedShareRecordResults( + results: TransferNestedShareRecordResult["results"], + ): string { + return formatTransferNestedShareRecordResults(results); + } + + public async removeNestedShareFolders( + input: RemoveNsfFolderInput, + ): Promise { + return removeNestedShareFolders(this.storage, this.requireAuth(), input); + } + + public formatRemoveNsfFolderPreview( + preview: RemoveNsfFolderResult["preview"], + operation: RemoveNsfFolderResult["operation"], + quiet?: boolean, + ): string { + return formatRemoveNsfFolderPreview(preview, operation, quiet); + } + + public async getNestedShareRecordDetails( + input: GetNsfRecordDetailsInput, + ): Promise { + return getNestedShareRecordDetails(this.storage, this.requireAuth(), input); + } + + public formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format?: GetNsfRecordDetailsInput["format"], + ): string { + return formatNsfRecordDetailsOutput(result, format); + } + + public async updateNestedShareRecords( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput, + ): Promise { + return updateNestedShareRecords(this.storage, this.requireAuth(), input); + } + + public async updateNestedShareRecord( + input: UpdateNsfRecordItemInput, + ): Promise { + return updateNestedShareRecord(this.storage, this.requireAuth(), input); + } + + public async addNestedShareRecords( + input: AddNsfRecordsInput, + ): Promise { + return addNestedShareRecords(this.storage, this.requireAuth(), input); + } + + public async addNestedShareRecord( + input: AddNsfRecordInput, + ): Promise { + return addNestedShareRecord(this.storage, this.requireAuth(), input); + } + + public async updateNestedShareRecordPermissions( + input: UpdateNsfRecordPermissionInput, + ): Promise { + return updateNestedShareRecordPermissions( + this.storage, + this.requireAuth(), + input, + ); + } + + public formatNsfRecordPermissionPlan( + result: UpdateNsfRecordPermissionResult, + ): string { + return formatNsfRecordPermissionPlan(result.plan); + } + + public formatNsfRecordPermissionFailures( + failures: UpdateNsfRecordPermissionResult["grantFailures"], + kind: "GRANT" | "REVOKE", + ): string { + return formatNsfRecordPermissionFailures(failures, kind); + } } diff --git a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts index e6e26009..fef612a8 100644 --- a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts @@ -1,193 +1,222 @@ -import type { Auth, record as RecordProto } from '@keeper-security/keeperapi' +import type { Auth, record as RecordProto } from "@keeper-security/keeperapi"; import { - Folder, - generateEncryptionKey, - generateUid, - keeperDriveRecordsAdd, - normal64Bytes, - platform, -} from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { NSF_MAX_RECORD_BATCH } from './nsfConstants' + Folder, + generateEncryptionKey, + generateUid, + keeperDriveRecordsAdd, + normal64Bytes, + platform, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { NSF_MAX_RECORD_BATCH } from "./nsfConstants"; +import { buildNsfRecordData, getPaddedJsonBytes } from "./nsfRecordData"; import { - buildNsfRecordData, - getPaddedJsonBytes, -} from './nsfRecordData' -import { - ensureNestedShareFolder, - nsfToNumber, - parseRecordModifyStatus, - requireAuthDataKey, - resolveNsfFolderIdentifier, -} from './nsfHelpers' -import { validateNsfRecordType } from './nsfRecordTypes' + ensureNestedShareFolder, + nsfToNumber, + parseRecordModifyStatus, + requireAuthDataKey, + resolveNsfFolderIdentifier, +} from "./nsfHelpers"; +import { validateNsfRecordType } from "./nsfRecordTypes"; import type { - AddNsfRecordInput, - AddNsfRecordResult, - AddNsfRecordsInput, - AddNsfRecordsResult, - RecordAddPayload, -} from './nsfTypes' - -function resolveFolderUid(storage: InMemoryStorage, folderInput?: string): string | undefined { - const trimmed = folderInput?.trim() - if (!trimmed) return undefined - - const folderUid = resolveNsfFolderIdentifier(storage, trimmed) - if (!folderUid) { - throw new KeeperSdkError(`No such folder: ${trimmed}`, ResultCodes.NSF_NOT_FOUND) - } - ensureNestedShareFolder(storage, folderUid, trimmed) - return folderUid + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + RecordAddPayload, +} from "./nsfTypes"; + +function resolveFolderUid( + storage: InMemoryStorage, + folderInput?: string, +): string | undefined { + const trimmed = folderInput?.trim(); + if (!trimmed) return undefined; + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed); + if (!folderUid) { + throw new KeeperSdkError( + `No such folder: ${trimmed}`, + ResultCodes.NSF_NOT_FOUND, + ); + } + ensureNestedShareFolder(storage, folderUid, trimmed); + return folderUid; } -async function requireFolderKey(storage: InMemoryStorage, folderUid: string): Promise { - const folderKey = await storage.getKeyBytes(folderUid) - if (folderKey) return folderKey - throw new KeeperSdkError( - `Folder key not found for ${folderUid}. Run sync() first.`, - ResultCodes.NSF_MISSING_KEY - ) +async function requireFolderKey( + storage: InMemoryStorage, + folderUid: string, +): Promise { + const folderKey = await storage.getKeyBytes(folderUid); + if (folderKey) return folderKey; + throw new KeeperSdkError( + `Folder key not found for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); } async function buildRecordAddPayload( - storage: InMemoryStorage, - auth: Auth, - recordData: Record, - folderUid?: string + storage: InMemoryStorage, + auth: Auth, + recordData: Record, + folderUid?: string, ): Promise { - const recordUid = generateUid() - const recordKey = generateEncryptionKey() - await storage.saveKeyBytes(recordUid, recordKey) - - const recordAdd: RecordProto.v3.IRecordAdd = { - recordUid: normal64Bytes(recordUid), - clientModifiedTime: Date.now(), - data: await platform.aesGcmEncrypt(getPaddedJsonBytes(recordData), recordKey), - } - - if (folderUid) { - const folderKey = await requireFolderKey(storage, folderUid) - recordAdd.folderUid = normal64Bytes(folderUid) - recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, folderKey) - recordAdd.recordKeyEncryptedBy = Folder.FolderKeyEncryptionType.ENCRYPTED_BY_PARENT_KEY - recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm - } else { - recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, requireAuthDataKey(auth)) - recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm - } - - return { recordUid, recordAdd } + const recordUid = generateUid(); + const recordKey = generateEncryptionKey(); + await storage.saveKeyBytes(recordUid, recordKey); + + const recordAdd: RecordProto.v3.IRecordAdd = { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + data: await platform.aesGcmEncrypt( + getPaddedJsonBytes(recordData), + recordKey, + ), + }; + + if (folderUid) { + const folderKey = await requireFolderKey(storage, folderUid); + recordAdd.folderUid = normal64Bytes(folderUid); + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, folderKey); + recordAdd.recordKeyEncryptedBy = + Folder.FolderKeyEncryptionType.ENCRYPTED_BY_PARENT_KEY; + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm; + } else { + recordAdd.recordKey = await platform.aesGcmEncrypt( + recordKey, + requireAuthDataKey(auth), + ); + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm; + } + + return { recordUid, recordAdd }; } function validateAddNsfRecordInput(input: AddNsfRecordInput): void { - if (!input.title?.trim()) { - throw new KeeperSdkError('Record title is required.', ResultCodes.NSF_ADD_FAILED) - } - if (!input.recordType?.trim() && !input.recordData) { - throw new KeeperSdkError('Record type is required.', ResultCodes.NSF_ADD_FAILED) - } - if (input.hasFileFields && !input.force) { - throw new KeeperSdkError( - 'File attachments are not supported in nested share record add.', - ResultCodes.NSF_ADD_FAILED - ) - } + if (!input.title?.trim()) { + throw new KeeperSdkError( + "Record title is required.", + ResultCodes.NSF_ADD_FAILED, + ); + } + if (!input.recordType?.trim() && !input.recordData) { + throw new KeeperSdkError( + "Record type is required.", + ResultCodes.NSF_ADD_FAILED, + ); + } + if (input.hasFileFields && !input.force) { + throw new KeeperSdkError( + "File attachments are not supported in nested share record add.", + ResultCodes.NSF_ADD_FAILED, + ); + } } -function buildRecordDataFromInput(input: AddNsfRecordInput): Record { - return ( - input.recordData ?? - buildNsfRecordData({ - title: input.title, - recordType: input.recordType, - notes: input.notes, - fieldEntries: input.fieldEntries, - customEntries: input.customEntries, - }) - ) +function buildRecordDataFromInput( + input: AddNsfRecordInput, +): Record { + return ( + input.recordData ?? + buildNsfRecordData({ + title: input.title, + recordType: input.recordType, + notes: input.notes, + fieldEntries: input.fieldEntries, + customEntries: input.customEntries, + }) + ); } export async function addNestedShareRecords( - storage: InMemoryStorage, - auth: Auth, - input: AddNsfRecordsInput + storage: InMemoryStorage, + auth: Auth, + input: AddNsfRecordsInput, ): Promise { - const recordInputs = input.records ?? [] - if (recordInputs.length === 0) { - throw new KeeperSdkError('At least one record is required.', ResultCodes.NSF_ADD_FAILED) - } - if (recordInputs.length > NSF_MAX_RECORD_BATCH) { - throw new KeeperSdkError( - `Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, - ResultCodes.NSF_TOO_MANY_RECORDS - ) - } - - const recordTypes = new Set() - for (const recordInput of recordInputs) { - validateAddNsfRecordInput(recordInput) - if (!recordInput.recordData && recordInput.recordType?.trim()) { - recordTypes.add(recordInput.recordType.trim()) - } - } - for (const recordType of recordTypes) { - await validateNsfRecordType(auth, recordType, ResultCodes.NSF_ADD_FAILED) - } - - try { - const payloads = await Promise.all( - recordInputs.map(async (recordInput) => - buildRecordAddPayload( - storage, - auth, - buildRecordDataFromInput(recordInput), - resolveFolderUid(storage, recordInput.folder) - ) - ) - ) - - const response = await auth.executeRest( - keeperDriveRecordsAdd({ - records: payloads.map((entry) => entry.recordAdd), - clientTime: Date.now(), - }) - ) - const revision = nsfToNumber(response.revision) - - const added = payloads.map((payload, index) => { - const { statusName, message } = parseRecordModifyStatus( - response.records?.[index], - ResultCodes.NSF_ADD_FAILED - ) - return { - recordUid: payload.recordUid, - success: true, - status: statusName, - message, - revision, - } - }) - - return { added, revision } - } catch (err) { - if (err instanceof KeeperSdkError) throw err - throw new KeeperSdkError( - `Failed to add nested share record(s): ${extractErrorMessage(err)}`, - ResultCodes.NSF_ADD_FAILED - ) + const recordInputs = input.records ?? []; + if (recordInputs.length === 0) { + throw new KeeperSdkError( + "At least one record is required.", + ResultCodes.NSF_ADD_FAILED, + ); + } + if (recordInputs.length > NSF_MAX_RECORD_BATCH) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, + ResultCodes.NSF_TOO_MANY_RECORDS, + ); + } + + const recordTypes = new Set(); + for (const recordInput of recordInputs) { + validateAddNsfRecordInput(recordInput); + if (!recordInput.recordData && recordInput.recordType?.trim()) { + recordTypes.add(recordInput.recordType.trim()); } + } + for (const recordType of recordTypes) { + await validateNsfRecordType(auth, recordType, ResultCodes.NSF_ADD_FAILED); + } + + try { + const payloads = await Promise.all( + recordInputs.map(async (recordInput) => + buildRecordAddPayload( + storage, + auth, + buildRecordDataFromInput(recordInput), + resolveFolderUid(storage, recordInput.folder), + ), + ), + ); + + const response = await auth.executeRest( + keeperDriveRecordsAdd({ + records: payloads.map((entry) => entry.recordAdd), + clientTime: Date.now(), + }), + ); + const revision = nsfToNumber(response.revision); + + const added = payloads.map((payload, index) => { + const { statusName, message } = parseRecordModifyStatus( + response.records?.[index], + ResultCodes.NSF_ADD_FAILED, + ); + return { + recordUid: payload.recordUid, + success: true, + status: statusName, + message, + revision, + }; + }); + + return { added, revision }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to add nested share record(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_ADD_FAILED, + ); + } } export async function addNestedShareRecord( - storage: InMemoryStorage, - auth: Auth, - input: AddNsfRecordInput + storage: InMemoryStorage, + auth: Auth, + input: AddNsfRecordInput, ): Promise { - const { added } = await addNestedShareRecords(storage, auth, { records: [input] }) - if (!added[0]) { - throw new KeeperSdkError('Failed to add nested share record.', ResultCodes.NSF_ADD_FAILED) - } - return added[0] + const { added } = await addNestedShareRecords(storage, auth, { + records: [input], + }); + if (!added[0]) { + throw new KeeperSdkError( + "Failed to add nested share record.", + ResultCodes.NSF_ADD_FAILED, + ); + } + return added[0]; } diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts index 91ce48d7..ba9b4da7 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -1,568 +1,777 @@ -import type { Auth, DRecord, DKdFolder, DKdFolderAccess } from '@keeper-security/keeperapi' +import type { + Auth, + DRecord, + DKdFolder, + DKdFolderAccess, +} from "@keeper-security/keeperapi"; import { - Folder, - Records, - getRecordsDetailsMessage, - getSharingAdminsMessage, - normal64Bytes, - webSafe64FromBytes, -} from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' + Folder, + Records, + getRecordsDetailsMessage, + getSharingAdminsMessage, + normal64Bytes, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; import { - getRecordFields, - getRecordLogin, - getRecordPassword, - getRecordTitle, - getRecordType, - getRecordUrl, -} from '../records/RecordUtils' -import { KeeperSdkError, ResultCodes } from '../utils' + getRecordFields, + getRecordLogin, + getRecordPassword, + getRecordTitle, + getRecordType, + getRecordUrl, +} from "../records/RecordUtils"; +import { KeeperSdkError, ResultCodes } from "../utils"; import { - buildFolderPath, - collectRecordsInFolder, - fetchLiveRecordAccessEntries, - getFolderAccessEntries, - findNestedShareFoldersForRecord, - findRecordFolderLocation, - folderAccessDisplayRole, - formatAccessType, - getKeeperDriveFolder, - getKeeperDriveRecord, - isFolderOwnerAccessor, - isFolderShareAdministrator, - isFolderUserPermission, - isSensitiveFieldType, - loadShareUserMap, - normalizeParentUid, - recordAccessDisplayRole, - resolveAccessUsername, - resolveNsfFolderIdentifier, - resolveNsfRecordIdentifier, -} from './nsfHelpers' + buildFolderPath, + collectRecordsInFolder, + displayNsfParentUid, + fetchLiveRecordAccessEntries, + getFolderAccessEntries, + findNestedShareFoldersForRecord, + findRecordFolderLocation, + folderAccessDisplayRole, + formatAccessType, + getKeeperDriveFolder, + getKeeperDriveRecord, + isFolderOwnerAccessor, + isFolderShareAdministrator, + isFolderUserPermission, + isSensitiveFieldType, + loadShareUserMap, + normalizeParentUid, + recordAccessDisplayRole, + resolveAccessUsername, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; import { - NSF_FOLDER_LABEL_WIDTH, - NSF_FOLDER_SHARE_ADMINS_HEADING, - NSF_USER_PERMISSIONS_HEADING, - NSF_MASKED_VALUE, - NSF_RECORD_LABEL_WIDTH, - NSF_SHARE_ADMINS_PREVIEW_LIMIT, - NSF_TOP_LEVEL_FIELD_TYPES, - NSF_UNKNOWN_RECORD_TITLES, -} from './nsfConstants' + NSF_FOLDER_LABEL_WIDTH, + NSF_FOLDER_SHARE_ADMINS_HEADING, + NSF_USER_PERMISSIONS_HEADING, + NSF_MASKED_VALUE, + NSF_RECORD_LABEL_WIDTH, + NSF_SHARE_ADMINS_PREVIEW_LIMIT, + NSF_TOP_LEVEL_FIELD_TYPES, + NSF_UNKNOWN_RECORD_TITLES, +} from "./nsfConstants"; import { - GetNsfFormat, - NsfAccessRoleLabel, - NsfObjectKind, - resolveRecordPermissionRole, - type GetNsfFormatInput, - type GetNsfOptions, - type GetNsfResult, - type NsfFolderAccessRow, - type NsfFolderPermission, - type NsfFolderView, - type NsfRecordFieldView, - type NsfRecordFolderView, - type NsfRecordJsonUserPermission, - type NsfRecordJsonView, - type NsfRecordPermission, - type NsfRecordView, -} from './nsfTypes' + GetNsfFormat, + NsfAccessRoleLabel, + NsfObjectKind, + resolveRecordPermissionRole, + type GetNsfFormatInput, + type GetNsfOptions, + type GetNsfResult, + type NsfFolderAccessRow, + type NsfFolderJsonView, + type NsfFolderPermission, + type NsfFolderView, + type NsfRecordFieldView, + type NsfRecordFolderView, + type NsfRecordJsonUserPermission, + type NsfRecordJsonView, + type NsfRecordPermission, + type NsfRecordView, +} from "./nsfTypes"; + +export const NSF_UNMASK_WARNING = + "WARNING: Sensitive field values are displayed in plain text."; + +function formatAsciiTable(headers: string[], rows: string[][]): string[] { + if (rows.length === 0) return []; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || "").length)), + ); + const pad = (cell: string, index: number) => + cell + " ".repeat(Math.max(0, widths[index] - cell.length)); + const formatRow = (cells: string[]) => + cells.map((cell, index) => pad(cell, index)).join(" "); + const rule = widths + .map((width, index) => pad("-".repeat(width), index)) + .join(" "); + return [formatRow(headers), rule, ...rows.map(formatRow)]; +} function formatNsfFieldParts(values: unknown[]): string[] { - return values - .filter((value) => value != null && value !== '') - .map(formatNsfFieldValue) - .filter((part) => part.length > 0) + return values + .filter((value) => value != null && value !== "") + .map(formatNsfFieldValue) + .filter((part) => part.length > 0); } function formatNsfFieldValue(value: unknown): string { - if (value == null || value === '') return '' - if (typeof value === 'string') return value - if (typeof value === 'number' || typeof value === 'boolean') return String(value) - if (Array.isArray(value)) { - return formatNsfFieldParts(value).join(', ') - } - if (typeof value === 'object') { - return formatNsfFieldParts(Object.values(value as Record)).join(', ') - } - return String(value) + if (value == null || value === "") return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + if (Array.isArray(value)) { + return formatNsfFieldParts(value).join(", "); + } + if (typeof value === "object") { + return formatNsfFieldParts( + Object.values(value as Record), + ).join(", "); + } + return String(value); } function folderDetailRow(label: string, value: string): string { - return `${label.padStart(NSF_FOLDER_LABEL_WIDTH)}: ${value}` + return `${label.padStart(NSF_FOLDER_LABEL_WIDTH)}: ${value}`; } function recordDetailRow(label: string, value: string): string { - return `${label.padStart(NSF_RECORD_LABEL_WIDTH)}: ${value}` + return `${label.padStart(NSF_RECORD_LABEL_WIDTH)}: ${value}`; } -function recordDetailsMessage(recordUid: string, include: Records.RecordDetailsInclude) { - return getRecordsDetailsMessage({ - clientTime: Date.now(), - recordUid: [normal64Bytes(recordUid)], - recordDetailsInclude: include, - }) +function recordDetailsMessage( + recordUid: string, + include: Records.RecordDetailsInclude, +) { + return getRecordsDetailsMessage({ + clientTime: Date.now(), + recordUid: [normal64Bytes(recordUid)], + recordDetailsInclude: include, + }); } function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { - return { - accessTypeUid: entry.accessTypeUid, - accessType: formatAccessType(entry.accessType), - accessRoleType: folderAccessDisplayRole(entry), - inherited: entry.inherited, - hidden: entry.hidden, - } + return { + accessTypeUid: entry.accessTypeUid, + accessType: formatAccessType(entry.accessType), + accessRoleType: folderAccessDisplayRole(entry), + inherited: entry.inherited, + hidden: entry.hidden, + }; } function buildFolderAccessRow( - storage: InMemoryStorage, - folder: DKdFolder, - entry: DKdFolderAccess, - shareUsers: Map + storage: InMemoryStorage, + folder: DKdFolder, + entry: DKdFolderAccess, + shareUsers: Map, ): NsfFolderAccessRow { - const username = resolveAccessUsername(storage, entry.accessTypeUid, folder, shareUsers) - const role = isFolderOwnerAccessor(folder, entry, username) - ? NsfAccessRoleLabel.Owner - : folderAccessDisplayRole(entry) - return { username, role } + const username = resolveAccessUsername( + storage, + entry.accessTypeUid, + folder, + shareUsers, + ); + const role = isFolderOwnerAccessor(folder, entry, username) + ? NsfAccessRoleLabel.Owner + : folderAccessDisplayRole(entry); + return { username, role }; } function splitFolderPermissions( - storage: InMemoryStorage, - folder: DKdFolder, - entries: DKdFolderAccess[], - shareUsers: Map + storage: InMemoryStorage, + folder: DKdFolder, + entries: DKdFolderAccess[], + shareUsers: Map, ) { - const userPermissions: NsfFolderAccessRow[] = [] - const shareAdmins: NsfFolderAccessRow[] = [] - const teamPermissions: NsfFolderPermission[] = [] - - for (const entry of entries) { - if (isFolderUserPermission(entry)) { - userPermissions.push(buildFolderAccessRow(storage, folder, entry, shareUsers)) - } - if (isFolderShareAdministrator(entry)) { - shareAdmins.push(buildFolderAccessRow(storage, folder, entry, shareUsers)) - } - if (entry.accessType === Folder.AccessType.AT_TEAM) { - teamPermissions.push(mapFolderPermission(entry)) - } + const userPermissions: NsfFolderAccessRow[] = []; + const shareAdmins: NsfFolderAccessRow[] = []; + const teamPermissions: NsfFolderPermission[] = []; + + for (const entry of entries) { + if (isFolderUserPermission(entry)) { + userPermissions.push( + buildFolderAccessRow(storage, folder, entry, shareUsers), + ); + } + if (isFolderShareAdministrator(entry)) { + shareAdmins.push( + buildFolderAccessRow(storage, folder, entry, shareUsers), + ); + } + if (entry.accessType === Folder.AccessType.AT_TEAM) { + teamPermissions.push(mapFolderPermission(entry)); } + } - return { userPermissions, shareAdmins, teamPermissions } + return { userPermissions, shareAdmins, teamPermissions }; } -function prependOwnerRow(rows: NsfFolderAccessRow[], ownerUsername: string): NsfFolderAccessRow[] { - if (rows.some((entry) => entry.username === ownerUsername)) return rows - return [{ username: ownerUsername, role: NsfAccessRoleLabel.Owner }, ...rows] +function prependOwnerRow( + rows: NsfFolderAccessRow[], + ownerUsername: string, +): NsfFolderAccessRow[] { + if (rows.some((entry) => entry.username === ownerUsername)) return rows; + return [{ username: ownerUsername, role: NsfAccessRoleLabel.Owner }, ...rows]; } function ensureFolderOwnerListed( - folder: DKdFolder, - userPermissions: NsfFolderAccessRow[], - shareAdmins: NsfFolderAccessRow[] -): { userPermissions: NsfFolderAccessRow[]; shareAdmins: NsfFolderAccessRow[] } { - const ownerUsername = folder.ownerInfo?.username?.trim() - if (!ownerUsername) return { userPermissions, shareAdmins } - return { - userPermissions: prependOwnerRow(userPermissions, ownerUsername), - shareAdmins: prependOwnerRow(shareAdmins, ownerUsername), - } + folder: DKdFolder, + userPermissions: NsfFolderAccessRow[], + shareAdmins: NsfFolderAccessRow[], +): { + userPermissions: NsfFolderAccessRow[]; + shareAdmins: NsfFolderAccessRow[]; +} { + const ownerUsername = folder.ownerInfo?.username?.trim(); + if (!ownerUsername) return { userPermissions, shareAdmins }; + return { + userPermissions: prependOwnerRow(userPermissions, ownerUsername), + shareAdmins: prependOwnerRow(shareAdmins, ownerUsername), + }; } function fieldValueToStrings(values: unknown[], fieldType?: string): string[] { - return values.map((value) => { - if (value == null || value === '') return '' - if (typeof value === 'string') return value - if (typeof value === 'number' || typeof value === 'boolean') return String(value) - if (typeof value === 'object') { - const obj = value as Record - if (fieldType === 'host' || fieldType === 'address') { - return JSON.stringify(obj) - } - if (typeof obj.url === 'string') return obj.url - if (typeof obj.value === 'string') return obj.value - return JSON.stringify(value) - } - return String(value) - }) + return values.map((value) => { + if (value == null || value === "") return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + if (typeof value === "object") { + const obj = value as Record; + if (fieldType === "host" || fieldType === "address") { + return JSON.stringify(obj); + } + if (typeof obj.url === "string") return obj.url; + if (typeof obj.value === "string") return obj.value; + return JSON.stringify(value); + } + return String(value); + }); } function resolveRecordFolder( - storage: InMemoryStorage, - recordUid: string + storage: InMemoryStorage, + recordUid: string, ): NsfRecordFolderView | undefined { - const folderUids = findNestedShareFoldersForRecord(storage, recordUid) - if (folderUids.length === 0) return undefined + const folderUids = findNestedShareFoldersForRecord(storage, recordUid); + if (folderUids.length === 0) return undefined; + + const uid = folderUids[0]; + return { + uid, + path: buildFolderPath(storage, uid).replace(/^\//, ""), + }; +} - const uid = folderUids[0] +function buildRecordFields( + record: DRecord, + unmask: boolean, +): NsfRecordFieldView[] { + return getRecordFields(record).map((field) => { + const rawValues = Array.isArray(field.value) ? field.value : [field.value]; + const stringValues = fieldValueToStrings(rawValues, field.type); + const masked = !unmask && isSensitiveFieldType(field.type); return { - uid, - path: buildFolderPath(storage, uid).replace(/^\//, ''), - } + type: field.type, + label: field.label, + value: + masked && stringValues.some((value) => value.length > 0) + ? stringValues.map((value) => + value.length > 0 ? NSF_MASKED_VALUE : value, + ) + : stringValues, + }; + }); } -function buildRecordFields(record: DRecord, unmask: boolean): NsfRecordFieldView[] { - return getRecordFields(record).map((field) => { - const rawValues = Array.isArray(field.value) ? field.value : [field.value] - const stringValues = fieldValueToStrings(rawValues, field.type) - const masked = !unmask && isSensitiveFieldType(field.type) - return { - type: field.type, - label: field.label, - value: - masked && stringValues.some((value) => value.length > 0) - ? stringValues.map((value) => (value.length > 0 ? NSF_MASKED_VALUE : value)) - : stringValues, +function formatRecordFieldDetailLines(field: NsfRecordFieldView): string[] { + if (NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) return []; + + if (field.type === "host" || field.type === "address") { + const lines: string[] = []; + for (const part of field.value) { + if (!part) continue; + let obj: Record | undefined; + if (typeof part === "string" && part.startsWith("{")) { + try { + obj = JSON.parse(part) as Record; + } catch { + obj = undefined; } - }) + } + if (obj) { + if (obj.hostName != null && String(obj.hostName).length > 0) { + lines.push(recordDetailRow("HostName", String(obj.hostName))); + } + if (obj.port != null && String(obj.port).length > 0) { + lines.push(recordDetailRow("Port", String(obj.port))); + } + if (obj.street1 != null && String(obj.street1).length > 0) { + lines.push(recordDetailRow("Street", String(obj.street1))); + } + continue; + } + } + if (lines.length > 0) return lines; + } + + const displayValue = field.value.filter(Boolean).join(", "); + if (!displayValue) return []; + const label = field.label || field.type; + return [ + recordDetailRow( + label.charAt(0).toUpperCase() + label.slice(1), + displayValue, + ), + ]; } -function formatRecordFieldDetailLines(field: NsfRecordFieldView): string[] { - if (NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) return [] - - if (field.type === 'host' || field.type === 'address') { - const lines: string[] = [] - for (const part of field.value) { - if (!part) continue - let obj: Record | undefined - if (typeof part === 'string' && part.startsWith('{')) { - try { - obj = JSON.parse(part) as Record - } catch { - obj = undefined - } - } - if (obj) { - if (obj.hostName != null && String(obj.hostName).length > 0) { - lines.push(recordDetailRow('HostName', String(obj.hostName))) - } - if (obj.port != null && String(obj.port).length > 0) { - lines.push(recordDetailRow('Port', String(obj.port))) - } - if (obj.street1 != null && String(obj.street1).length > 0) { - lines.push(recordDetailRow('Street', String(obj.street1))) - } - continue - } - } - if (lines.length > 0) return lines +function jsonFieldValues( + type: string, + value: string[], + unmask = false, +): unknown[] { + const masked = !unmask && isSensitiveFieldType(type); + return value.map((part) => { + if (masked && part.length > 0) return NSF_MASKED_VALUE; + if ((type === "host" || type === "address") && part.startsWith("{")) { + try { + return JSON.parse(part) as Record; + } catch { + return part; + } } + return part; + }); +} - const displayValue = field.value.filter(Boolean).join(', ') - if (!displayValue) return [] - const label = field.label || field.type - return [recordDetailRow(label.charAt(0).toUpperCase() + label.slice(1), displayValue)] +export function toNsfRecordJsonView( + view: NsfRecordView, + options: { includeDag?: boolean; unmask?: boolean } = {}, +): NsfRecordJsonView { + const includeDag = options.includeDag ?? false; + const unmask = options.unmask ?? false; + const fields = view.fields + .filter((field) => !NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) + .map(({ type, value }) => ({ + type, + value: jsonFieldValues(type, value, unmask), + })); + + return { + record_uid: view.recordUid, + title: view.title, + type: view.type, + ...(includeDag + ? { + version: view.version, + revision: view.revision, + ...(view.folder ? { folder: view.folder } : {}), + } + : {}), + fields, + ...(view.notes ? { notes: view.notes } : {}), + user_permissions: view.userPermissions.map((entry) => ({ + username: entry.username, + owner: entry.owner, + shareable: entry.shareable, + editable: entry.editable, + role: resolveRecordPermissionRole(entry), + })), + share_admins: view.shareAdmins, + }; } -function jsonFieldValues(type: string, value: string[]): unknown[] { - return value.map((part) => { - if ((type === 'host' || type === 'address') && part.startsWith('{')) { - try { - return JSON.parse(part) as Record - } catch { - return part - } +export function toNsfFolderJsonView( + view: NsfFolderView, + options: { includeDag?: boolean } = {}, +): NsfFolderJsonView { + const includeDag = options.includeDag ?? false; + return { + folder_uid: view.folderUid, + type: "nested_share_folder", + name: view.name, + parent_uid: view.parentUid, + ...(includeDag + ? { + path: view.path, + records: view.records, + user_permissions: view.userPermissions, + share_admins: view.shareAdmins, + team_permissions: view.teamPermissions, } - return part - }) + : {}), + }; } -export function toNsfRecordJsonView(view: NsfRecordView): NsfRecordJsonView { - return { - record_uid: view.recordUid, - title: view.title, - type: view.type, - version: view.version, - revision: view.revision, - ...(view.folder ? { folder: view.folder } : {}), - fields: view.fields.map(({ type, value }) => ({ type, value: jsonFieldValues(type, value) })), - ...(view.notes ? { notes: view.notes } : {}), - user_permissions: view.userPermissions.map((entry) => ({ - username: entry.username, - owner: entry.owner, - shareable: entry.shareable, - editable: entry.editable, - role: resolveRecordPermissionRole(entry), - })), - share_admins: view.shareAdmins, - } +export function formatNsfRecordJson( + view: NsfRecordView, + options: { includeDag?: boolean; unmask?: boolean } = {}, +): string { + return JSON.stringify(toNsfRecordJsonView(view, options), null, 2); } -export function formatNsfRecordJson(view: NsfRecordView): string { - return JSON.stringify(toNsfRecordJsonView(view), null, 2) +export function formatNsfFolderJson( + view: NsfFolderView, + options: { includeDag?: boolean } = {}, +): string { + return JSON.stringify(toNsfFolderJsonView(view, options), null, 2); } -function formatRecordUserPermissionBlock(entry: NsfRecordPermission): string[] { - const lines: string[] = [] - if (entry.username) lines.push(` User: ${entry.username}`) - else if (entry.accountUid) lines.push(` User UID: ${entry.accountUid}`) - if (entry.owner) lines.push(' Owner: Yes') - else if (entry.role) lines.push(` Role: ${entry.role}`) - lines.push(` Shareable: ${entry.shareable ? 'Yes' : 'No'}`) - lines.push(` Read-Only: ${entry.editable ? 'No' : 'Yes'}`) - return lines +function formatRecordUserPermissionBlock( + entry: NsfRecordPermission, + verbose: boolean, +): string[] { + if (verbose) return []; + const lines: string[] = []; + if (entry.username) lines.push(` User: ${entry.username}`); + else if (entry.accountUid) lines.push(` User UID: ${entry.accountUid}`); + if (entry.owner) lines.push(" Owner: Yes"); + else if (entry.role) lines.push(` Role: ${entry.role}`); + lines.push(` Shareable: ${entry.shareable ? "Yes" : "No"}`); + lines.push(` Read-Only: ${entry.editable ? "No" : "Yes"}`); + return lines; +} + +function formatRecordUserPermissionTable( + entries: NsfRecordPermission[], +): string[] { + if (entries.length === 0) return []; + const rows = entries.map((entry) => [ + entry.username || entry.accountUid || "", + entry.owner ? "Owner" : entry.role || "", + entry.shareable ? "Yes" : "No", + entry.editable ? "No" : "Yes", + ]); + return [ + "", + NSF_USER_PERMISSIONS_HEADING, + ...formatAsciiTable(["User", "Role", "Shareable", "Read-Only"], rows).map( + (line) => ` ${line}`, + ), + ]; +} + +function formatFolderAccessTable( + heading: string, + entries: NsfFolderAccessRow[], +): string[] { + if (entries.length === 0) return []; + const rows = entries.map((entry) => [entry.username, entry.role]); + return [ + "", + heading, + ...formatAsciiTable(["User", "Role"], rows).map((line) => ` ${line}`), + ]; } async function fetchRecordPermissions( - auth: Auth, - storage: InMemoryStorage, - recordUid: string + auth: Auth, + storage: InMemoryStorage, + recordUid: string, ): Promise { - const entries = await fetchLiveRecordAccessEntries(auth, storage, recordUid) - return entries.map(({ username, accountUid, data }) => { - const owner = !!data.owner - return { - username, - accountUid, - owner, - shareAdmin: false, - shareable: !!(data.canApproveAccess || data.canUpdateAccess), - editable: !!data.canEdit, - awaitingApproval: false, - role: owner ? undefined : recordAccessDisplayRole(data), - } - }) -} - -async function fetchRecordShareAdmins(auth: Auth, recordUid: string): Promise { - try { - const response = await auth.executeRest( - getSharingAdminsMessage({ recordUid: normal64Bytes(recordUid) }) - ) - return (response.userProfileExts ?? []) - .flatMap((ext) => (ext?.email ? [ext.email] : [])) - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) - } catch { - return [] - } + const entries = await fetchLiveRecordAccessEntries(auth, storage, recordUid); + return entries.map(({ username, accountUid, data }) => { + const owner = !!data.owner; + return { + username, + accountUid, + owner, + shareAdmin: false, + shareable: !!(data.canApproveAccess || data.canUpdateAccess), + editable: !!data.canEdit, + awaitingApproval: false, + role: owner ? undefined : recordAccessDisplayRole(data), + }; + }); } -async function fetchRecordDataFallback(auth: Auth, recordUid: string): Promise { - try { - const response = await auth.executeRest( - recordDetailsMessage(recordUid, Records.RecordDetailsInclude.DATA_ONLY) - ) - return response.recordDataWithAccessInfo?.[0]?.recordData as DRecord['data'] | undefined - } catch { - return undefined - } +async function fetchRecordShareAdmins( + auth: Auth, + recordUid: string, +): Promise { + try { + const response = await auth.executeRest( + getSharingAdminsMessage({ recordUid: normal64Bytes(recordUid) }), + ); + return (response.userProfileExts ?? []) + .flatMap((ext) => (ext?.email ? [ext.email] : [])) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + } catch { + return []; + } +} + +async function fetchRecordDataFallback( + auth: Auth, + recordUid: string, +): Promise { + try { + const response = await auth.executeRest( + recordDetailsMessage(recordUid, Records.RecordDetailsInclude.DATA_ONLY), + ); + return response.recordDataWithAccessInfo?.[0]?.recordData as + | DRecord["data"] + | undefined; + } catch { + return undefined; + } } async function buildFolderView( - auth: Auth, - storage: InMemoryStorage, - folderUid: string + auth: Auth, + storage: InMemoryStorage, + folderUid: string, ): Promise { - const folder = getKeeperDriveFolder(storage, folderUid) - if (!folder) { - throw new KeeperSdkError(`Nested share folder not found: ${folderUid}`, ResultCodes.NSF_NOT_FOUND) - } - - const entries = getFolderAccessEntries(storage, folderUid) - const shareUsers = await loadShareUserMap(auth, storage) - const split = splitFolderPermissions(storage, folder, entries, shareUsers) - const { userPermissions, shareAdmins } = ensureFolderOwnerListed( - folder, - split.userPermissions, - split.shareAdmins - ) - - return { - objectType: NsfObjectKind.Folder, - folderUid, - name: folder.data.name || 'Unnamed', - parentUid: normalizeParentUid(storage, folder.parentUid), - path: buildFolderPath(storage, folderUid), - userPermissions, - shareAdmins, - teamPermissions: split.teamPermissions, - records: collectRecordsInFolder(storage, folderUid).map((record) => ({ - uid: record.uid, - title: getRecordTitle(record), - type: getRecordType(record), - })), - } + const folder = getKeeperDriveFolder(storage, folderUid); + if (!folder) { + throw new KeeperSdkError( + `Nested share folder not found: ${folderUid}`, + ResultCodes.NSF_NOT_FOUND, + ); + } + + const entries = getFolderAccessEntries(storage, folderUid); + const shareUsers = await loadShareUserMap(auth, storage); + const split = splitFolderPermissions(storage, folder, entries, shareUsers); + const { userPermissions, shareAdmins } = ensureFolderOwnerListed( + folder, + split.userPermissions, + split.shareAdmins, + ); + + return { + objectType: NsfObjectKind.Folder, + folderUid, + name: folder.data.name || "Unnamed", + parentUid: displayNsfParentUid(storage, folder.parentUid), + path: buildFolderPath(storage, folderUid), + userPermissions, + shareAdmins, + teamPermissions: split.teamPermissions, + records: collectRecordsInFolder(storage, folderUid).map((record) => ({ + uid: record.uid, + title: getRecordTitle(record), + type: getRecordType(record), + })), + }; } async function buildRecordView( - auth: Auth, - storage: InMemoryStorage, - recordUid: string, - unmask: boolean + auth: Auth, + storage: InMemoryStorage, + recordUid: string, + unmask: boolean, ): Promise { - let record = getKeeperDriveRecord(storage, recordUid) - if (!record) { - throw new KeeperSdkError(`Nested share record not found: ${recordUid}`, ResultCodes.NSF_NOT_FOUND) - } - - let title = getRecordTitle(record) - if (NSF_UNKNOWN_RECORD_TITLES.has(title)) { - const fallbackData = await fetchRecordDataFallback(auth, recordUid) - if (fallbackData) { - record = { ...record, data: fallbackData } - title = getRecordTitle(record) - } - } - - const password = getRecordPassword(record) - const [userPermissions, shareAdmins] = await Promise.all([ - fetchRecordPermissions(auth, storage, recordUid), - fetchRecordShareAdmins(auth, recordUid), - ]) - const notes = - typeof record.data?.notes === 'string' && record.data.notes.trim() ? record.data.notes.trim() : undefined - - return { - objectType: NsfObjectKind.Record, - recordUid, - title, - type: getRecordType(record), - revision: record.revision, - version: record.version, - folder: resolveRecordFolder(storage, recordUid), - folderLocation: findRecordFolderLocation(storage, recordUid) || 'root', - login: getRecordLogin(record) || undefined, - password: password ? (unmask ? password : NSF_MASKED_VALUE) : undefined, - url: getRecordUrl(record) || undefined, - notes, - fields: buildRecordFields(record, unmask), - userPermissions, - shareAdmins, + let record = getKeeperDriveRecord(storage, recordUid); + if (!record) { + throw new KeeperSdkError( + `Nested share record not found: ${recordUid}`, + ResultCodes.NSF_NOT_FOUND, + ); + } + + let title = getRecordTitle(record); + if (NSF_UNKNOWN_RECORD_TITLES.has(title)) { + const fallbackData = await fetchRecordDataFallback(auth, recordUid); + if (fallbackData) { + record = { ...record, data: fallbackData }; + title = getRecordTitle(record); } + } + + const password = getRecordPassword(record); + const [userPermissions, shareAdmins] = await Promise.all([ + fetchRecordPermissions(auth, storage, recordUid), + fetchRecordShareAdmins(auth, recordUid), + ]); + const notes = + typeof record.data?.notes === "string" && record.data.notes.trim() + ? record.data.notes.trim() + : undefined; + + return { + objectType: NsfObjectKind.Record, + recordUid, + title, + type: getRecordType(record), + revision: record.revision, + version: record.version, + folder: resolveRecordFolder(storage, recordUid), + folderLocation: findRecordFolderLocation(storage, recordUid) || "root", + login: getRecordLogin(record) || undefined, + password: password ? (unmask ? password : NSF_MASKED_VALUE) : undefined, + url: getRecordUrl(record) || undefined, + notes, + fields: buildRecordFields(record, unmask), + userPermissions, + shareAdmins, + }; } -export function resolveNsfFolder(storage: InMemoryStorage, identifier: string): string | undefined { - return resolveNsfFolderIdentifier(storage, identifier) +export function resolveNsfFolder( + storage: InMemoryStorage, + identifier: string, +): string | undefined { + return resolveNsfFolderIdentifier(storage, identifier); } -export function resolveNsfRecord(storage: InMemoryStorage, identifier: string): string | undefined { - const uid = resolveNsfRecordIdentifier(storage, identifier) - if (!uid) return undefined - return getKeeperDriveRecord(storage, uid)?.uid +export function resolveNsfRecord( + storage: InMemoryStorage, + identifier: string, +): string | undefined { + const uid = resolveNsfRecordIdentifier(storage, identifier); + if (!uid) return undefined; + return getKeeperDriveRecord(storage, uid)?.uid; } export async function getNestedShareFolder( - storage: InMemoryStorage, - auth: Auth, - identifier: string, - options: GetNsfOptions = {} + storage: InMemoryStorage, + auth: Auth, + identifier: string, + options: GetNsfOptions = {}, ): Promise { - const trimmed = identifier.trim() - if (!trimmed) { - throw new KeeperSdkError('UID or title is required.', ResultCodes.NSF_NOT_FOUND) - } - - const folderUid = resolveNsfFolder(storage, trimmed) - if (folderUid) { - return { kind: NsfObjectKind.Folder, view: await buildFolderView(auth, storage, folderUid) } - } - - const recordUid = resolveNsfRecord(storage, trimmed) - if (recordUid) { - const view = await buildRecordView(auth, storage, recordUid, options.unmask ?? false) - return { kind: NsfObjectKind.Record, view } - } - + const trimmed = identifier.trim(); + if (!trimmed) { throw new KeeperSdkError( - `Cannot find any Nested Share Folder object with UID or title: ${trimmed}`, - ResultCodes.NSF_NOT_FOUND - ) + "UID or title is required.", + ResultCodes.NSF_NOT_FOUND, + ); + } + + const folderUid = resolveNsfFolder(storage, trimmed); + if (folderUid) { + return { + kind: NsfObjectKind.Folder, + view: await buildFolderView(auth, storage, folderUid), + }; + } + + const recordUid = resolveNsfRecord(storage, trimmed); + if (recordUid) { + const view = await buildRecordView( + auth, + storage, + recordUid, + options.unmask ?? false, + ); + return { kind: NsfObjectKind.Record, view }; + } + + throw new KeeperSdkError( + `Cannot find any Nested Share Folder object with UID or title: ${trimmed}`, + ResultCodes.NSF_NOT_FOUND, + ); } -export function formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { - const lines = [ - folderDetailRow('Nested Share Folder UID', view.folderUid), - folderDetailRow('Name', view.name), - '', +export function formatNsfFolderDetail( + view: NsfFolderView, + verbose = false, +): string { + const lines = [ + folderDetailRow("Nested Share Folder UID", view.folderUid), + folderDetailRow("Name", view.name), + ]; + + if (verbose) { + lines.push( + ...formatFolderAccessTable( NSF_USER_PERMISSIONS_HEADING, - ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), - '', + view.userPermissions, + ), + ...formatFolderAccessTable( NSF_FOLDER_SHARE_ADMINS_HEADING, - ...view.shareAdmins.map((entry) => `${entry.username}: ${entry.role}`), - ] - - if (!verbose) return lines.join('\n') - - lines.push('', folderDetailRow('Parent UID', view.parentUid), folderDetailRow('Path', view.path)) + view.shareAdmins, + ), + "", + folderDetailRow("Parent UID", view.parentUid), + folderDetailRow("Path", view.path), + ); if (view.records.length > 0) { - lines.push('', 'Records:') - for (const record of view.records) { - lines.push(` ${record.uid} ${record.title} (${record.type})`) - } + lines.push("", "Records:"); + for (const record of view.records) { + lines.push(` ${record.uid} ${record.title} (${record.type})`); + } } if (view.teamPermissions.length > 0) { - lines.push('', 'Team Permissions:') - for (const entry of view.teamPermissions) { - lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`) - } + lines.push("", "Team Permissions:"); + for (const entry of view.teamPermissions) { + lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`); + } } - return lines.join('\n') -} - -export function formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { - const lines = [ - recordDetailRow('UID', view.recordUid), - recordDetailRow('Type', view.type), - recordDetailRow('Title', view.title), - ] + return lines.join("\n"); + } - if (view.login) lines.push(recordDetailRow('Login', view.login)) - if (view.password) lines.push(recordDetailRow('Password', view.password)) - if (view.url) lines.push(recordDetailRow('Url', view.url)) - if (view.notes) lines.push(recordDetailRow('Notes', view.notes)) + lines.push( + "", + NSF_USER_PERMISSIONS_HEADING, + ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), + "", + NSF_FOLDER_SHARE_ADMINS_HEADING, + ...view.shareAdmins.map((entry) => `${entry.username}: ${entry.role}`), + ); + return lines.join("\n"); +} - for (const field of view.fields) { - lines.push(...formatRecordFieldDetailLines(field)) +export function formatNsfRecordDetail( + view: NsfRecordView, + verbose = false, +): string { + const lines = [ + recordDetailRow("UID", view.recordUid), + recordDetailRow("Type", view.type), + recordDetailRow("Title", view.title), + ]; + + if (view.login) lines.push(recordDetailRow("Login", view.login)); + if (view.password) lines.push(recordDetailRow("Password", view.password)); + if (view.url) lines.push(recordDetailRow("Url", view.url)); + if (view.notes) lines.push(recordDetailRow("Notes", view.notes)); + + for (const field of view.fields) { + lines.push(...formatRecordFieldDetailLines(field)); + } + + if (view.userPermissions.length > 0) { + if (verbose) { + lines.push(...formatRecordUserPermissionTable(view.userPermissions)); + } else { + lines.push("", NSF_USER_PERMISSIONS_HEADING); + for (const entry of view.userPermissions) { + lines.push("", ...formatRecordUserPermissionBlock(entry, verbose)); + } } - - if (view.userPermissions.length > 0) { - lines.push('', NSF_USER_PERMISSIONS_HEADING) - for (const entry of view.userPermissions) { - lines.push('', ...formatRecordUserPermissionBlock(entry)) - } + } + + if (view.shareAdmins.length > 0) { + const total = view.shareAdmins.length; + const preview = view.shareAdmins.slice(0, NSF_SHARE_ADMINS_PREVIEW_LIMIT); + const headingSuffix = + total > NSF_SHARE_ADMINS_PREVIEW_LIMIT + ? `, showing first ${NSF_SHARE_ADMINS_PREVIEW_LIMIT}` + : ""; + lines.push("", `Share Admins (${total}${headingSuffix}):`); + for (const admin of preview) { + lines.push(` ${admin}`); } - - if (view.shareAdmins.length > 0) { - const total = view.shareAdmins.length - const preview = view.shareAdmins.slice(0, NSF_SHARE_ADMINS_PREVIEW_LIMIT) - const headingSuffix = - total > NSF_SHARE_ADMINS_PREVIEW_LIMIT - ? `, showing first ${NSF_SHARE_ADMINS_PREVIEW_LIMIT}` - : '' - lines.push('', `Share Admins (${total}${headingSuffix}):`) - for (const admin of preview) { - lines.push(` ${admin}`) - } - if (total > NSF_SHARE_ADMINS_PREVIEW_LIMIT) { - lines.push(` ... and ${total - NSF_SHARE_ADMINS_PREVIEW_LIMIT} more`) - } + if (total > NSF_SHARE_ADMINS_PREVIEW_LIMIT) { + lines.push(` ... and ${total - NSF_SHARE_ADMINS_PREVIEW_LIMIT} more`); } - - if (verbose) { - lines.push( - '', - recordDetailRow('Folder', view.folder?.path ?? view.folderLocation), - recordDetailRow('Revision', String(view.revision)), - recordDetailRow('Version', String(view.version)) - ) - if (view.folder?.uid) { - lines.push(recordDetailRow('Folder UID', view.folder.uid)) - } + } + + if (verbose) { + lines.push( + "", + recordDetailRow("Folder", view.folder?.path ?? view.folderLocation), + recordDetailRow("Revision", String(view.revision)), + recordDetailRow("Version", String(view.version)), + ); + if (view.folder?.uid) { + lines.push(recordDetailRow("Folder UID", view.folder.uid)); } + } - return lines.join('\n') + return lines.join("\n"); } export function formatNsfDetail(result: GetNsfResult, verbose = false): string { - return result.kind === NsfObjectKind.Folder - ? formatNsfFolderDetail(result.view, verbose) - : formatNsfRecordDetail(result.view, verbose) + return result.kind === NsfObjectKind.Folder + ? formatNsfFolderDetail(result.view, verbose) + : formatNsfRecordDetail(result.view, verbose); } -export function formatNsfJson(result: GetNsfResult): string { - if (result.kind === NsfObjectKind.Record) { - return formatNsfRecordJson(result.view) - } - return JSON.stringify(result.view, null, 2) +export function formatNsfJson( + result: GetNsfResult, + options: { includeDag?: boolean; unmask?: boolean } = {}, +): string { + if (result.kind === NsfObjectKind.Record) { + return formatNsfRecordJson(result.view, options); + } + return formatNsfFolderJson(result.view, options); } diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts index a5b5c46f..0f910791 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -1,111 +1,139 @@ -import type { Auth, Records } from '@keeper-security/keeperapi' -import { normal64Bytes, recordDetailsDataMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { decryptRecordTitleAndType } from './nsfRecordCrypto' -import { ensureNestedShareRecord, nsfToNumber, resolveNsfRecordIdentifier } from './nsfHelpers' +import type { Auth, Records } from "@keeper-security/keeperapi"; import { - GetNsfRecordDetailsFormat, - type GetNsfRecordDetailsFormatInput, - type GetNsfRecordDetailsInput, - type GetNsfRecordDetailsResult, - type NsfRecordDetailsItem, -} from './nsfTypes' + normal64Bytes, + recordDetailsDataMessage, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { decryptRecordTitleAndType } from "./nsfRecordCrypto"; +import { + ensureNestedShareRecord, + nsfToNumber, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; +import { + GetNsfRecordDetailsFormat, + type GetNsfRecordDetailsFormatInput, + type GetNsfRecordDetailsInput, + type GetNsfRecordDetailsResult, + type NsfRecordDetailsItem, +} from "./nsfTypes"; -function resolveRecordUids(storage: InMemoryStorage, identifiers: string[]): string[] { - if (identifiers.length === 0) { - throw new KeeperSdkError('At least one record UID or title is required.', ResultCodes.NSF_DETAILS_FAILED) - } +function resolveRecordUids( + storage: InMemoryStorage, + identifiers: string[], +): string[] { + if (identifiers.length === 0) { + throw new KeeperSdkError( + "At least one record UID or title is required.", + ResultCodes.NSF_DETAILS_FAILED, + ); + } - return identifiers.map((identifier) => { - const recordUid = resolveNsfRecordIdentifier(storage, identifier) - if (!recordUid) { - throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) - } - ensureNestedShareRecord(storage, recordUid, identifier) - return recordUid - }) + return identifiers.map((identifier) => { + const recordUid = resolveNsfRecordIdentifier(storage, identifier); + if (!recordUid) { + throw new KeeperSdkError( + `Record '${identifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } + ensureNestedShareRecord(storage, recordUid, identifier); + return recordUid; + }); } async function mapRecordDetailsItem( - storage: InMemoryStorage, - auth: Auth, - item: Records.IRecordData + storage: InMemoryStorage, + auth: Auth, + item: Records.IRecordData, ): Promise { - const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' - if (!recordUid) return undefined + const recordUid = item.recordUid?.length + ? webSafe64FromBytes(item.recordUid) + : ""; + if (!recordUid) return undefined; - const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) - return { - recordUid, - title, - type, - revision: nsfToNumber(item.revision, 0) ?? 0, - version: item.version ?? 0, - } + const { title, type } = await decryptRecordTitleAndType( + storage, + auth, + recordUid, + item, + ); + return { + recordUid, + title, + type, + revision: nsfToNumber(item.revision, 0) ?? 0, + version: item.version ?? 0, + }; } -export function formatNsfRecordDetailsTable(result: GetNsfRecordDetailsResult): string { - const lines: string[] = [] - for (const record of result.data) { - lines.push(`Record UID: ${record.recordUid}`) - lines.push(` Title: ${record.title}`) - lines.push(` Type: ${record.type}`) - lines.push(` Version: ${record.version}`) - lines.push(` Revision: ${record.revision}`) - lines.push('') - } - if (result.forbiddenRecords.length > 0) { - lines.push(`Forbidden records: ${result.forbiddenRecords.length}`) - for (const uid of result.forbiddenRecords) { - lines.push(` ${uid}`) - } - lines.push('') +export function formatNsfRecordDetailsTable( + result: GetNsfRecordDetailsResult, +): string { + const lines: string[] = []; + for (const record of result.data) { + lines.push(`Record UID: ${record.recordUid}`); + lines.push(` Title: ${record.title}`); + lines.push(` Type: ${record.type}`); + lines.push(` Version: ${record.version}`); + lines.push(` Revision: ${record.revision}`); + lines.push(""); + } + if (result.forbiddenRecords.length > 0) { + lines.push(`Forbidden records: ${result.forbiddenRecords.length}`); + for (const uid of result.forbiddenRecords) { + lines.push(` ${uid}`); } - lines.push(`Total records retrieved: ${result.data.length}`) - return lines.join('\n').trimEnd() + lines.push(""); + } + lines.push(`Total records retrieved: ${result.data.length}`); + return lines.join("\n").trimEnd(); } export function formatNsfRecordDetailsOutput( - result: GetNsfRecordDetailsResult, - format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table + result: GetNsfRecordDetailsResult, + format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table, ): string { - if (String(format).toLowerCase() === GetNsfRecordDetailsFormat.JSON) { - return JSON.stringify(result, null, 2) - } - return formatNsfRecordDetailsTable(result) + if (String(format).toLowerCase() === GetNsfRecordDetailsFormat.JSON) { + return JSON.stringify(result, null, 2); + } + return formatNsfRecordDetailsTable(result); } export async function getNestedShareRecordDetails( - storage: InMemoryStorage, - auth: Auth, - input: GetNsfRecordDetailsInput + storage: InMemoryStorage, + auth: Auth, + input: GetNsfRecordDetailsInput, ): Promise { - const recordUids = resolveRecordUids(storage, input.records) + const recordUids = resolveRecordUids(storage, input.records); - try { - const response = await auth.executeRest( - recordDetailsDataMessage({ - recordUids: recordUids.map((uid) => normal64Bytes(uid)), - clientTime: Date.now(), - }) - ) + try { + const response = await auth.executeRest( + recordDetailsDataMessage({ + recordUids: recordUids.map((uid) => normal64Bytes(uid)), + clientTime: Date.now(), + }), + ); - const data: NsfRecordDetailsItem[] = [] - for (const item of response.data ?? []) { - const mapped = await mapRecordDetailsItem(storage, auth, item) - if (mapped) data.push(mapped) - } - - return { - data, - forbiddenRecords: (response.forbiddenRecords ?? []).map((uid) => webSafe64FromBytes(uid)), - } - } catch (err) { - if (err instanceof KeeperSdkError) throw err - throw new KeeperSdkError( - `Failed to get nested share record details: ${extractErrorMessage(err)}`, - ResultCodes.NSF_DETAILS_FAILED - ) + const data: NsfRecordDetailsItem[] = []; + for (const item of response.data ?? []) { + const mapped = await mapRecordDetailsItem(storage, auth, item); + if (mapped) data.push(mapped); } + + return { + data, + forbiddenRecords: (response.forbiddenRecords ?? []).map((uid) => + webSafe64FromBytes(uid), + ), + }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to get nested share record details: ${extractErrorMessage(err)}`, + ResultCodes.NSF_DETAILS_FAILED, + ); + } } diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index e4d87594..c36ba8d4 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -1,151 +1,277 @@ export { - KeeperDriveKind, - NsfItemType, - formatAccessRoleType, - formatAccessType, - normalizeParentUid, - isRootFolderUid, - resolveKeeperDriveRootParentUid, - getKeeperDriveFolders, - getKeeperDriveRecords, - findRecordFolderLocation, - buildFolderPath, - isSensitiveFieldType, - resolveAccessUsername, - folderAccessDisplayRole, - isNestedShareRecord, - isNestedShareFolder, - ensureNestedShareRecord, - ensureNestedShareFolder, - resolveNsfRecordIdentifier, - resolveNsfFolderIdentifier, - resolveNsfFolderUidOrName, - findNestedShareFoldersForRecord, - checkFolderRemovePermission, - checkRecordDeletePermission, - checkRecordEditPermission, - checkFolderDeletePermission, - parseNsfPath, - findExistingChildFolder, -} from './nsfHelpers' - -export { - listNestedShareFolders, - formatListNsfTable, - renderListNsfAsciiTable, - formatListNsfCsv, - formatListNsfJson, - formatListNsfOutput, -} from './listNsf' - -export { - resolveNsfFolder, - resolveNsfRecord, - getNestedShareFolder, - formatNsfFolderDetail, - formatNsfRecordDetail, - formatNsfDetail, - formatNsfRecordJson, - formatNsfJson, - toNsfRecordJsonView, -} from './getNsf' - -export { - ListNsfFormat, - GetNsfFormat, - NsfAccessRoleLabel, - NsfObjectKind, - NsfRemoveOperation, - NsfRemoveFolderOperation, - GetNsfRecordDetailsFormat, - NSF_ACCESS_ROLE_LABELS, - resolveRecordPermissionRole, - toNsfAccessRoleLabel, -} from './nsfTypes' + ROOT_FOLDER_UID, + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + displayNsfParentUid, + findRecordFolderParentUid, + nsfFolderHasPamUserWithRotation, + nsfRecordIsRoeEligible, + isRootFolderUid, + resolveKeeperDriveRootParentUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + resolveAccessUsername, + folderAccessDisplayRole, + isNestedShareRecord, + isNestedShareFolder, + ensureNestedShareRecord, + ensureNestedShareFolder, + resolveNsfRecordIdentifier, + resolveNsfFolderIdentifier, + resolveNsfFolderUidOrName, + findNestedShareFoldersForRecord, + checkFolderRemovePermission, + checkRecordDeletePermission, + checkRecordEditPermission, + checkFolderDeletePermission, + checkFolderEditPermission, + checkFolderSharePermission, + checkRecordSharePermission, + parseNsfPath, + findExistingChildFolder, + resolveNsfRoleName, + getNsfAccessRoleLabel, + getNsfRecordPermissionRoleLabel, + normalizeNsfRecordPermissionRole, + parseShareExpiration, + parseShareExpirationValue, + validateShareExpirationTimestamp, + isShareExpirationNoop, +} from "./nsfHelpers"; +export type { NsfRecordAccessFlags } from "./nsfHelpers"; + +export { + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, +} from "./listNsf"; + +export { + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, + formatNsfRecordJson, + formatNsfFolderJson, + formatNsfJson, + toNsfRecordJsonView, + toNsfFolderJsonView, + NSF_UNMASK_WARNING, +} from "./getNsf"; + +export { + ListNsfFormat, + GetNsfFormat, + NsfAccessRoleLabel, + NsfObjectKind, + NsfRemoveOperation, + NsfRemoveFolderOperation, + GetNsfRecordDetailsFormat, + NsfFolderShareAction, + NsfFolderShareActionTaken, + NsfRecordShareAction, + NsfRecordShareActionTaken, + NsfRecordPermissionAction, + NsfResultStatus, + NsfTransferApiStatus, + NsfPermissionFailureCode, + NSF_ACCESS_ROLE_LABELS, + resolveRecordPermissionRole, + toNsfAccessRoleLabel, +} from "./nsfTypes"; + +export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfRecordFieldView, + NsfRecordFolderView, + NsfRecordJsonView, + NsfFolderJsonView, + NsfRecordJsonUserPermission, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, + RecordAddPayload, + RecordUpdatePayload, + FolderCreatePayload, + LinkNsfRecordResult, + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, + NsfFolderShareActionInput, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, + ParseShareExpirationInput, + NsfTeamPublicKeys, + NsfResolvedShareRecipient, +} from "./nsfTypes"; + +export { + fetchNsfTeamPublicKeys, + encryptNsfFolderKeyForTeam, + resolveNsfShareRecipient, +} from "./nsfTeamShare"; + +export { linkNestedShareRecord } from "./linkNsfRecord"; + +export { + removeNestedShareRecords, + formatRemoveNsfPreview, + collectRemoveNsfWarnings, +} from "./removeNsfRecord"; + +export { mkdirNestedShareFolder } from "./mkdirNsf"; + +export { + updateNestedShareFolder, + updateNestedShareFolders, +} from "./updateNsfFolder"; +export type { + UpdateNsfFolderInput, + UpdateNsfFolderResult, + UpdateNsfFolderBatchItem, + UpdateNsfFolderBatchResultItem, + UpdateNsfFoldersResult, +} from "./updateNsfFolder"; + +export { + NSF_FOLDER_COLORS, + NSF_MAX_RECORD_BATCH, + NSF_MAX_FOLDER_UPDATES, + NSF_MAX_REMOVALS, + MIN_SHARE_EXPIRATION_MS, + NSF_SHARE_EXPIRATION_NEVER, + NSF_SHARE_BATCH_SIZE, + NSF_RECORD_PERMISSION_ROLES, + TeamGetKeysResponseKeyType, + NSFShareRoleName, + NsfShareCommandName, + getFolderPermissionsForRole, +} from "./nsfConstants"; export type { - ListNsfFormatInput, - ListNsfOptions, - ListNsfRow, - FormattedListNsfTable, - GetNsfFormatInput, - GetNsfOptions, - GetNsfResult, - NsfFolderView, - NsfRecordView, - NsfRecordFieldView, - NsfRecordFolderView, - NsfRecordJsonView, - NsfRecordJsonUserPermission, - NsfFolderPermission, - NsfFolderAccessRow, - NsfRecordPermission, - MkdirNsfInput, - MkdirNsfResult, - NsfFolderColorInput, - AddNsfRecordInput, - AddNsfRecordResult, - AddNsfRecordsInput, - AddNsfRecordsResult, - UpdateNsfRecordInput, - UpdateNsfRecordItemInput, - UpdateNsfRecordsInput, - UpdateNsfRecordResult, - UpdateNsfRecordResultItem, - RecordAddPayload, - RecordUpdatePayload, - FolderCreatePayload, - LinkNsfRecordResult, - NsfRemoveOperationInput, - RemoveNsfRecordInput, - NsfRemovePreviewItem, - RemoveNsfRecordResult, - NsfRemoveFolderOperationInput, - RemoveNsfFolderInput, - NsfRemoveFolderPreviewItem, - RemoveNsfFolderResult, - GetNsfRecordDetailsFormatInput, - GetNsfRecordDetailsInput, - GetNsfRecordDetailsResult, - NsfRecordDetailsItem, -} from './nsfTypes' - -export { linkNestedShareRecord } from './linkNsfRecord' - -export { - removeNestedShareRecords, - formatRemoveNsfPreview, - collectRemoveNsfWarnings, -} from './removeNsfRecord' - -export { mkdirNestedShareFolder } from './mkdirNsf' -export { NSF_FOLDER_COLORS, NSF_MAX_RECORD_BATCH } from './nsfConstants' -export type { NsfFolderColor } from './nsfConstants' - -export { - removeNestedShareFolders, - formatRemoveNsfFolderPreview, -} from './removeNsfFolder' - -export { - getNestedShareRecordDetails, - formatNsfRecordDetailsTable, - formatNsfRecordDetailsOutput, -} from './getNsfRecordDetails' - -export { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' - -export { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' - -export { clearNsfRecordTypeCache } from './nsfRecordTypes' - -export { - buildNsfRecordData, - parseNsfFieldInput, - parseNsfFieldSpaceInput, - resolveNsfFieldValue, - type ParsedNsfFields, - type RecordFieldEntry, -} from './nsfRecordData' - -export { NestedShareFolderManager } from './NestedShareFolderManager' + NsfFolderColor, + NsfRecordPermissionRole, + NsfRecordPermissionRoleInput, +} from "./nsfConstants"; + +export { + removeNestedShareFolders, + formatRemoveNsfFolderPreview, +} from "./removeNsfFolder"; + +export { + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, +} from "./getNsfRecordDetails"; + +export { + updateNestedShareRecords, + updateNestedShareRecord, +} from "./updateNsfRecord"; + +export { addNestedShareRecord, addNestedShareRecords } from "./addNsfRecord"; + +export { clearNsfRecordTypeCache } from "./nsfRecordTypes"; + +export { + buildNsfRecordData, + parseNsfFieldInput, + parseNsfFieldSpaceInput, + resolveNsfFieldValue, + type ParsedNsfFields, + type RecordFieldEntry, +} from "./nsfRecordData"; + +export { NestedShareFolderManager } from "./NestedShareFolderManager"; + +export { + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, +} from "./nsfShare"; + +export { + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, +} from "./nsfRecordPermission"; + +export { + getNsfRecordShortcuts, + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, +} from "./nsfShortcut"; + +export { + transferNestedShareRecords, + formatTransferNestedShareRecordResults, +} from "./nsfTransferRecord"; diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts index d00aea38..fb3069ad 100644 --- a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -1,132 +1,176 @@ -import type { Auth, DRecordMetadata, EncryptionType } from '@keeper-security/keeperapi' +import type { + Auth, + DRecordMetadata, + EncryptionType, +} from "@keeper-security/keeperapi"; import { - Folder, - Records, - folderRecordUpdateMessage, - normal64Bytes, - platform, - webSafe64FromBytes, -} from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { VaultObjectKind } from '../folders/folderHelpers' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' + Folder, + Records, + folderRecordUpdateMessage, + normal64Bytes, + platform, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { VaultObjectKind } from "../folders/folderHelpers"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; import { - ensureNestedShareFolder, - ensureNestedShareRecord, - resolveNsfFolderIdentifier, - resolveNsfRecordIdentifier, -} from './nsfHelpers' -import type { LinkNsfRecordResult } from './nsfTypes' + ensureNestedShareFolder, + ensureNestedShareRecord, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; function resolveRecordKeyType( - storage: InMemoryStorage, - recordUid: string + storage: InMemoryStorage, + recordUid: string, ): { encryptionType: EncryptionType; keyType: Folder.EncryptedKeyType } { - const metadata = storage.getByUid(VaultObjectKind.Metadata, recordUid) - if (metadata?.recordKeyType === Records.RecordKeyType.ENCRYPTED_BY_DATA_KEY) { - return { - encryptionType: 'cbc', - keyType: Folder.EncryptedKeyType.encrypted_by_data_key, - } - } + const metadata = storage.getByUid( + VaultObjectKind.Metadata, + recordUid, + ); + if (metadata?.recordKeyType === Records.RecordKeyType.ENCRYPTED_BY_DATA_KEY) { return { - encryptionType: 'gcm', - keyType: Folder.EncryptedKeyType.encrypted_by_data_key_gcm, - } + encryptionType: "cbc", + keyType: Folder.EncryptedKeyType.encrypted_by_data_key, + }; + } + return { + encryptionType: "gcm", + keyType: Folder.EncryptedKeyType.encrypted_by_data_key_gcm, + }; } +export type LinkNsfRecordResult = { + success: boolean; + recordUid: string; + folderUid: string; + status: string; + message: string; +}; + async function buildRecordMetadata( - storage: InMemoryStorage, - folderUid: string, - recordUid: string + storage: InMemoryStorage, + folderUid: string, + recordUid: string, ): Promise { - const recordKey = await storage.getKeyBytes(recordUid) - const folderKey = await storage.getKeyBytes(folderUid) - if (!recordKey) { - throw new KeeperSdkError( - `Record key not found for ${recordUid}. Run sync() first.`, - ResultCodes.NSF_MISSING_KEY - ) - } - if (!folderKey) { - throw new KeeperSdkError( - `Folder key not found for ${folderUid}. Run sync() first.`, - ResultCodes.NSF_MISSING_KEY - ) - } + const recordKey = await storage.getKeyBytes(recordUid); + const folderKey = await storage.getKeyBytes(folderUid); + if (!recordKey) { + throw new KeeperSdkError( + `Record key not found for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); + } + if (!folderKey) { + throw new KeeperSdkError( + `Folder key not found for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); + } - const { encryptionType, keyType } = resolveRecordKeyType(storage, recordUid) - const encryptedRecordKey = await platform.wrapKey(recordUid, folderUid, encryptionType, storage) - return { - recordUid: normal64Bytes(recordUid), - encryptedRecordKey, - encryptedRecordKeyType: keyType, - } + const { encryptionType, keyType } = resolveRecordKeyType(storage, recordUid); + const encryptedRecordKey = await platform.wrapKey( + recordUid, + folderUid, + encryptionType, + storage, + ); + return { + recordUid: normal64Bytes(recordUid), + encryptedRecordKey, + encryptedRecordKeyType: keyType, + }; } function parseFolderRecordUpdateResponse( - response: Folder.IFolderRecordUpdateResponse, - folderUid: string, - recordUid: string + response: Folder.IFolderRecordUpdateResponse, + folderUid: string, + recordUid: string, ): LinkNsfRecordResult { - const result = response.folderRecordUpdateResult?.[0] - if (!result) { - return { - success: true, - recordUid, - folderUid, - status: 'SUCCESS', - message: 'Record added to folder successfully', - } - } - const statusName = Folder.FolderModifyStatus[result.status ?? Folder.FolderModifyStatus.SUCCESS] ?? 'UNKNOWN' - const success = result.status === Folder.FolderModifyStatus.SUCCESS + const result = response.folderRecordUpdateResult?.[0]; + if (!result) { return { - success, - recordUid: result.recordUid?.length ? webSafe64FromBytes(result.recordUid) : recordUid, - folderUid: response.folderUid?.length ? webSafe64FromBytes(response.folderUid) : folderUid, - status: statusName, - message: result.message || (success ? 'Record added to folder successfully' : 'Failed to link record'), - } + success: true, + recordUid, + folderUid, + status: "SUCCESS", + message: "Record added to folder successfully", + }; + } + const statusName = + Folder.FolderModifyStatus[ + result.status ?? Folder.FolderModifyStatus.SUCCESS + ] ?? "UNKNOWN"; + const success = result.status === Folder.FolderModifyStatus.SUCCESS; + return { + success, + recordUid: result.recordUid?.length + ? webSafe64FromBytes(result.recordUid) + : recordUid, + folderUid: response.folderUid?.length + ? webSafe64FromBytes(response.folderUid) + : folderUid, + status: statusName, + message: + result.message || + (success + ? "Record added to folder successfully" + : "Failed to link record"), + }; } export async function linkNestedShareRecord( - storage: InMemoryStorage, - auth: Auth, - recordIdentifier: string, - folderIdentifier: string + storage: InMemoryStorage, + auth: Auth, + recordIdentifier: string, + folderIdentifier: string, ): Promise { - const recordUid = resolveNsfRecordIdentifier(storage, recordIdentifier) - if (!recordUid) { - throw new KeeperSdkError(`Record '${recordIdentifier}' not found`, ResultCodes.NSF_NOT_FOUND) - } + const recordUid = resolveNsfRecordIdentifier(storage, recordIdentifier); + if (!recordUid) { + throw new KeeperSdkError( + `Record '${recordIdentifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } - const folderUid = resolveNsfFolderIdentifier(storage, folderIdentifier) - if (!folderUid) { - throw new KeeperSdkError(`Folder '${folderIdentifier}' not found`, ResultCodes.NSF_NOT_FOUND) - } + const folderUid = resolveNsfFolderIdentifier(storage, folderIdentifier); + if (!folderUid) { + throw new KeeperSdkError( + `Folder '${folderIdentifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } - ensureNestedShareRecord(storage, recordUid, recordIdentifier) - ensureNestedShareFolder(storage, folderUid, folderIdentifier) + ensureNestedShareRecord(storage, recordUid, recordIdentifier); + ensureNestedShareFolder(storage, folderUid, folderIdentifier); - try { - const recordMetadata = await buildRecordMetadata(storage, folderUid, recordUid) - const response = await auth.executeRest( - folderRecordUpdateMessage({ - folderUid: normal64Bytes(folderUid), - addRecords: [recordMetadata], - }) - ) - const parsed = parseFolderRecordUpdateResponse(response, folderUid, recordUid) - if (!parsed.success) { - throw new KeeperSdkError(parsed.message, ResultCodes.NSF_LINK_FAILED) - } - return parsed - } catch (err) { - if (err instanceof KeeperSdkError) throw err - throw new KeeperSdkError( - `Failed to link record to folder: ${extractErrorMessage(err)}`, - ResultCodes.NSF_LINK_FAILED - ) + try { + const recordMetadata = await buildRecordMetadata( + storage, + folderUid, + recordUid, + ); + const response = await auth.executeRest( + folderRecordUpdateMessage({ + folderUid: normal64Bytes(folderUid), + addRecords: [recordMetadata], + }), + ); + const parsed = parseFolderRecordUpdateResponse( + response, + folderUid, + recordUid, + ); + if (!parsed.success) { + throw new KeeperSdkError(parsed.message, ResultCodes.NSF_LINK_FAILED); } + return parsed; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to link record to folder: ${extractErrorMessage(err)}`, + ResultCodes.NSF_LINK_FAILED, + ); + } } diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts index 0e21e178..455cd79d 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -1,162 +1,190 @@ -import type { InMemoryStorage } from '../storage/InMemoryStorage' +import type { InMemoryStorage } from "../storage/InMemoryStorage"; import { - NsfItemType, - findRecordFolderLocation, - getKeeperDriveFolders, - getKeeperDriveRecords, - getRecordDescription, - normalizeParentUid, - resolveKeeperDriveRootParentUid, -} from './nsfHelpers' -import { getRecordTitle, getRecordType } from '../records/RecordUtils' + NsfItemType, + displayNsfParentUid, + findRecordFolderParentUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + getRecordDescription, + nsfFolderHasPamUserWithRotation, + nsfRecordIsRoeEligible, +} from "./nsfHelpers"; +import { getRecordTitle, getRecordType } from "../records/RecordUtils"; import { - NSF_LIST_DEFAULT_COLUMN_WIDTH, - NSF_LIST_FULL_HEADERS, - NSF_LIST_MIN_TRUNCATE_PREFIX, - NSF_LIST_TABLE_HEADERS, -} from './nsfConstants' -import { - ListNsfFormat, - type FormattedListNsfTable, - type ListNsfFormatInput, - type ListNsfOptions, - type ListNsfRow, -} from './nsfTypes' + NSF_LIST_DEFAULT_COLUMN_WIDTH, + NSF_LIST_FULL_HEADERS, + NSF_LIST_MIN_TRUNCATE_PREFIX, + NSF_LIST_TABLE_HEADERS, +} from "./nsfConstants"; +import type { ListNsfFormatInput, ListNsfOptions, ListNsfRow } from "./nsfTypes"; -function compareRows(a: ListNsfRow, b: ListNsfRow): number { - const typeCompare = a.itemType.localeCompare(b.itemType) - return typeCompare !== 0 ? typeCompare : a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) +export enum ListNsfFormat { + Table = "table", + CSV = "csv", + JSON = "json", } -function resolveListParentOrFolder(storage: InMemoryStorage, value: string): string { - if (value !== 'root') return value - return resolveKeeperDriveRootParentUid(storage) ?? value +export type { ListNsfFormatInput, ListNsfOptions, ListNsfRow }; + +export type FormattedListNsfTable = { + headers: string[]; + rows: string[][]; +}; + +function compareRows(a: ListNsfRow, b: ListNsfRow): number { + const typeCompare = a.itemType.localeCompare(b.itemType); + return typeCompare !== 0 + ? typeCompare + : a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); } function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { - return getKeeperDriveFolders(storage).map((folder) => ({ - itemType: NsfItemType.Folder, - uid: folder.uid, - title: folder.data.name || 'Unnamed', - type: '', - description: '', - parentOrFolder: resolveListParentOrFolder(storage, normalizeParentUid(storage, folder.parentUid)), - })) + return getKeeperDriveFolders(storage).map((folder) => ({ + itemType: NsfItemType.Folder, + uid: folder.uid, + title: folder.data.name || "Unnamed", + type: "", + description: "", + parentOrFolder: displayNsfParentUid(storage, folder.parentUid), + })); } function collectRecordRows(storage: InMemoryStorage): ListNsfRow[] { - return getKeeperDriveRecords(storage).map((record) => ({ - itemType: NsfItemType.Record, - uid: record.uid, - title: getRecordTitle(record), - type: getRecordType(record), - description: getRecordDescription(record), - parentOrFolder: resolveListParentOrFolder( - storage, - findRecordFolderLocation(storage, record.uid) || 'root' - ), - })) + return getKeeperDriveRecords(storage).map((record) => ({ + itemType: NsfItemType.Record, + uid: record.uid, + title: getRecordTitle(record), + type: getRecordType(record), + description: getRecordDescription(record), + parentOrFolder: findRecordFolderParentUid(storage, record.uid), + })); } -export function listNestedShareFolders(storage: InMemoryStorage, options: ListNsfOptions = {}): ListNsfRow[] { - const showFolders = options.folders ?? options.records == null - const showRecords = options.records ?? options.folders == null - const rows: ListNsfRow[] = [] - if (showFolders) rows.push(...collectFolderRows(storage)) - if (showRecords) rows.push(...collectRecordRows(storage)) - return rows.sort(compareRows) +export function listNestedShareFolders( + storage: InMemoryStorage, + options: ListNsfOptions = {}, +): ListNsfRow[] { + const showFolders = options.folders ?? options.records == null; + const showRecords = options.records ?? options.folders == null; + const roeEligible = options.roeEligible ?? false; + const rows: ListNsfRow[] = []; + if (showFolders) { + const folderRows = collectFolderRows(storage); + rows.push( + ...(roeEligible + ? folderRows.filter((row) => + nsfFolderHasPamUserWithRotation(storage, row.uid), + ) + : folderRows), + ); + } + if (showRecords) { + const recordRows = collectRecordRows(storage); + rows.push( + ...(roeEligible + ? recordRows.filter((row) => nsfRecordIsRoeEligible(storage, row.uid)) + : recordRows), + ); + } + return rows.sort(compareRows); } function truncateText(text: string, maxLength: number): string { - if (!text || text.length <= maxLength) return text - if (maxLength <= NSF_LIST_MIN_TRUNCATE_PREFIX) return text.slice(0, maxLength) - return `${text.slice(0, maxLength - NSF_LIST_MIN_TRUNCATE_PREFIX)}...` + if (!text || text.length <= maxLength) return text; + if (maxLength <= NSF_LIST_MIN_TRUNCATE_PREFIX) + return text.slice(0, maxLength); + return `${text.slice(0, maxLength - NSF_LIST_MIN_TRUNCATE_PREFIX)}...`; } export function formatListNsfTable( - rows: ListNsfRow[], - options: { columnWidth?: number } = {} + rows: ListNsfRow[], + options: { columnWidth?: number } = {}, ): FormattedListNsfTable { - const columnWidth = options.columnWidth ?? NSF_LIST_DEFAULT_COLUMN_WIDTH - const outRows = rows.map((row, index) => [ - String(index + 1), - row.itemType, - truncateText(row.uid, columnWidth), - truncateText(row.title, columnWidth), - truncateText(row.type, columnWidth), - truncateText(row.description, columnWidth), - ]) - return { headers: [...NSF_LIST_TABLE_HEADERS], rows: outRows } + const columnWidth = options.columnWidth ?? NSF_LIST_DEFAULT_COLUMN_WIDTH; + const outRows = rows.map((row, index) => [ + String(index + 1), + row.itemType, + truncateText(row.uid, columnWidth), + truncateText(row.title, columnWidth), + truncateText(row.type, columnWidth), + truncateText(row.description, columnWidth), + ]); + return { headers: [...NSF_LIST_TABLE_HEADERS], rows: outRows }; } export function renderListNsfAsciiTable( - table: FormattedListNsfTable, - options: { minColWidth?: number } = {} + table: FormattedListNsfTable, + options: { minColWidth?: number } = {}, ): string { - const { minColWidth = 2 } = options - const { headers, rows } = table - const columnCount = headers.length - const columnWidths = headers.map((header, columnIndex) => { - let width = Math.max(header.length, minColWidth) - for (const row of rows) { - width = Math.max(width, (row[columnIndex] || '').length, minColWidth) - } - return width - }) - const padCell = (cell: string, columnIndex: number) => - cell + ' '.repeat(columnWidths[columnIndex] - cell.length) - const formatRow = (cells: string[]) => cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - const ruleRow = columnWidths.map((width, columnIndex) => padCell('-'.repeat(width), columnIndex)).join(' ') - return [formatRow(headers), ruleRow, ...rows.map(formatRow)].join('\n') + const { minColWidth = 2 } = options; + const { headers, rows } = table; + const columnCount = headers.length; + const columnWidths = headers.map((header, columnIndex) => { + let width = Math.max(header.length, minColWidth); + for (const row of rows) { + width = Math.max(width, (row[columnIndex] || "").length, minColWidth); + } + return width; + }); + const padCell = (cell: string, columnIndex: number) => + cell + " ".repeat(columnWidths[columnIndex] - cell.length); + const formatRow = (cells: string[]) => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + const ruleRow = columnWidths + .map((width, columnIndex) => padCell("-".repeat(width), columnIndex)) + .join(" "); + return [formatRow(headers), ruleRow, ...rows.map(formatRow)].join("\n"); } function escapeCsvCell(value: string): string { - if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"` - return value + if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`; + return value; } export function formatListNsfCsv(rows: ListNsfRow[]): string { - const lines = [NSF_LIST_FULL_HEADERS.join(',')] - for (const row of rows) { - lines.push( - [ - row.itemType, - row.uid, - row.title, - row.type, - row.description, - row.parentOrFolder, - ] - .map(escapeCsvCell) - .join(',') - ) - } - return lines.join('\n') -} - -function toListNsfJsonRow(row: ListNsfRow): Record { - const out: Record = { - item_type: row.itemType, - uid: row.uid, - title: row.title, - parent_or_folder: row.parentOrFolder, - } - if (row.type) out.type = row.type - if (row.description) out.description = row.description - return out + const lines = [NSF_LIST_FULL_HEADERS.join(",")]; + for (const row of rows) { + lines.push( + [ + row.itemType, + row.uid, + row.title, + row.type, + row.description, + row.parentOrFolder, + ] + .map(escapeCsvCell) + .join(","), + ); + } + return lines.join("\n"); } export function formatListNsfJson(rows: ListNsfRow[]): string { - return JSON.stringify(rows.map(toListNsfJsonRow), null, 2) + return JSON.stringify( + rows.map((row) => ({ + "Item Type": row.itemType, + UID: row.uid, + Title: row.title, + Type: row.type, + Description: row.description, + "Parent/Folder": row.parentOrFolder, + })), + null, + 2, + ); } -export function formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = ListNsfFormat.Table): string { - switch (format) { - case ListNsfFormat.CSV: - return formatListNsfCsv(rows) - case ListNsfFormat.JSON: - return formatListNsfJson(rows) - default: - return renderListNsfAsciiTable(formatListNsfTable(rows)) - } +export function formatListNsfOutput( + rows: ListNsfRow[], + format: ListNsfFormatInput = ListNsfFormat.Table, +): string { + switch (format) { + case ListNsfFormat.CSV: + return formatListNsfCsv(rows); + case ListNsfFormat.JSON: + return formatListNsfJson(rows); + default: + return renderListNsfAsciiTable(formatListNsfTable(rows)); + } } diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts index b8350d7b..8ba29b4f 100644 --- a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -1,224 +1,251 @@ -import type { Auth } from '@keeper-security/keeperapi' +import type { Auth } from "@keeper-security/keeperapi"; import { - Folder, - folderAddMessage, - generateEncryptionKey, - generateUid, - normal64Bytes, - platform, -} from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { NSF_FOLDER_COLORS, type NsfFolderColor } from './nsfConstants' + Folder, + folderAddMessage, + generateEncryptionKey, + generateUid, + normal64Bytes, + platform, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { NSF_FOLDER_COLORS, type NsfFolderColor } from "./nsfConstants"; import { - buildFolderOwnerInfo, - cacheNewNsfFolder, - findExistingChildFolder, - isNestedShareFolder, - parseFolderModifyStatus, - parseNsfPath, - requireAuthDataKey, - resolveKeeperDriveParentUid, -} from './nsfHelpers' + buildFolderOwnerInfo, + cacheNewNsfFolder, + findExistingChildFolder, + isNestedShareFolder, + parseFolderModifyStatus, + parseNsfPath, + requireAuthDataKey, + resolveKeeperDriveParentUid, +} from "./nsfHelpers"; import type { - FolderCreatePayload, - FolderSegmentCreateSpec, - MkdirNsfInput, - MkdirNsfResult, - NsfFolderColorInput, -} from './nsfTypes' + FolderCreatePayload, + FolderSegmentCreateSpec, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, +} from "./nsfTypes"; -type NsfFolderMetadata = { - name: string - color?: string -} +export type { NsfFolderColorInput } from "./nsfTypes"; -function normalizeColor(color?: NsfFolderColorInput): NsfFolderColor | undefined { - if (!color) return undefined - if (!NSF_FOLDER_COLORS.some((candidate) => candidate === color)) { - throw new KeeperSdkError( - `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(', ')}.`, - ResultCodes.NSF_MKDIR_FAILED - ) - } - return color +type NsfFolderMetadata = { + name: string; + color?: string; +}; + +function normalizeColor( + color?: NsfFolderColorInput, +): NsfFolderColor | undefined { + if (!color) return undefined; + if (!NSF_FOLDER_COLORS.some((candidate) => candidate === color)) { + throw new KeeperSdkError( + `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(", ")}.`, + ResultCodes.NSF_MKDIR_FAILED, + ); + } + return color; } function resolveBaseFolderUid( - storage: InMemoryStorage, - baseFolderUid: string | null | undefined + storage: InMemoryStorage, + baseFolderUid: string | null | undefined, ): string | null { - if (!baseFolderUid) return null - return isNestedShareFolder(storage, baseFolderUid) ? baseFolderUid : null + if (!baseFolderUid) return null; + return isNestedShareFolder(storage, baseFolderUid) ? baseFolderUid : null; } async function resolveFolderKeyEncryptionKey( - storage: InMemoryStorage, - auth: Auth, - parentUid: string | null + storage: InMemoryStorage, + auth: Auth, + parentUid: string | null, ): Promise { - if (parentUid) { - const parentKey = await storage.getKeyBytes(parentUid) - if (parentKey) return parentKey - } - return requireAuthDataKey(auth) + if (parentUid) { + const parentKey = await storage.getKeyBytes(parentUid); + if (parentKey) return parentKey; + } + return requireAuthDataKey(auth); } async function buildFolderCreatePayload( - storage: InMemoryStorage, - auth: Auth, - folderName: string, - parentUid: string | null, - color: NsfFolderColor | undefined, - inheritPermissions: boolean + storage: InMemoryStorage, + auth: Auth, + folderName: string, + parentUid: string | null, + color: NsfFolderColor | undefined, + inheritPermissions: boolean, ): Promise { - const folderUid = generateUid() - const folderKey = generateEncryptionKey() - await storage.saveKeyBytes(folderUid, folderKey) - - const metadata: NsfFolderMetadata = { name: folderName } - if (color && color !== 'none') metadata.color = color - - const resolvedParentUid = resolveKeeperDriveParentUid(storage, parentUid) - const encryptedData = await platform.aesGcmEncrypt( - platform.stringToBytes(JSON.stringify(metadata)), - folderKey - ) - const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, resolvedParentUid) - const encryptedFolderKey = await platform.aesGcmEncrypt(folderKey, encryptionKey) - - return { - folderUid, - folderName, - parentUid, - inheritPermissions, - folderData: Folder.FolderData.create({ - folderUid: normal64Bytes(folderUid), - parentUid: resolvedParentUid ? normal64Bytes(resolvedParentUid) : undefined, - data: encryptedData, - folderKey: encryptedFolderKey, - type: Folder.FolderUsageType.UT_NORMAL, - inheritUserPermissions: inheritPermissions - ? Folder.SetBooleanValue.BOOLEAN_TRUE - : Folder.SetBooleanValue.BOOLEAN_FALSE, - ownerInfo: buildFolderOwnerInfo(auth), - }), - } + const folderUid = generateUid(); + const folderKey = generateEncryptionKey(); + await storage.saveKeyBytes(folderUid, folderKey); + + const metadata: NsfFolderMetadata = { name: folderName }; + if (color && color !== "none") metadata.color = color; + + const resolvedParentUid = resolveKeeperDriveParentUid(storage, parentUid); + const encryptedData = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(metadata)), + folderKey, + ); + const encryptionKey = await resolveFolderKeyEncryptionKey( + storage, + auth, + resolvedParentUid, + ); + const encryptedFolderKey = await platform.aesGcmEncrypt( + folderKey, + encryptionKey, + ); + + return { + folderUid, + folderName, + parentUid, + inheritPermissions, + folderData: Folder.FolderData.create({ + folderUid: normal64Bytes(folderUid), + parentUid: resolvedParentUid + ? normal64Bytes(resolvedParentUid) + : undefined, + data: encryptedData, + folderKey: encryptedFolderKey, + type: Folder.FolderUsageType.UT_NORMAL, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + ownerInfo: buildFolderOwnerInfo(auth), + }), + }; } async function createFolderSegmentsBatch( - storage: InMemoryStorage, - auth: Auth, - segments: FolderSegmentCreateSpec[], - parentUid: string | null + storage: InMemoryStorage, + auth: Auth, + segments: FolderSegmentCreateSpec[], + parentUid: string | null, ): Promise<{ folderUid: string }> { - let currentParentUid = parentUid - const payloads: FolderCreatePayload[] = [] - - for (const segment of segments) { - const payload = await buildFolderCreatePayload( - storage, - auth, - segment.segmentName, - currentParentUid, - segment.color, - segment.inheritPermissions - ) - payloads.push(payload) - currentParentUid = payload.folderUid - } - - const response = await auth.executeRest( - folderAddMessage({ folderData: payloads.map((entry) => entry.folderData) }) - ) - - for (let index = 0; index < payloads.length; index++) { - const payload = payloads[index] - parseFolderModifyStatus(response.folderAddResults?.[index], ResultCodes.NSF_MKDIR_FAILED) - await cacheNewNsfFolder( - storage, - auth, - payload.folderUid, - payload.folderName, - payload.parentUid, - payload.inheritPermissions - ) - } - - return { folderUid: payloads[payloads.length - 1].folderUid } + let currentParentUid = parentUid; + const payloads: FolderCreatePayload[] = []; + + for (const segment of segments) { + const payload = await buildFolderCreatePayload( + storage, + auth, + segment.segmentName, + currentParentUid, + segment.color, + segment.inheritPermissions, + ); + payloads.push(payload); + currentParentUid = payload.folderUid; + } + + const response = await auth.executeRest( + folderAddMessage({ folderData: payloads.map((entry) => entry.folderData) }), + ); + + for (let index = 0; index < payloads.length; index++) { + const payload = payloads[index]; + parseFolderModifyStatus( + response.folderAddResults?.[index], + ResultCodes.NSF_MKDIR_FAILED, + ); + await cacheNewNsfFolder( + storage, + auth, + payload.folderUid, + payload.folderName, + payload.parentUid, + payload.inheritPermissions, + ); + } + + return { folderUid: payloads[payloads.length - 1].folderUid }; } export async function mkdirNestedShareFolder( - storage: InMemoryStorage, - auth: Auth, - input: MkdirNsfInput + storage: InMemoryStorage, + auth: Auth, + input: MkdirNsfInput, ): Promise { - const folderPath = (input.folder ?? '').trim() - if (!folderPath) { - throw new KeeperSdkError('Folder name is required.', ResultCodes.NSF_MKDIR_FAILED) - } - - const color = normalizeColor(input.color) - const inheritPermissions = !input.noInheritPermissions - const segments = parseNsfPath(folderPath) - let parentUid: string | null = resolveBaseFolderUid(storage, input.baseFolderUid) - const lastIdx = segments.length - 1 - let createdUid: string | undefined - const pendingSegments: FolderSegmentCreateSpec[] = [] - - const flushPendingSegments = async (): Promise => { - if (pendingSegments.length === 0) return - const result = await createFolderSegmentsBatch(storage, auth, pendingSegments, parentUid) - createdUid = result.folderUid - parentUid = result.folderUid - pendingSegments.length = 0 - } - - try { - for (let idx = 0; idx < segments.length; idx++) { - const segment = segments[idx] - const isLeaf = idx === lastIdx - const existingUid = findExistingChildFolder(storage, segment, parentUid) - - if (existingUid) { - await flushPendingSegments() - if (isLeaf) { - return { - folderUid: existingUid, - created: false, - message: `Folder "${segment}" already exists.`, - } - } - parentUid = existingUid - continue - } - - pendingSegments.push({ - segmentName: segment, - color: isLeaf ? color : undefined, - inheritPermissions: isLeaf ? inheritPermissions : true, - }) + const folderPath = (input.folder ?? "").trim(); + if (!folderPath) { + throw new KeeperSdkError( + "Folder name is required.", + ResultCodes.NSF_MKDIR_FAILED, + ); + } + + const color = normalizeColor(input.color); + const inheritPermissions = !input.noInheritPermissions; + const segments = parseNsfPath(folderPath); + let parentUid: string | null = resolveBaseFolderUid( + storage, + input.baseFolderUid, + ); + const lastIdx = segments.length - 1; + let createdUid: string | undefined; + const pendingSegments: FolderSegmentCreateSpec[] = []; + + const flushPendingSegments = async (): Promise => { + if (pendingSegments.length === 0) return; + const result = await createFolderSegmentsBatch( + storage, + auth, + pendingSegments, + parentUid, + ); + createdUid = result.folderUid; + parentUid = result.folderUid; + pendingSegments.length = 0; + }; + + try { + for (let idx = 0; idx < segments.length; idx++) { + const segment = segments[idx]; + const isLeaf = idx === lastIdx; + const existingUid = findExistingChildFolder(storage, segment, parentUid); + + if (existingUid) { + await flushPendingSegments(); + if (isLeaf) { + return { + folderUid: existingUid, + created: false, + message: `Folder "${segment}" already exists.`, + }; } + parentUid = existingUid; + continue; + } + + pendingSegments.push({ + segmentName: segment, + color: isLeaf ? color : undefined, + inheritPermissions: isLeaf ? inheritPermissions : true, + }); + } - await flushPendingSegments() - - if (!createdUid) { - throw new KeeperSdkError('Folder creation did not return a UID.', ResultCodes.NSF_MKDIR_FAILED) - } + await flushPendingSegments(); - return { - folderUid: createdUid, - created: true, - message: - segments.length > 1 - ? `Created folder path "${folderPath}".` - : `Created folder "${segments[lastIdx]}".`, - } - } catch (err) { - if (err instanceof KeeperSdkError) throw err - throw new KeeperSdkError( - `Failed to create nested share folder: ${extractErrorMessage(err)}`, - ResultCodes.NSF_MKDIR_FAILED - ) + if (!createdUid) { + throw new KeeperSdkError( + "Folder creation did not return a UID.", + ResultCodes.NSF_MKDIR_FAILED, + ); } + + return { + folderUid: createdUid, + created: true, + message: createdUid, + }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to create nested share folder: ${extractErrorMessage(err)}`, + ResultCodes.NSF_MKDIR_FAILED, + ); + } } diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index b8d09c45..73e51248 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -1,87 +1,323 @@ -import { Folder } from '@keeper-security/keeperapi' +import { Folder } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; + +export const ROOT_FOLDER_UID = "AAAAAAAAAAAAAAAAAPmtNA"; export const NSF_LEGACY_RECORD_MSG = - "Record '{0}' is a legacy vault record. Nested Share Folder commands operate only on Nested Share Records." + "Record '{0}' is a legacy vault record. Nested Share Folder commands operate only on Nested Share Records."; export const NSF_LEGACY_FOLDER_MSG = - "Folder '{0}' is a legacy folder. Nested Share Folder commands operate only on Nested Share Folders." + "Folder '{0}' is a legacy folder. Nested Share Folder commands operate only on Nested Share Folders."; -export const NSF_LEGACY_RECORD_TYPES = new Set(['legacy', 'general']) +export const NSF_LEGACY_RECORD_TYPES = new Set(["legacy", "general"]); export const NSF_ACCESS_TYPE_LABELS: Record = { - [Folder.AccessType.AT_USER]: 'user', - [Folder.AccessType.AT_TEAM]: 'team', - [Folder.AccessType.AT_OWNER]: 'owner', - [Folder.AccessType.AT_ENTERPRISE]: 'enterprise', - [Folder.AccessType.AT_FOLDER]: 'folder', - [Folder.AccessType.AT_APPLICATION]: 'application', -} + [Folder.AccessType.AT_USER]: "user", + [Folder.AccessType.AT_TEAM]: "team", + [Folder.AccessType.AT_OWNER]: "owner", + [Folder.AccessType.AT_ENTERPRISE]: "enterprise", + [Folder.AccessType.AT_FOLDER]: "folder", + [Folder.AccessType.AT_APPLICATION]: "application", +}; -export const NSF_SENSITIVE_FIELD_TYPES = new Set(['password', 'secret', 'pinCode']) -export const NSF_NOTE_FIELD_TYPES = new Set(['note', 'multiline']) -export const NSF_TOP_LEVEL_FIELD_TYPES = new Set(['login', 'password', 'url', 'note', 'multiline', 'text']) -export const NSF_UNKNOWN_RECORD_TITLES = new Set(['(no data)', '(untitled)', 'Unknown']) -export const NSF_RECORD_DESCRIPTION_MAX_LENGTH = 120 +export const NSF_SENSITIVE_FIELD_TYPES = new Set([ + "password", + "secret", + "pinCode", +]); +export const NSF_NOTE_FIELD_TYPES = new Set(["note", "multiline"]); +export const NSF_TOP_LEVEL_FIELD_TYPES = new Set([ + "login", + "password", + "url", + "note", + "multiline", + "text", +]); +export const NSF_UNKNOWN_RECORD_TITLES = new Set([ + "(no data)", + "(untitled)", + "Unknown", +]); +export const NSF_RECORD_DESCRIPTION_MAX_LENGTH = 120; -export const NSF_MASKED_VALUE = '********' -export const NSF_FOLDER_LABEL_WIDTH = 22 -export const NSF_RECORD_LABEL_WIDTH = 17 -export const NSF_USER_PERMISSIONS_HEADING = 'User Permissions:' -export const NSF_FOLDER_SHARE_ADMINS_HEADING = 'Share Administrators:' -export const NSF_SHARE_ADMINS_PREVIEW_LIMIT = 10 +export const NSF_MASKED_VALUE = "********"; +export const NSF_FOLDER_LABEL_WIDTH = 22; +export const NSF_RECORD_LABEL_WIDTH = 17; +export const NSF_USER_PERMISSIONS_HEADING = "User Permissions:"; +export const NSF_FOLDER_USER_PERMISSIONS_HEADING = NSF_USER_PERMISSIONS_HEADING; +export const NSF_FOLDER_SHARE_ADMINS_HEADING = "Share Administrators:"; +export const NSF_RECORD_USER_PERMISSIONS_HEADING = NSF_USER_PERMISSIONS_HEADING; +export const NSF_SHARE_ADMINS_PREVIEW_LIMIT = 10; -export const NSF_LIST_TABLE_HEADERS = ['#', 'Item Type', 'UID', 'Title', 'Type', 'Description'] as const +export const NSF_LIST_TABLE_HEADERS = [ + "#", + "Item Type", + "UID", + "Title", + "Type", + "Description", +] as const; export const NSF_LIST_FULL_HEADERS = [ - 'Item Type', - 'UID', - 'Title', - 'Type', - 'Description', - 'Parent/Folder', -] as const -export const NSF_LIST_DEFAULT_COLUMN_WIDTH = 40 -export const NSF_LIST_MIN_TRUNCATE_PREFIX = 3 - -export const NSF_MAX_RECORD_BATCH = 500 -export const NSF_MAX_FOLDER_REMOVALS = 100 + "Item Type", + "UID", + "Title", + "Type", + "Description", + "Parent/Folder", +] as const; +export const NSF_LIST_DEFAULT_COLUMN_WIDTH = 40; +export const NSF_LIST_MIN_TRUNCATE_PREFIX = 3; -export const NSF_PATH_SENTINEL = '\x00' +export const NSF_MAX_RECORD_BATCH = 500; +export const NSF_MAX_REMOVALS = 500; +export const NSF_MAX_FOLDER_REMOVALS = 100; +/** Max folder updates per `vault/folders/v3/update` request (FolderUpdateRequest.folderData). */ +export const NSF_MAX_FOLDER_UPDATES = 100; -export const NSF_FOLDER_COLORS = ['none', 'red', 'orange', 'yellow', 'green', 'blue', 'gray'] as const -export type NsfFolderColor = (typeof NSF_FOLDER_COLORS)[number] +export const NSF_PATH_SENTINEL = "\x00"; +export const NSF_FOLDER_COLORS = [ + "none", + "red", + "orange", + "yellow", + "green", + "blue", + "gray", +] as const; +export type NsfFolderColor = (typeof NSF_FOLDER_COLORS)[number]; export const NSF_KNOWN_FIELD_TYPES = new Set([ - 'login', - 'password', - 'url', - 'email', - 'text', - 'multiline', - 'secret', - 'note', - 'onetimecode', - 'date', - 'phone', - 'name', - 'address', - 'paymentcard', - 'bankaccount', - 'securityquestion', - 'host', - 'keypair', - 'fileref', - 'passkey', - 'pincode', -]) - + "login", + "password", + "url", + "email", + "text", + "multiline", + "secret", + "note", + "onetimecode", + "date", + "phone", + "name", + "address", + "paymentcard", + "bankaccount", + "securityquestion", + "host", + "keypair", + "fileref", + "passkey", + "pincode", +]); export const NSF_STRUCTURED_SUBKEYS: Record> = { - name: new Set(['first', 'middle', 'last']), - host: new Set(['hostName', 'port', 'host']), - address: new Set(['street1', 'street2', 'city', 'state', 'zip', 'country', 'address']), - paymentcard: new Set(['cardNumber', 'cardExpirationDate', 'cardSecurityCode', 'cardPin']), - bankaccount: new Set(['accountNumber', 'routingNumber', 'accountType']), - securityquestion: new Set(['question', 'answer']), - phone: new Set(['number', 'region', 'type', 'ext']), + name: new Set(["first", "middle", "last"]), + host: new Set(["hostName", "port", "host"]), + address: new Set([ + "street1", + "street2", + "city", + "state", + "zip", + "country", + "address", + ]), + paymentcard: new Set([ + "cardNumber", + "cardExpirationDate", + "cardSecurityCode", + "cardPin", + ]), + bankaccount: new Set(["accountNumber", "routingNumber", "accountType"]), + securityquestion: new Set(["question", "answer"]), + phone: new Set(["number", "region", "type", "ext"]), +}; + +export enum NSFShareRoleName { + Contributor = "contributor", + Viewer = "viewer", + ShareManager = "share-manager", + ContentManager = "content-manager", + ContentShareManager = "content-share-manager", + FullManager = "full-manager", + Unresolved = "unresolved", +} + +export const NSF_RECORD_PERMISSION_ROLES = [ + NSFShareRoleName.Viewer, + NSFShareRoleName.ShareManager, + NSFShareRoleName.ContentManager, + NSFShareRoleName.ContentShareManager, + NSFShareRoleName.FullManager, +] as const; +export type NsfRecordPermissionRole = + (typeof NSF_RECORD_PERMISSION_ROLES)[number]; +export type NsfRecordPermissionRoleInput = + | NsfRecordPermissionRole + | `${NsfRecordPermissionRole}`; + +export const NSF_RECORD_PERMISSION_ROLE_LABELS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: NSFShareRoleName.Contributor, + [Folder.AccessRoleType.REQUESTOR]: NSFShareRoleName.Contributor, + [Folder.AccessRoleType.VIEWER]: NSFShareRoleName.Viewer, + [Folder.AccessRoleType.SHARED_MANAGER]: NSFShareRoleName.ShareManager, + [Folder.AccessRoleType.CONTENT_MANAGER]: NSFShareRoleName.ContentManager, + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: + NSFShareRoleName.ContentShareManager, + [Folder.AccessRoleType.MANAGER]: NSFShareRoleName.FullManager, + [Folder.AccessRoleType.UNRESOLVED]: NSFShareRoleName.Unresolved, +}; + +export const NSF_RECORD_PERMISSION_ROLE_MAP: Record< + string, + Folder.AccessRoleType +> = { + [NSFShareRoleName.Contributor]: Folder.AccessRoleType.REQUESTOR, + requestor: Folder.AccessRoleType.REQUESTOR, + [NSFShareRoleName.Viewer]: Folder.AccessRoleType.VIEWER, + [NSFShareRoleName.ShareManager]: Folder.AccessRoleType.SHARED_MANAGER, + share_manager: Folder.AccessRoleType.SHARED_MANAGER, + shared_manager: Folder.AccessRoleType.SHARED_MANAGER, + [NSFShareRoleName.ContentManager]: Folder.AccessRoleType.CONTENT_MANAGER, + content_manager: Folder.AccessRoleType.CONTENT_MANAGER, + [NSFShareRoleName.ContentShareManager]: + Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + content_share_manager: Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + [NSFShareRoleName.FullManager]: Folder.AccessRoleType.MANAGER, + full_manager: Folder.AccessRoleType.MANAGER, +}; + +export const NSF_SHARE_EXPIRATION_NEVER = "never"; + +export enum NsfShareCommandName { + FolderShare = "nsf-share-folder", + RecordShare = "nsf-share-record", +} + +export const NSF_SHARE_BATCH_SIZE = 200; + +export enum TeamGetKeysResponseKeyType { + EccPublicKey = -1, + RsaPublicKey = -3, + EncryptedAesTeamKeyByDataKey = 1, + EncryptedAesTeamKeyByRsa = 2, + EncryptedAesTeamKeyByDataKeyGcm = 3, + EncryptedAesTeamKeyByEcc = 4, +} + +export const MIN_SHARE_EXPIRATION_MS = 60_000; +export const NSF_TLA_EXPIRATION_TOLERANCE_MS = 60_000; + +const NSF_SHARE_EXPIRATION_DAY_MS = 86_400_000; + +export const NSF_SHARE_EXPIRATION_RE = + /^(\d+)\s*(mi(?:nutes?)?|h(?:ours?)?|d(?:ays?)?|mo(?:nths?)?|y(?:ears?)?)$/i; + +export const NSF_SHARE_EXPIRATION_UNIT_MS: Record = { + mi: 60_000, + m: 60_000, + minute: 60_000, + minutes: 60_000, + h: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, + d: NSF_SHARE_EXPIRATION_DAY_MS, + day: NSF_SHARE_EXPIRATION_DAY_MS, + days: NSF_SHARE_EXPIRATION_DAY_MS, + mo: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + month: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + months: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + y: 365 * NSF_SHARE_EXPIRATION_DAY_MS, + year: 365 * NSF_SHARE_EXPIRATION_DAY_MS, + years: 365 * NSF_SHARE_EXPIRATION_DAY_MS, +}; + +type FolderRolePermissionFlags = { + canAdd?: boolean; + canRemove?: boolean; + canDelete?: boolean; + canListAccess?: boolean; + canUpdateAccess?: boolean; + canChangeOwnership?: boolean; + canEditRecords?: boolean; + canViewRecords?: boolean; + canApproveAccess?: boolean; + canRequestAccess?: boolean; + canUpdateSetting?: boolean; + canListRecords?: boolean; + canListFolders?: boolean; +}; + +const NSF_FOLDER_ROLE_PERMISSIONS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: { + canListFolders: true, + }, + [Folder.AccessRoleType.REQUESTOR]: { + canRequestAccess: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.VIEWER]: { + canListAccess: true, + canViewRecords: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.SHARED_MANAGER]: { + canListAccess: true, + canUpdateAccess: true, + canViewRecords: true, + canApproveAccess: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.CONTENT_MANAGER]: { + canAdd: true, + canListAccess: true, + canEditRecords: true, + canViewRecords: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: { + canAdd: true, + canRemove: true, + canListAccess: true, + canUpdateAccess: true, + canEditRecords: true, + canViewRecords: true, + canApproveAccess: true, + canUpdateSetting: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.MANAGER]: { + canAdd: true, + canRemove: true, + canDelete: true, + canListAccess: true, + canUpdateAccess: true, + canChangeOwnership: true, + canEditRecords: true, + canViewRecords: true, + canApproveAccess: true, + canUpdateSetting: true, + canListRecords: true, + canListFolders: true, + }, +}; + +export function getFolderPermissionsForRole( + roleType: Folder.AccessRoleType, +): Folder.IFolderPermissions { + const flags = NSF_FOLDER_ROLE_PERMISSIONS[roleType]; + if (!flags) { + throw new KeeperSdkError( + `Unknown folder access role type: ${roleType}`, + ResultCodes.NSF_SHARE_FAILED, + ); + } + return Folder.FolderPermissions.create(flags); } diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 7e42605d..4b5d7b66 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,804 +1,1761 @@ import type { - Auth, - DRecord, - DUser, - DKdFolder, - DKdFolderAccess, - DKdFolderRecord, - DKdRecordAccess, -} from '@keeper-security/keeperapi' + Auth, + DRecord, + DRecordRotation, + DUser, + DKdFolder, + DKdFolderAccess, + DKdFolderRecord, + DKdRecordAccess, +} from "@keeper-security/keeperapi"; import { - Folder, - Records, - getRecordAccessMessage, - getShareObjectsMessage, - normal64Bytes, - webSafe64FromBytes, -} from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { VaultObjectKind } from '../folders/folderHelpers' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { getRecordTitle } from '../records/RecordUtils' + Folder, + Records, + getFolderAccessMessage, + getRecordAccessMessage, + getShareObjectsMessage, + normal64Bytes, + record, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { VaultObjectKind } from "../folders/folderHelpers"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { getRecordTitle, getRecordType } from "../records/RecordUtils"; import { - NSF_ACCESS_TYPE_LABELS, - NSF_LEGACY_FOLDER_MSG, - NSF_LEGACY_RECORD_MSG, - NSF_NOTE_FIELD_TYPES, - NSF_PATH_SENTINEL, - NSF_RECORD_DESCRIPTION_MAX_LENGTH, - NSF_SENSITIVE_FIELD_TYPES, -} from './nsfConstants' -import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS } from './nsfTypes' + NSF_ACCESS_TYPE_LABELS, + NSF_LEGACY_FOLDER_MSG, + NSF_LEGACY_RECORD_MSG, + NSF_NOTE_FIELD_TYPES, + NSF_PATH_SENTINEL, + NSF_RECORD_DESCRIPTION_MAX_LENGTH, + NSF_RECORD_PERMISSION_ROLE_LABELS, + NSF_RECORD_PERMISSION_ROLE_MAP, + NSF_RECORD_PERMISSION_ROLES, + NSF_SENSITIVE_FIELD_TYPES, + NSF_SHARE_BATCH_SIZE, + MIN_SHARE_EXPIRATION_MS, + NSF_TLA_EXPIRATION_TOLERANCE_MS, + NSF_SHARE_EXPIRATION_RE, + NSF_SHARE_EXPIRATION_UNIT_MS, + NSF_SHARE_EXPIRATION_NEVER, + NsfShareCommandName, +} from "./nsfConstants"; +import { + NsfAccessRoleLabel, + NSF_ACCESS_ROLE_LABELS, + type ParseShareExpirationInput, +} from "./nsfTypes"; + +export { ROOT_FOLDER_UID } from "./nsfConstants"; export enum KeeperDriveKind { - Folder = 'keeper_drive_folder', - FolderAccess = 'keeper_drive_folder_access', - FolderRecord = 'keeper_drive_folder_record', - RecordAccess = 'keeper_drive_record_access', + Folder = "keeper_drive_folder", + FolderAccess = "keeper_drive_folder_access", + FolderRecord = "keeper_drive_folder_record", + RecordAccess = "keeper_drive_record_access", } export enum NsfItemType { - Folder = 'Folder', - Record = 'Record', + Folder = "Folder", + Record = "Record", } export function nsfToNumber( - value: number | { toNumber: () => number } | null | undefined, - fallback?: number + value: number | { toNumber: () => number } | null | undefined, + fallback?: number, ): number | undefined { - if (value == null) return fallback - return typeof value === 'number' ? value : value.toNumber() + if (value == null) return fallback; + return typeof value === "number" ? value : value.toNumber(); } export function requireAuthAccountUid(auth: Auth): Uint8Array { - const accountUid = auth.accountUid - if (!accountUid?.length) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } - return accountUid + const accountUid = auth.accountUid; + if (!accountUid?.length) { + throw new KeeperSdkError( + "Not logged in. Call login() first.", + ResultCodes.NOT_LOGGED_IN, + ); + } + return accountUid; } export function requireAuthDataKey(auth: Auth): Uint8Array { - if (!auth.dataKey?.length) { - throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) - } - return auth.dataKey + if (!auth.dataKey?.length) { + throw new KeeperSdkError( + "Data key not available. Ensure you are logged in.", + ResultCodes.NSF_MISSING_KEY, + ); + } + return auth.dataKey; } export function buildFolderOwnerInfo(auth: Auth): Folder.IUserInfo | undefined { - if (!auth.accountUid?.length) return undefined - return { - accountUid: auth.accountUid, - username: auth.username, - } + if (!auth.accountUid?.length) return undefined; + return { + accountUid: auth.accountUid, + username: auth.username, + }; } export function parseFolderModifyStatus( - result: Folder.IFolderModifyResult | null | undefined, - failureCode: string + result: Folder.IFolderModifyResult | null | undefined, + failureCode: string, ): string { - if (!result) { - throw new KeeperSdkError('No results from folder operation.', failureCode) - } - const status = result.status ?? Folder.FolderModifyStatus.SUCCESS - const statusName = Folder.FolderModifyStatus[status] ?? String(status) - if (status !== Folder.FolderModifyStatus.SUCCESS) { - throw new KeeperSdkError( - result.message || `Folder operation failed (${statusName}).`, - failureCode - ) - } - return result.message || 'Folder operation succeeded' + if (!result) { + throw new KeeperSdkError("No results from folder operation.", failureCode); + } + const status = result.status ?? Folder.FolderModifyStatus.SUCCESS; + const statusName = Folder.FolderModifyStatus[status] ?? String(status); + if (status !== Folder.FolderModifyStatus.SUCCESS) { + throw new KeeperSdkError( + result.message || `Folder operation failed (${statusName}).`, + failureCode, + ); + } + return result.message || "Folder operation succeeded"; } export function parseRecordModifyStatus( - result: Records.IRecordModifyStatus | null | undefined, - failureCode: string + result: Records.IRecordModifyStatus | null | undefined, + failureCode: string, ): { statusName: string; message: string } { - if (!result) { - throw new KeeperSdkError('No results from record operation.', failureCode) - } - const status = result.status ?? Records.RecordModifyResult.RS_SUCCESS - const statusName = - status === Records.RecordModifyResult.RS_SUCCESS - ? 'SUCCESS' - : (Records.RecordModifyResult[status] ?? String(status)) - if (status !== Records.RecordModifyResult.RS_SUCCESS) { - throw new KeeperSdkError( - result.message || `Record operation failed (${statusName}).`, - failureCode - ) - } - return { - statusName, - message: result.message || 'Record operation succeeded', - } + if (!result) { + throw new KeeperSdkError("No results from record operation.", failureCode); + } + const status = result.status ?? Records.RecordModifyResult.RS_SUCCESS; + const statusName = + status === Records.RecordModifyResult.RS_SUCCESS + ? "SUCCESS" + : (Records.RecordModifyResult[status] ?? String(status)); + if (status !== Records.RecordModifyResult.RS_SUCCESS) { + throw new KeeperSdkError( + result.message || `Record operation failed (${statusName}).`, + failureCode, + ); + } + return { + statusName, + message: result.message || "Record operation succeeded", + }; } -export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string): boolean { - return !!getKeeperDriveRecord(storage, recordUid) +export function isNestedShareRecord( + storage: InMemoryStorage, + recordUid: string, +): boolean { + return !!getKeeperDriveRecord(storage, recordUid); } function getKnownKeeperDriveFolderUids(storage: InMemoryStorage): Set { - return new Set(getKeeperDriveFolders(storage).map((folder) => folder.uid)) + return new Set(getKeeperDriveFolders(storage).map((folder) => folder.uid)); } -export function resolveKeeperDriveRootParentUid(storage: InMemoryStorage): string | undefined { - const knownFolderUids = getKnownKeeperDriveFolderUids(storage) - for (const folder of getKeeperDriveFolders(storage)) { - const parentUid = folder.parentUid?.trim() - if (parentUid && !knownFolderUids.has(parentUid)) { - return parentUid - } +export function resolveKeeperDriveRootParentUid( + storage: InMemoryStorage, +): string | undefined { + const knownFolderUids = getKnownKeeperDriveFolderUids(storage); + for (const folder of getKeeperDriveFolders(storage)) { + const parentUid = folder.parentUid?.trim(); + if (parentUid && !knownFolderUids.has(parentUid)) { + return parentUid; } - return undefined + } + return undefined; } export function isRootFolderUid( - storage: InMemoryStorage, - folderUid: string | undefined | null + storage: InMemoryStorage, + folderUid: string | undefined | null, ): boolean { - const value = (folderUid ?? '').trim() - if (!value) return true - const driveRoot = resolveKeeperDriveRootParentUid(storage) - return !!driveRoot && value === driveRoot + const value = (folderUid ?? "").trim(); + if (!value) return true; + const driveRoot = resolveKeeperDriveRootParentUid(storage); + return !!driveRoot && value === driveRoot; +} + +export function displayNsfParentUid( + storage: InMemoryStorage, + parentUid: string | undefined | null, +): string { + if (isRootFolderUid(storage, parentUid)) { + return resolveKeeperDriveRootParentUid(storage) ?? ""; + } + return (parentUid ?? "").trim(); } export function normalizeParentUid( - storage: InMemoryStorage, - parentUid: string | undefined | null + storage: InMemoryStorage, + parentUid: string | undefined | null, ): string { - return isRootFolderUid(storage, parentUid) ? 'root' : (parentUid ?? '').trim() + return isRootFolderUid(storage, parentUid) + ? "root" + : (parentUid ?? "").trim(); } -export function isNestedShareFolder(storage: InMemoryStorage, folderUid: string): boolean { - if (isRootFolderUid(storage, folderUid)) return true - return !!getKeeperDriveFolder(storage, folderUid) +export function isNestedShareFolder( + storage: InMemoryStorage, + folderUid: string, +): boolean { + if (isRootFolderUid(storage, folderUid)) return true; + return !!getKeeperDriveFolder(storage, folderUid); } -export function ensureNestedShareRecord(storage: InMemoryStorage, recordUid: string, identifier?: string): void { - if (isNestedShareRecord(storage, recordUid)) return - const ident = identifier ?? recordUid - throw new KeeperSdkError(NSF_LEGACY_RECORD_MSG.replace('{0}', ident), ResultCodes.NSF_LEGACY_RECORD) +export function ensureNestedShareRecord( + storage: InMemoryStorage, + recordUid: string, + identifier?: string, +): void { + if (isNestedShareRecord(storage, recordUid)) return; + const ident = identifier ?? recordUid; + throw new KeeperSdkError( + NSF_LEGACY_RECORD_MSG.replace("{0}", ident), + ResultCodes.NSF_LEGACY_RECORD, + ); } -export function ensureNestedShareFolder(storage: InMemoryStorage, folderUid: string, identifier?: string): void { - if (isNestedShareFolder(storage, folderUid)) return - const ident = identifier ?? folderUid - throw new KeeperSdkError(NSF_LEGACY_FOLDER_MSG.replace('{0}', ident), ResultCodes.NSF_LEGACY_FOLDER) +export function ensureNestedShareFolder( + storage: InMemoryStorage, + folderUid: string, + identifier?: string, +): void { + if (isNestedShareFolder(storage, folderUid)) return; + const ident = identifier ?? folderUid; + throw new KeeperSdkError( + NSF_LEGACY_FOLDER_MSG.replace("{0}", ident), + ResultCodes.NSF_LEGACY_FOLDER, + ); } function resolveByUidOrName( - items: T[], - identifier: string, - getUid: (item: T) => string, - getName: (item: T) => string + items: T[], + identifier: string, + getUid: (item: T) => string, + getName: (item: T) => string, ): T | undefined { - const trimmed = identifier.trim() - if (!trimmed) return undefined - - const byUid = items.find((item) => getUid(item) === trimmed) - if (byUid) return byUid - - const lower = trimmed.toLowerCase() - const nameMatches = items.filter((item) => getName(item).toLowerCase() === lower) - if (nameMatches.length === 1) return nameMatches[0] - if (nameMatches.length > 1) { - throw new KeeperSdkError( - `Multiple matches found for "${identifier}". Use a UID instead.`, - ResultCodes.MULTIPLE_NSF_MATCHES - ) - } - return undefined -} - -function resolveRecordByTitleSearch(storage: InMemoryStorage, identifier: string): DRecord | undefined { - const lower = identifier.toLowerCase() - const matches = getKeeperDriveRecords(storage).filter((record) => { - const title = getRecordTitle(record) - return title && lower.length > 0 && title.toLowerCase().includes(lower) - }) - if (matches.length === 1) return matches[0] - if (matches.length > 1) { - throw new KeeperSdkError( - `Multiple records matched "${identifier}". Use a UID instead.`, - ResultCodes.MULTIPLE_NSF_MATCHES - ) - } - return undefined + const trimmed = identifier.trim(); + if (!trimmed) return undefined; + + const byUid = items.find((item) => getUid(item) === trimmed); + if (byUid) return byUid; + + const lower = trimmed.toLowerCase(); + const nameMatches = items.filter( + (item) => getName(item).toLowerCase() === lower, + ); + if (nameMatches.length === 1) return nameMatches[0]; + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Multiple matches found for "${identifier}". Use a UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES, + ); + } + return undefined; } -function resolveFolderByPath(storage: InMemoryStorage, identifier: string): string | undefined { - const trimmed = identifier.trim().replace(/^\/+/, '') - if (!trimmed) return resolveKeeperDriveRootParentUid(storage) ?? '' +function resolveRecordByTitleSearch( + storage: InMemoryStorage, + identifier: string, +): DRecord | undefined { + const lower = identifier.toLowerCase(); + const matches = getKeeperDriveRecords(storage).filter((record) => { + const title = getRecordTitle(record); + return title && lower.length > 0 && title.toLowerCase().includes(lower); + }); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + throw new KeeperSdkError( + `Multiple records matched "${identifier}". Use a UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES, + ); + } + return undefined; +} - const targetPath = `/${trimmed.toLowerCase()}` - for (const folder of getKeeperDriveFolders(storage)) { - if (buildFolderPath(storage, folder.uid).toLowerCase() === targetPath) { - return folder.uid - } +function resolveFolderByPath( + storage: InMemoryStorage, + identifier: string, +): string | undefined { + const trimmed = identifier.trim().replace(/^\/+/, ""); + if (!trimmed) return resolveKeeperDriveRootParentUid(storage) ?? ""; + + const targetPath = `/${trimmed.toLowerCase()}`; + for (const folder of getKeeperDriveFolders(storage)) { + if (buildFolderPath(storage, folder.uid).toLowerCase() === targetPath) { + return folder.uid; } - return undefined + } + return undefined; } -export function resolveNsfRecordIdentifier(storage: InMemoryStorage, identifier: string): string | undefined { - const trimmed = identifier.trim() - if (!trimmed) return undefined +export function resolveNsfRecordIdentifier( + storage: InMemoryStorage, + identifier: string, +): string | undefined { + const trimmed = identifier.trim(); + if (!trimmed) return undefined; - const kdRecord = getKeeperDriveRecord(storage, trimmed) - if (kdRecord) return kdRecord.uid + const kdRecord = getKeeperDriveRecord(storage, trimmed); + if (kdRecord) return kdRecord.uid; - const anyRecord = storage.getByUid(VaultObjectKind.Record, trimmed) - if (anyRecord) return anyRecord.uid + const anyRecord = storage.getByUid(VaultObjectKind.Record, trimmed); + if (anyRecord) return anyRecord.uid; - return resolveRecordByTitleSearch(storage, trimmed)?.uid + return resolveRecordByTitleSearch(storage, trimmed)?.uid; } -export function resolveNsfFolderIdentifier(storage: InMemoryStorage, identifier: string): string | undefined { - const trimmed = identifier.trim() - if (!trimmed) return undefined - - if (trimmed.toLowerCase() === 'root' || trimmed === '/') { - return resolveKeeperDriveRootParentUid(storage) ?? '' - } - const driveRoot = resolveKeeperDriveRootParentUid(storage) - if (driveRoot && trimmed === driveRoot) return driveRoot - - const byUidOrName = resolveByUidOrName( - getKeeperDriveFolders(storage), - trimmed, - (folder) => folder.uid, - (folder) => folder.data.name || '' - ) - if (byUidOrName) return byUidOrName.uid - - return resolveFolderByPath(storage, trimmed) +export function resolveNsfFolderIdentifier( + storage: InMemoryStorage, + identifier: string, +): string | undefined { + const trimmed = identifier.trim(); + if (!trimmed) return undefined; + + if (trimmed.toLowerCase() === "root" || trimmed === "/") { + return resolveKeeperDriveRootParentUid(storage) ?? ""; + } + const driveRoot = resolveKeeperDriveRootParentUid(storage); + if (driveRoot && trimmed === driveRoot) return driveRoot; + + const byUidOrName = resolveByUidOrName( + getKeeperDriveFolders(storage), + trimmed, + (folder) => folder.uid, + (folder) => folder.data.name || "", + ); + if (byUidOrName) return byUidOrName.uid; + + return resolveFolderByPath(storage, trimmed); } -export function resolveNsfFolderUidOrName(storage: InMemoryStorage, identifier: string): string | undefined { - const trimmed = identifier.trim() - if (!trimmed) return undefined +export function resolveNsfFolderUidOrName( + storage: InMemoryStorage, + identifier: string, +): string | undefined { + const trimmed = identifier.trim(); + if (!trimmed) return undefined; - if (getKeeperDriveFolder(storage, trimmed)) return trimmed + if (getKeeperDriveFolder(storage, trimmed)) return trimmed; - return resolveByUidOrName( - getKeeperDriveFolders(storage), - trimmed, - (folder) => folder.uid, - (folder) => folder.data.name || '' - )?.uid + return resolveByUidOrName( + getKeeperDriveFolders(storage), + trimmed, + (folder) => folder.uid, + (folder) => folder.data.name || "", + )?.uid; } export function parseNsfPath(folderPath: string): string[] { - const collapsed = folderPath.replace(/\/\//g, NSF_PATH_SENTINEL) - const segments: string[] = [] - for (const raw of collapsed.split('/')) { - const name = raw.replace(new RegExp(NSF_PATH_SENTINEL, 'g'), '/').trim() - if (name) segments.push(name) - } - if (segments.length === 0) { - throw new KeeperSdkError('Invalid folder name.', ResultCodes.NSF_MKDIR_FAILED) - } - return segments + const collapsed = folderPath.replace(/\/\//g, NSF_PATH_SENTINEL); + const segments: string[] = []; + for (const raw of collapsed.split("/")) { + const name = raw.replace(new RegExp(NSF_PATH_SENTINEL, "g"), "/").trim(); + if (name) segments.push(name); + } + if (segments.length === 0) { + throw new KeeperSdkError( + "Invalid folder name.", + ResultCodes.NSF_MKDIR_FAILED, + ); + } + return segments; } -function isVirtualDriveRootParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { - const trimmed = parentUid?.trim() - if (!trimmed || isRootFolderUid(storage, trimmed)) return true - return !getKnownKeeperDriveFolderUids(storage).has(trimmed) +function isVirtualDriveRootParent( + storage: InMemoryStorage, + parentUid: string | undefined | null, +): boolean { + const trimmed = parentUid?.trim(); + if (!trimmed || isRootFolderUid(storage, trimmed)) return true; + return !getKnownKeeperDriveFolderUids(storage).has(trimmed); } -function isRootLevelParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { - return isVirtualDriveRootParent(storage, parentUid) +function isRootLevelParent( + storage: InMemoryStorage, + parentUid: string | undefined | null, +): boolean { + return isVirtualDriveRootParent(storage, parentUid); } function folderParentsMatch( - storage: InMemoryStorage, - folderParentUid: string | undefined, - searchParentUid: string | null | undefined + storage: InMemoryStorage, + folderParentUid: string | undefined, + searchParentUid: string | null | undefined, ): boolean { - if (normalizeParentUid(storage, folderParentUid) === normalizeParentUid(storage, searchParentUid)) { - return true - } - return isRootLevelParent(storage, searchParentUid) && isRootLevelParent(storage, folderParentUid) + if ( + normalizeParentUid(storage, folderParentUid) === + normalizeParentUid(storage, searchParentUid) + ) { + return true; + } + return ( + isRootLevelParent(storage, searchParentUid) && + isRootLevelParent(storage, folderParentUid) + ); } export function findExistingChildFolder( - storage: InMemoryStorage, - segment: string, - parentUid: string | null | undefined + storage: InMemoryStorage, + segment: string, + parentUid: string | null | undefined, ): string | undefined { - const byUid = getKeeperDriveFolder(storage, segment) - if (byUid) { - if (parentUid == null || parentUid === '') { - return segment - } - if (normalizeParentUid(storage, byUid.parentUid) === normalizeParentUid(storage, parentUid)) { - return segment - } - return undefined + const byUid = getKeeperDriveFolder(storage, segment); + if (byUid) { + if (parentUid == null || parentUid === "") { + return segment; } + if ( + normalizeParentUid(storage, byUid.parentUid) === + normalizeParentUid(storage, parentUid) + ) { + return segment; + } + return undefined; + } - const lower = segment.toLowerCase() - const exactMatches: string[] = [] - const rootMatches: string[] = [] + const lower = segment.toLowerCase(); + const exactMatches: string[] = []; + const rootMatches: string[] = []; - for (const folder of getKeeperDriveFolders(storage)) { - const name = folder.data.name || '' - if (name.toLowerCase() !== lower) continue + for (const folder of getKeeperDriveFolders(storage)) { + const name = folder.data.name || ""; + if (name.toLowerCase() !== lower) continue; - if (normalizeParentUid(storage, folder.parentUid) === normalizeParentUid(storage, parentUid)) { - exactMatches.push(folder.uid) - continue - } - if (folderParentsMatch(storage, folder.parentUid, parentUid)) { - rootMatches.push(folder.uid) - } + if ( + normalizeParentUid(storage, folder.parentUid) === + normalizeParentUid(storage, parentUid) + ) { + exactMatches.push(folder.uid); + continue; } + if (folderParentsMatch(storage, folder.parentUid, parentUid)) { + rootMatches.push(folder.uid); + } + } - if (exactMatches.length > 0) return exactMatches[0] - if (rootMatches.length > 0) return rootMatches[0] - return undefined + if (exactMatches.length > 0) return exactMatches[0]; + if (rootMatches.length > 0) return rootMatches[0]; + return undefined; } export function resolveKeeperDriveParentUid( - storage: InMemoryStorage, - parentUid: string | null | undefined + storage: InMemoryStorage, + parentUid: string | null | undefined, ): string | null { - if (parentUid && !isRootFolderUid(storage, parentUid)) return parentUid - return resolveKeeperDriveRootParentUid(storage) ?? null + if (parentUid && !isRootFolderUid(storage, parentUid)) return parentUid; + return resolveKeeperDriveRootParentUid(storage) ?? null; } export async function cacheNewNsfFolder( - storage: InMemoryStorage, - auth: { username?: string; accountUid?: Uint8Array }, - folderUid: string, - name: string, - parentUid: string | null | undefined, - inheritPermissions: boolean + storage: InMemoryStorage, + auth: { username?: string; accountUid?: Uint8Array }, + folderUid: string, + name: string, + parentUid: string | null | undefined, + inheritPermissions: boolean, ): Promise { - const normalizedParent = - parentUid && !isRootFolderUid(storage, parentUid) - ? parentUid.trim() - : resolveKeeperDriveRootParentUid(storage) - await storage.put({ - kind: 'keeper_drive_folder', - uid: folderUid, - data: { name }, - parentUid: normalizedParent, - ownerInfo: { - accountUid: auth.accountUid?.length ? webSafe64FromBytes(auth.accountUid) : undefined, - username: auth.username, - }, - type: Folder.FolderUsageType.UT_NORMAL, - inheritUserPermissions: inheritPermissions - ? Folder.SetBooleanValue.BOOLEAN_TRUE - : Folder.SetBooleanValue.BOOLEAN_FALSE, - }) + const normalizedParent = + parentUid && !isRootFolderUid(storage, parentUid) + ? parentUid.trim() + : resolveKeeperDriveRootParentUid(storage); + await storage.put({ + kind: "keeper_drive_folder", + uid: folderUid, + data: { name }, + parentUid: normalizedParent, + ownerInfo: { + accountUid: auth.accountUid?.length + ? webSafe64FromBytes(auth.accountUid) + : undefined, + username: auth.username, + }, + type: Folder.FolderUsageType.UT_NORMAL, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + }); } export function checkFolderDeletePermission( - storage: InMemoryStorage, - folderUid: string, - username: string, - accountUid: Uint8Array + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array, ): void { - if (isRootFolderUid(storage, folderUid)) { - throw new KeeperSdkError('The root folder cannot be removed.', ResultCodes.NSF_PERMISSION_DENIED) - } - - const accountUidStr = toRequiredAccountUidStr(accountUid) - const entries = getFolderAccessEntries(storage, folderUid) - if (entries.length === 0) return - - for (const entry of entries) { - if (!isCurrentUserFolderAccess(storage, entry, username, accountUidStr)) continue - if (entry.accessType === Folder.AccessType.AT_OWNER || entry.permission?.canDelete) return - throw new KeeperSdkError( - 'You do not have permission to delete this folder.', - ResultCodes.NSF_PERMISSION_DENIED - ) - } + if (isRootFolderUid(storage, folderUid)) { throw new KeeperSdkError( - 'You do not have permission to delete this folder.', - ResultCodes.NSF_PERMISSION_DENIED + "The root folder cannot be removed.", + ResultCodes.NSF_PERMISSION_DENIED, + ); + } + + const accountUidStr = toRequiredAccountUidStr(accountUid); + const entries = getFolderAccessEntries(storage, folderUid); + if (entries.length === 0) return; + + for (const entry of entries) { + if (!isCurrentUserFolderAccess(storage, entry, username, accountUidStr)) + continue; + if ( + entry.accessType === Folder.AccessType.AT_OWNER || + entry.permission?.canDelete ) + return; + throw new KeeperSdkError( + "You do not have permission to delete this folder.", + ResultCodes.NSF_PERMISSION_DENIED, + ); + } + throw new KeeperSdkError( + "You do not have permission to delete this folder.", + ResultCodes.NSF_PERMISSION_DENIED, + ); +} + +export function findNestedShareFoldersForRecord( + storage: InMemoryStorage, + recordUid: string, +): string[] { + return storage + .getAll(KeeperDriveKind.FolderRecord) + .filter((entry) => entry.recordUid === recordUid) + .map((entry) => entry.folderUid); +} + +export function findRecordFolderParentUid( + storage: InMemoryStorage, + recordUid: string, +): string { + const folderUids = findNestedShareFoldersForRecord(storage, recordUid); + if (folderUids.length === 0) { + return resolveKeeperDriveRootParentUid(storage) ?? ""; + } + return folderUids[0]; +} + +function recordHasRotationConfigured( + storage: InMemoryStorage, + recordUid: string, +): boolean { + const rotation = storage.getByUid( + "record_rotation", + recordUid, + ); + return rotation != null && rotation.disabled !== true; } -export function findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { - return storage - .getAll(KeeperDriveKind.FolderRecord) - .filter((entry) => entry.recordUid === recordUid) - .map((entry) => entry.folderUid) +export function nsfFolderHasPamUserWithRotation( + storage: InMemoryStorage, + folderUid: string, +): boolean { + const targetUid = isRootFolderUid(storage, folderUid) + ? resolveKeeperDriveRootParentUid(storage) + : folderUid; + if (!targetUid) return false; + + for (const entry of storage.getAll( + KeeperDriveKind.FolderRecord, + )) { + const linkFolder = entry.folderUid?.trim(); + if (!linkFolder) continue; + const normalizedLink = isRootFolderUid(storage, linkFolder) + ? resolveKeeperDriveRootParentUid(storage) + : linkFolder; + if (normalizedLink !== targetUid) continue; + const record = getKeeperDriveRecord(storage, entry.recordUid); + if (!record) continue; + if (getRecordType(record).toLowerCase() !== "pamuser") continue; + if (recordHasRotationConfigured(storage, entry.recordUid)) return true; + } + return false; +} + +export function nsfRecordIsRoeEligible( + storage: InMemoryStorage, + recordUid: string, +): boolean { + const record = getKeeperDriveRecord(storage, recordUid); + if (!record) return false; + if (getRecordType(record).toLowerCase() !== "pamuser") return false; + return recordHasRotationConfigured(storage, recordUid); } function toRequiredAccountUidStr(accountUid: Uint8Array): string { - if (!accountUid.length) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } - return webSafe64FromBytes(accountUid) + if (!accountUid.length) { + throw new KeeperSdkError( + "Not logged in. Call login() first.", + ResultCodes.NOT_LOGGED_IN, + ); + } + return webSafe64FromBytes(accountUid); } function isCurrentUserRecordAccess( - storage: InMemoryStorage, - entry: DKdRecordAccess, - username: string, - accountUidStr: string + storage: InMemoryStorage, + entry: DKdRecordAccess, + username: string, + accountUidStr: string, ): boolean { - return ( - (entry.accessType === Folder.AccessType.AT_USER && entry.accessTypeUid === accountUidStr) || - (username.length > 0 && - storage.getAll('user').some( - (user) => - user.username === username && - webSafe64FromBytes(user.accountUid) === entry.accessTypeUid - )) - ) + if ( + entry.accessType !== Folder.AccessType.AT_USER && + entry.accessType !== Folder.AccessType.AT_OWNER + ) { + return false; + } + return ( + entry.accessTypeUid === accountUidStr || + (username.length > 0 && + storage + .getAll("user") + .some( + (user) => + user.username.toLowerCase() === username.toLowerCase() && + webSafe64FromBytes(user.accountUid) === entry.accessTypeUid, + )) + ); } function isCurrentUserFolderAccess( - storage: InMemoryStorage, - entry: DKdFolderAccess, - username: string, - accountUidStr: string + storage: InMemoryStorage, + entry: DKdFolderAccess, + username: string, + accountUidStr: string, ): boolean { - if ( - entry.accessType !== Folder.AccessType.AT_USER && - entry.accessType !== Folder.AccessType.AT_OWNER - ) { - return false - } - return ( - entry.accessTypeUid === accountUidStr || - (username.length > 0 && - storage.getAll('user').some( - (user) => - user.username === username && - webSafe64FromBytes(user.accountUid) === entry.accessTypeUid - )) - ) + if ( + entry.accessType !== Folder.AccessType.AT_USER && + entry.accessType !== Folder.AccessType.AT_OWNER + ) { + return false; + } + return ( + entry.accessTypeUid === accountUidStr || + (username.length > 0 && + storage + .getAll("user") + .some( + (user) => + user.username === username && + webSafe64FromBytes(user.accountUid) === entry.accessTypeUid, + )) + ); } -function isFolderOwnerAccount(storage: InMemoryStorage, folderUid: string, accountUidStr: string): boolean { - if (isRootFolderUid(storage, folderUid)) return true - const folder = getKeeperDriveFolder(storage, folderUid) - return folder?.ownerInfo?.accountUid === accountUidStr +function isFolderOwnerAccount( + storage: InMemoryStorage, + folderUid: string, + accountUidStr: string, +): boolean { + if (isRootFolderUid(storage, folderUid)) return true; + const folder = getKeeperDriveFolder(storage, folderUid); + return folder?.ownerInfo?.accountUid === accountUidStr; } -type FolderPermissionFlag = 'canRemove' | 'canDelete' +type FolderPermissionFlag = + | "canRemove" + | "canDelete" + | "canUpdateAccess" + | "canEditRecords" + | "canUpdateSetting" + | "canChangeOwnership"; function hasFolderPermission( - storage: InMemoryStorage, - folderUid: string, - username: string, - accountUid: Uint8Array, - permission: FolderPermissionFlag + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array, + permission: FolderPermissionFlag, ): boolean { - const accountUidStr = toRequiredAccountUidStr(accountUid) - if (isFolderOwnerAccount(storage, folderUid, accountUidStr)) return true + const accountUidStr = toRequiredAccountUidStr(accountUid); + if (isFolderOwnerAccount(storage, folderUid, accountUidStr)) return true; + + for (const entry of getFolderAccessEntries(storage, folderUid)) { + if (!isCurrentUserFolderAccess(storage, entry, username, accountUidStr)) + continue; + if (entry.permission?.[permission]) return true; + } + return false; +} - for (const entry of getFolderAccessEntries(storage, folderUid)) { - if (!isCurrentUserFolderAccess(storage, entry, username, accountUidStr)) continue - if (entry.permission?.[permission]) return true +function getRecordAccessEntries( + storage: InMemoryStorage, + recordUid: string, +): DKdRecordAccess[] { + return storage + .getAll(KeeperDriveKind.RecordAccess) + .filter((entry) => entry.recordUid === recordUid); +} + +function canRecordBeDeleted( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, + folderUid?: string, +): boolean { + const accountUidStr = toRequiredAccountUidStr(accountUid); + const entries = getRecordAccessEntries(storage, recordUid); + if (entries.length === 0) return true; + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) + continue; + if (entry.owner || entry.canDelete) return true; + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, "canDelete") + ) { + return true; } - return false + return false; + } + + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, "canDelete") + ); } -function getRecordAccessEntries(storage: InMemoryStorage, recordUid: string): DKdRecordAccess[] { - return storage - .getAll(KeeperDriveKind.RecordAccess) - .filter((entry) => entry.recordUid === recordUid) +function canShareRecord( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, + folderUid?: string, +): boolean { + const accountUidStr = toRequiredAccountUidStr(accountUid); + const entries = getRecordAccessEntries(storage, recordUid); + if (entries.length === 0) return true; + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) + continue; + if (entry.owner || entry.canUpdateAccess) return true; + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission( + storage, + folderUid, + username, + accountUid, + "canUpdateAccess", + ) + ) { + return true; + } + for (const parentFolderUid of findNestedShareFoldersForRecord( + storage, + recordUid, + )) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission( + storage, + parentFolderUid, + username, + accountUid, + "canUpdateAccess", + ) + ) { + return true; + } + } + return false; + } + + for (const parentFolderUid of findNestedShareFoldersForRecord( + storage, + recordUid, + )) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission( + storage, + parentFolderUid, + username, + accountUid, + "canUpdateAccess", + ) + ) { + return true; + } + } + + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission( + storage, + folderUid, + username, + accountUid, + "canUpdateAccess", + ) + ); } -function canRecordBeDeleted( - storage: InMemoryStorage, - recordUid: string, - username: string, - accountUid: Uint8Array, - folderUid?: string +function canChangeRecordOwnership( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, + folderUid?: string, ): boolean { - const accountUidStr = toRequiredAccountUidStr(accountUid) - const entries = getRecordAccessEntries(storage, recordUid) - if (entries.length === 0) return true - - for (const entry of entries) { - if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue - if (entry.owner || entry.canDelete) return true - if ( - folderUid && - !isRootFolderUid(storage, folderUid) && - hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') - ) { - return true - } - return false + const accountUidStr = toRequiredAccountUidStr(accountUid); + const entries = getRecordAccessEntries(storage, recordUid); + if (entries.length === 0) return true; + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) + continue; + if (entry.owner || entry.canChangeOwnership) return true; + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission( + storage, + folderUid, + username, + accountUid, + "canChangeOwnership", + ) + ) { + return true; } + for (const parentFolderUid of findNestedShareFoldersForRecord( + storage, + recordUid, + )) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission( + storage, + parentFolderUid, + username, + accountUid, + "canChangeOwnership", + ) + ) { + return true; + } + } + return false; + } - return ( - !!folderUid && - !isRootFolderUid(storage, folderUid) && - hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') + for (const parentFolderUid of findNestedShareFoldersForRecord( + storage, + recordUid, + )) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission( + storage, + parentFolderUid, + username, + accountUid, + "canChangeOwnership", + ) + ) { + return true; + } + } + + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission( + storage, + folderUid, + username, + accountUid, + "canChangeOwnership", ) + ); } export function checkFolderRemovePermission( - storage: InMemoryStorage, - folderUid: string, - recordUid: string, - username: string, - accountUid: Uint8Array + storage: InMemoryStorage, + folderUid: string, + recordUid: string, + username: string, + accountUid: Uint8Array, ): void { - if (hasFolderPermission(storage, folderUid, username, accountUid, 'canRemove')) return - if (canRecordBeDeleted(storage, recordUid, username, accountUid, folderUid)) return - throw new KeeperSdkError( - 'You do not have permission to remove records from this folder.', - ResultCodes.NSF_PERMISSION_DENIED - ) + if ( + hasFolderPermission(storage, folderUid, username, accountUid, "canRemove") + ) + return; + if (canRecordBeDeleted(storage, recordUid, username, accountUid, folderUid)) + return; + throw new KeeperSdkError( + "You do not have permission to remove records from this folder.", + ResultCodes.NSF_PERMISSION_DENIED, + ); } export function checkRecordDeletePermission( - storage: InMemoryStorage, - recordUid: string, - username: string, - accountUid: Uint8Array, - folderUid?: string + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, + folderUid?: string, ): void { - if (canRecordBeDeleted(storage, recordUid, username, accountUid, folderUid)) return - throw new KeeperSdkError( - 'You do not have permission to delete this record.', - ResultCodes.NSF_PERMISSION_DENIED - ) + if (canRecordBeDeleted(storage, recordUid, username, accountUid, folderUid)) + return; + throw new KeeperSdkError( + "You do not have permission to delete this record.", + ResultCodes.NSF_PERMISSION_DENIED, + ); } export function checkRecordEditPermission( - storage: InMemoryStorage, - recordUid: string, - username: string, - accountUid: Uint8Array + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, ): void { - const accountUidStr = toRequiredAccountUidStr(accountUid) - const entries = getRecordAccessEntries(storage, recordUid) - if (entries.length === 0) return - - for (const entry of entries) { - if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue - if (entry.owner || entry.canEdit) return - throw new KeeperSdkError( - 'You do not have permission to edit this record.', - ResultCodes.NSF_PERMISSION_DENIED - ) - } + const accountUidStr = toRequiredAccountUidStr(accountUid); + const entries = getRecordAccessEntries(storage, recordUid); + if (entries.length === 0) return; + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) + continue; + if (entry.owner || entry.canEdit) return; throw new KeeperSdkError( - 'You do not have permission to edit this record.', - ResultCodes.NSF_PERMISSION_DENIED - ) + "You do not have permission to edit this record.", + ResultCodes.NSF_PERMISSION_DENIED, + ); + } + throw new KeeperSdkError( + "You do not have permission to edit this record.", + ResultCodes.NSF_PERMISSION_DENIED, + ); } -export function formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): NsfAccessRoleLabel { - if (role == null) return NsfAccessRoleLabel.Unknown - return NSF_ACCESS_ROLE_LABELS[role] ?? NsfAccessRoleLabel.Unknown +export function formatAccessRoleType( + role: Folder.AccessRoleType | null | undefined, +): NsfAccessRoleLabel { + if (role == null) return NsfAccessRoleLabel.Unknown; + return NSF_ACCESS_ROLE_LABELS[role] ?? NsfAccessRoleLabel.Unknown; } -export function formatAccessType(type: Folder.AccessType | null | undefined): string { - if (type == null) return 'unknown' - return NSF_ACCESS_TYPE_LABELS[type] ?? `type-${type}` +export function formatAccessType( + type: Folder.AccessType | null | undefined, +): string { + if (type == null) return "unknown"; + return NSF_ACCESS_TYPE_LABELS[type] ?? `type-${type}`; } export function getKeeperDriveFolders(storage: InMemoryStorage): DKdFolder[] { - return storage.getAll(KeeperDriveKind.Folder) + return storage.getAll(KeeperDriveKind.Folder); } export function getKeeperDriveRecords(storage: InMemoryStorage): DRecord[] { - return storage.getRecords().filter((record) => record.isKeeperDriveData) + return storage.getRecords().filter((record) => record.isKeeperDriveData); } -export function getKeeperDriveFolder(storage: InMemoryStorage, folderUid: string): DKdFolder | undefined { - return storage.getByUid(KeeperDriveKind.Folder, folderUid) +export function getKeeperDriveFolder( + storage: InMemoryStorage, + folderUid: string, +): DKdFolder | undefined { + return storage.getByUid(KeeperDriveKind.Folder, folderUid); } -export function getKeeperDriveRecord(storage: InMemoryStorage, recordUid: string): DRecord | undefined { - const record = storage.getByUid('record', recordUid) - return record?.isKeeperDriveData ? record : undefined +export function getKeeperDriveRecord( + storage: InMemoryStorage, + recordUid: string, +): DRecord | undefined { + const record = storage.getByUid("record", recordUid); + return record?.isKeeperDriveData ? record : undefined; } -export function getFolderAccessEntries(storage: InMemoryStorage, folderUid: string): DKdFolderAccess[] { - return storage - .getAll(KeeperDriveKind.FolderAccess) - .filter((entry) => entry.folderUid === folderUid) +export function getFolderAccessEntries( + storage: InMemoryStorage, + folderUid: string, +): DKdFolderAccess[] { + return storage + .getAll(KeeperDriveKind.FolderAccess) + .filter((entry) => entry.folderUid === folderUid); } -export function getFolderDisplayName(storage: InMemoryStorage, folderUid: string): string { - if (isRootFolderUid(storage, folderUid)) return 'root' - return getKeeperDriveFolder(storage, folderUid)?.data.name ?? folderUid +export function getFolderDisplayName( + storage: InMemoryStorage, + folderUid: string, +): string { + if (isRootFolderUid(storage, folderUid)) return "root"; + return getKeeperDriveFolder(storage, folderUid)?.data.name ?? folderUid; } -export function findRecordFolderLocation(storage: InMemoryStorage, recordUid: string): string { - const folderUids = findNestedShareFoldersForRecord(storage, recordUid) - if (folderUids.length === 0) return 'root' - return getFolderDisplayName(storage, folderUids[0]) +export function findRecordFolderLocation( + storage: InMemoryStorage, + recordUid: string, +): string { + const folderUids = findNestedShareFoldersForRecord(storage, recordUid); + if (folderUids.length === 0) return "root"; + return getFolderDisplayName(storage, folderUids[0]); } -export function buildFolderPath(storage: InMemoryStorage, folderUid: string): string { - if (isRootFolderUid(storage, folderUid)) return '/' - - const segments: string[] = [] - let currentUid: string | undefined = folderUid - const seen = new Set() - - while (currentUid && !isRootFolderUid(storage, currentUid) && !seen.has(currentUid)) { - seen.add(currentUid) - const folder = getKeeperDriveFolder(storage, currentUid) - if (!folder) break - segments.unshift(folder.data.name || folder.uid) - currentUid = folder.parentUid - } - - return `/${segments.join('/')}` +export function buildFolderPath( + storage: InMemoryStorage, + folderUid: string, +): string { + if (isRootFolderUid(storage, folderUid)) return "/"; + + const segments: string[] = []; + let currentUid: string | undefined = folderUid; + const seen = new Set(); + + while ( + currentUid && + !isRootFolderUid(storage, currentUid) && + !seen.has(currentUid) + ) { + seen.add(currentUid); + const folder = getKeeperDriveFolder(storage, currentUid); + if (!folder) break; + segments.unshift(folder.data.name || folder.uid); + currentUid = folder.parentUid; + } + + return `/${segments.join("/")}`; } -export function collectRecordsInFolder(storage: InMemoryStorage, folderUid: string): DRecord[] { - const targetIsRoot = isRootFolderUid(storage, folderUid) - const records: DRecord[] = [] - for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { - const entryIsRoot = isRootFolderUid(storage, entry.folderUid) - if (targetIsRoot ? !entryIsRoot : entry.folderUid !== folderUid) continue - const record = getKeeperDriveRecord(storage, entry.recordUid) - if (record) records.push(record) - } - return records +export function collectRecordsInFolder( + storage: InMemoryStorage, + folderUid: string, +): DRecord[] { + const targetIsRoot = isRootFolderUid(storage, folderUid); + const records: DRecord[] = []; + for (const entry of storage.getAll( + KeeperDriveKind.FolderRecord, + )) { + const entryIsRoot = isRootFolderUid(storage, entry.folderUid); + if (targetIsRoot ? !entryIsRoot : entry.folderUid !== folderUid) continue; + const record = getKeeperDriveRecord(storage, entry.recordUid); + if (record) records.push(record); + } + return records; } export function getRecordDescription(record: DRecord): string { - const data = record.data - if (!data || typeof data !== 'object') return '' - - const fields = Array.isArray(data.fields) ? data.fields : [] - for (const field of fields) { - if (!NSF_NOTE_FIELD_TYPES.has(field?.type)) continue - const value = Array.isArray(field.value) ? field.value[0] : field.value - if (typeof value === 'string' && value.trim()) { - return value.trim().slice(0, NSF_RECORD_DESCRIPTION_MAX_LENGTH) - } + const data = record.data; + if (!data || typeof data !== "object") return ""; + + const fields = Array.isArray(data.fields) ? data.fields : []; + for (const field of fields) { + if (!NSF_NOTE_FIELD_TYPES.has(field?.type)) continue; + const value = Array.isArray(field.value) ? field.value[0] : field.value; + if (typeof value === "string" && value.trim()) { + return value.trim().slice(0, NSF_RECORD_DESCRIPTION_MAX_LENGTH); } + } - if (typeof data.notes === 'string' && data.notes.trim()) { - return data.notes.trim().slice(0, NSF_RECORD_DESCRIPTION_MAX_LENGTH) - } - return '' + if (typeof data.notes === "string" && data.notes.trim()) { + return data.notes.trim().slice(0, NSF_RECORD_DESCRIPTION_MAX_LENGTH); + } + return ""; } export function isSensitiveFieldType(fieldType: string): boolean { - return NSF_SENSITIVE_FIELD_TYPES.has(fieldType) + return NSF_SENSITIVE_FIELD_TYPES.has(fieldType); } export function resolveAccessUsername( - storage: InMemoryStorage, - accessTypeUid: string, - folder?: DKdFolder, - shareUsers?: Map + storage: InMemoryStorage, + accessTypeUid: string, + folder?: DKdFolder, + shareUsers?: Map, ): string { - const fromShare = shareUsers?.get(accessTypeUid) - if (fromShare) return fromShare + const fromShare = shareUsers?.get(accessTypeUid); + if (fromShare) return fromShare; - for (const user of storage.getAll('user')) { - if (webSafe64FromBytes(user.accountUid) === accessTypeUid) { - return user.username - } + for (const user of storage.getAll("user")) { + if (webSafe64FromBytes(user.accountUid) === accessTypeUid) { + return user.username; } - if (folder?.ownerInfo?.accountUid === accessTypeUid && folder.ownerInfo.username) { - return folder.ownerInfo.username - } - return accessTypeUid + } + if ( + folder?.ownerInfo?.accountUid === accessTypeUid && + folder.ownerInfo.username + ) { + return folder.ownerInfo.username; + } + return accessTypeUid; } -export async function loadShareUserMap(auth: Auth, storage: InMemoryStorage): Promise> { - const map = new Map() +export async function loadShareUserMap( + auth: Auth, + storage: InMemoryStorage, +): Promise> { + const map = new Map(); - for (const user of storage.getAll('user')) { - if (user.accountUid?.length && user.username) { - map.set(webSafe64FromBytes(user.accountUid), user.username) - } + for (const user of storage.getAll("user")) { + if (user.accountUid?.length && user.username) { + map.set(webSafe64FromBytes(user.accountUid), user.username); } - - try { - const response = await auth.executeRest(getShareObjectsMessage()) - for (const list of [ - response.shareEnterpriseUsers, - response.shareFamilyUsers, - response.shareRelationships, - response.shareMCEnterpriseUsers, - ]) { - for (const entry of list ?? []) { - if (entry.userAccountUid?.length && entry.username) { - map.set(webSafe64FromBytes(entry.userAccountUid), entry.username) - } - } + } + + try { + const response = await auth.executeRest(getShareObjectsMessage()); + for (const list of [ + response.shareEnterpriseUsers, + response.shareFamilyUsers, + response.shareRelationships, + response.shareMCEnterpriseUsers, + ]) { + for (const entry of list ?? []) { + if (entry.userAccountUid?.length && entry.username) { + map.set(webSafe64FromBytes(entry.userAccountUid), entry.username); } - } catch { + } } + } catch {} - return map + return map; } export function isFolderOwnerAccessor( - folder: DKdFolder, - entry: DKdFolderAccess, - username: string + folder: DKdFolder, + entry: DKdFolderAccess, + username: string, ): boolean { - const ownerUsername = folder.ownerInfo?.username?.trim().toLowerCase() - if (ownerUsername && username.toLowerCase() === ownerUsername) return true - if (folder.ownerInfo?.accountUid && folder.ownerInfo.accountUid === entry.accessTypeUid) return true - return entry.accessType === Folder.AccessType.AT_OWNER + const ownerUsername = folder.ownerInfo?.username?.trim().toLowerCase(); + if (ownerUsername && username.toLowerCase() === ownerUsername) return true; + if ( + folder.ownerInfo?.accountUid && + folder.ownerInfo.accountUid === entry.accessTypeUid + ) + return true; + return entry.accessType === Folder.AccessType.AT_OWNER; } -export function recordAccessDisplayRole(data: Folder.IRecordAccessData): NsfAccessRoleLabel { - return formatAccessRoleType(data.accessRoleType) +export function recordAccessDisplayRole( + data: Folder.IRecordAccessData, +): NsfAccessRoleLabel { + return formatAccessRoleType(data.accessRoleType); } export async function fetchLiveRecordAccessEntries( - auth: Auth, - storage: InMemoryStorage, - recordUid: string + auth: Auth, + storage: InMemoryStorage, + recordUid: string, ): Promise< - { - username: string - accountUid?: string - data: Folder.IRecordAccessData - }[] + { + username: string; + accountUid?: string; + data: Folder.IRecordAccessData; + }[] > { + try { + const [response, shareUsers] = await Promise.all([ + auth.executeRest( + getRecordAccessMessage({ recordUids: [normal64Bytes(recordUid)] }), + ), + loadShareUserMap(auth, storage), + ]); + + return (response.recordAccesses ?? []) + .filter((entry) => { + const accessType = entry.data?.accessType; + return ( + accessType === Folder.AccessType.AT_USER || + accessType === Folder.AccessType.AT_OWNER || + !!entry.data?.owner + ); + }) + .map((entry) => { + const data = entry.data; + if (!data?.accessTypeUid?.length || data.accessType == null) + return undefined; + + const accountUid = webSafe64FromBytes(data.accessTypeUid); + const username = + entry.accessorInfo?.name?.trim() || + shareUsers.get(accountUid) || + resolveAccessUsername(storage, accountUid); + + return { username, accountUid, data }; + }) + .filter( + (entry): entry is NonNullable => + !!entry && entry.username.length > 0, + ); + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}`, + ResultCodes.NSF_DETAILS_FAILED, + ); + } +} + +export function folderAccessDisplayRole( + entry: DKdFolderAccess, +): NsfAccessRoleLabel { + if (entry.accessType === Folder.AccessType.AT_OWNER) + return NsfAccessRoleLabel.Owner; + return formatAccessRoleType(entry.accessRoleType); +} + +export function isFolderShareAdministrator(entry: DKdFolderAccess): boolean { + return ( + entry.accessType === Folder.AccessType.AT_OWNER || + entry.accessRoleType === Folder.AccessRoleType.MANAGER || + entry.accessRoleType === Folder.AccessRoleType.CONTENT_SHARE_MANAGER || + entry.accessRoleType === Folder.AccessRoleType.SHARED_MANAGER + ); +} + +export function isFolderUserPermission(entry: DKdFolderAccess): boolean { + return ( + entry.accessType === Folder.AccessType.AT_USER || + entry.accessType === Folder.AccessType.AT_OWNER + ); +} + +export async function patchNsfFolderMetadata( + storage: InMemoryStorage, + folderUid: string, + metadata: { name: string; color?: string }, +): Promise { + const folder = getKeeperDriveFolder(storage, folderUid); + if (!folder) return; + await storage.put({ + ...folder, + data: { + ...folder.data, + name: metadata.name, + ...(metadata.color ? { color: metadata.color } : {}), + }, + }); +} + +export async function patchNsfFolderInheritPermissions( + storage: InMemoryStorage, + folderUid: string, + inheritPermissions: boolean, +): Promise { + const folder = getKeeperDriveFolder(storage, folderUid); + if (!folder) return; + + await storage.put({ + ...folder, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + }); +} + +export function checkFolderEditPermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array, +): void { + if ( + hasFolderPermission( + storage, + folderUid, + username, + accountUid, + "canUpdateSetting", + ) + ) + return; + throw new KeeperSdkError( + "You do not have permission to edit this folder.", + ResultCodes.NSF_PERMISSION_DENIED, + ); +} + +export function checkFolderSharePermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array, +): void { + if ( + hasFolderPermission( + storage, + folderUid, + username, + accountUid, + "canUpdateAccess", + ) + ) + return; + throw new KeeperSdkError( + "You do not have permission to share this folder.", + ResultCodes.NSF_PERMISSION_DENIED, + ); +} + +export function checkRecordSharePermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, +): void { + if (canShareRecord(storage, recordUid, username, accountUid)) return; + throw new KeeperSdkError( + "You do not have permission to share this record.", + ResultCodes.NSF_PERMISSION_DENIED, + ); +} + +export function checkRecordChangeOwnershipPermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, +): void { + if (canChangeRecordOwnership(storage, recordUid, username, accountUid)) + return; + throw new KeeperSdkError( + "You do not have permission to transfer ownership of this record.", + ResultCodes.NSF_PERMISSION_DENIED, + ); +} + +export function findFolderAccessEntry( + storage: InMemoryStorage, + folderUid: string, + accessTypeUid: string, + accessType: Folder.AccessType, +): DKdFolderAccess | undefined { + return getFolderAccessEntries(storage, folderUid).find( + (entry) => + entry.accessTypeUid === accessTypeUid && entry.accessType === accessType, + ); +} + +function toFolderAccessEntry( + folderUid: string, + accessor: Folder.IFolderAccessData, +): DKdFolderAccess { + return { + kind: "keeper_drive_folder_access", + accessUid: `${folderUid}:${webSafe64FromBytes(accessor.accessTypeUid!)}`, + folderUid, + accessTypeUid: webSafe64FromBytes(accessor.accessTypeUid!), + accessType: accessor.accessType!, + accessRoleType: accessor.accessRoleType!, + permission: accessor.permissions ?? {}, + inherited: accessor.inherited ?? undefined, + hidden: accessor.hidden ?? undefined, + }; +} + +export async function fetchLiveFolderAccessEntries( + auth: Auth, + folderUid: string, +): Promise { + try { + const response = await auth.executeRest( + getFolderAccessMessage({ folderUid: [normal64Bytes(folderUid)] }), + ); + const result = response.folderAccessResults?.find( + (entry) => + entry.folderUid?.length && + webSafe64FromBytes(entry.folderUid) === folderUid, + ); + return (result?.accessors ?? []) + .filter( + (accessor) => + accessor.accessTypeUid?.length && + accessor.accessType != null && + accessor.accessRoleType != null, + ) + .map((accessor) => toFolderAccessEntry(folderUid, accessor)); + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch folder permissions for ${folderUid}: ${extractErrorMessage(err)}`, + ResultCodes.NSF_DETAILS_FAILED, + ); + } +} + +export async function findFolderAccessEntryOrLive( + storage: InMemoryStorage, + auth: Auth, + folderUid: string, + accessTypeUid: string, + accessType: Folder.AccessType, +): Promise { + const fromStorage = findFolderAccessEntry( + storage, + folderUid, + accessTypeUid, + accessType, + ); + if (fromStorage) return fromStorage; + + try { + const liveEntries = await fetchLiveFolderAccessEntries(auth, folderUid); + return liveEntries.find( + (entry) => + entry.accessTypeUid === accessTypeUid && + entry.accessType === accessType, + ); + } catch { + return undefined; + } +} + +export function collectExistingFolderShareTargets( + storage: InMemoryStorage, + folderUid: string, + currentUsername: string, +): Array<{ recipient: string; isTeam: boolean; accountUid?: string }> { + const targets: Array<{ + recipient: string; + isTeam: boolean; + accountUid?: string; + }> = []; + for (const entry of getFolderAccessEntries(storage, folderUid)) { + if (entry.accessType === Folder.AccessType.AT_TEAM) { + targets.push({ + recipient: entry.accessTypeUid, + isTeam: true, + accountUid: entry.accessTypeUid, + }); + continue; + } + if (entry.accessType !== Folder.AccessType.AT_USER) continue; + const username = resolveAccessUsername(storage, entry.accessTypeUid); + if (!username || username.toLowerCase() === currentUsername.toLowerCase()) + continue; + targets.push({ + recipient: username, + isTeam: false, + accountUid: entry.accessTypeUid, + }); + } + return targets; +} + +export function resolveNsfRoleName(role: string): Folder.AccessRoleType { + const normalized = role.trim().toLowerCase().replace(/\s+/g, "-"); + const mapped = NSF_RECORD_PERMISSION_ROLE_MAP[normalized]; + if (mapped != null) return mapped; + throw new KeeperSdkError( + `Invalid role '${role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(", ")}.`, + ResultCodes.NSF_SHARE_FAILED, + ); +} + +export function getNsfRecordPermissionRoleLabel( + accessRoleType: Folder.AccessRoleType | number | null | undefined, +): string { + if (accessRoleType == null) return "unresolved"; + return ( + NSF_RECORD_PERMISSION_ROLE_LABELS[accessRoleType] ?? + `role-${accessRoleType}` + ); +} + +export type NsfRecordAccessFlags = { + accessRoleType?: number; + owner?: boolean; +}; + +export function getNsfAccessRoleLabel(access: NsfRecordAccessFlags): string { + if (access.owner) return NsfAccessRoleLabel.Owner; + return getNsfRecordPermissionRoleLabel(access.accessRoleType); +} + +export function normalizeNsfRecordPermissionRole( + role?: string, +): string | undefined { + if (!role?.trim()) return undefined; + const normalized = role.trim().toLowerCase().replace(/\s+/g, "-"); + if ((NSF_RECORD_PERMISSION_ROLES as readonly string[]).includes(normalized)) + return normalized; + if (normalized in NSF_RECORD_PERMISSION_ROLE_MAP) return normalized; + throw new KeeperSdkError( + `Invalid role '${role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(", ")}.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); +} + +export type NsfLiveRecordAccessEntry = { + recordUid: string; + accessorName: string; + accessTypeUid: string; + owner: boolean; + inherited: boolean; + accessRoleType: number; + canViewTitle: boolean; + canEdit: boolean; + canView: boolean; + canListAccess: boolean; + canUpdateAccess: boolean; + canDelete: boolean; + canChangeOwnership: boolean; + canRequestAccess: boolean; + canApproveAccess: boolean; +}; + +function mapLiveRecordAccessEntry( + storage: InMemoryStorage, + shareUsers: Map, + entry: record.v3.details.IRecordAccess, +): NsfLiveRecordAccessEntry | undefined { + const data = entry.data; + if ( + !data?.recordUid?.length || + !data.accessTypeUid?.length || + data.accessType == null + ) + return undefined; + + const accessType = data.accessType; + if ( + accessType !== Folder.AccessType.AT_USER && + accessType !== Folder.AccessType.AT_OWNER && + !data.owner + ) { + return undefined; + } + + const recordUid = webSafe64FromBytes(data.recordUid); + const accessTypeUid = webSafe64FromBytes(data.accessTypeUid); + const accessorName = + entry.accessorInfo?.name?.trim() || + shareUsers.get(accessTypeUid) || + resolveAccessUsername(storage, accessTypeUid); + + if (!accessorName) return undefined; + + return { + recordUid, + accessorName, + accessTypeUid, + owner: !!data.owner, + inherited: !!data.inherited, + accessRoleType: data.accessRoleType ?? Folder.AccessRoleType.UNRESOLVED, + canViewTitle: data.canViewTitle ?? true, + canEdit: !!data.canEdit, + canView: !!data.canView, + canListAccess: !!data.canListAccess, + canUpdateAccess: !!data.canUpdateAccess, + canDelete: !!data.canDelete, + canChangeOwnership: !!data.canChangeOwnership, + canRequestAccess: !!data.canRequestAccess, + canApproveAccess: !!data.canApproveAccess, + }; +} + +export async function fetchLiveRecordAccessesV3( + auth: Auth, + storage: InMemoryStorage, + recordUids: string[], +): Promise<{ + recordAccesses: NsfLiveRecordAccessEntry[]; + forbiddenRecords: string[]; +}> { + if (recordUids.length === 0) { + return { recordAccesses: [], forbiddenRecords: [] }; + } + + const shareUsers = await loadShareUserMap(auth, storage); + const recordAccesses: NsfLiveRecordAccessEntry[] = []; + const forbiddenRecords: string[] = []; + + for ( + let index = 0; + index < recordUids.length; + index += NSF_SHARE_BATCH_SIZE + ) { + const chunk = recordUids.slice(index, index + NSF_SHARE_BATCH_SIZE); + let response: record.v3.details.IRecordAccessResponse; try { - const [response, shareUsers] = await Promise.all([ - auth.executeRest(getRecordAccessMessage({ recordUids: [normal64Bytes(recordUid)] })), - loadShareUserMap(auth, storage), - ]) - - return (response.recordAccesses ?? []) - .filter((entry) => { - const accessType = entry.data?.accessType - return ( - accessType === Folder.AccessType.AT_USER || - accessType === Folder.AccessType.AT_OWNER || - !!entry.data?.owner - ) - }) - .map((entry) => { - const data = entry.data - if (!data?.accessTypeUid?.length || data.accessType == null) return undefined - - const accountUid = webSafe64FromBytes(data.accessTypeUid) - const username = - entry.accessorInfo?.name?.trim() || - shareUsers.get(accountUid) || - resolveAccessUsername(storage, accountUid) - - return { username, accountUid, data } - }) - .filter((entry): entry is NonNullable => !!entry && entry.username.length > 0) + response = await auth.executeRest( + getRecordAccessMessage({ + recordUids: chunk.map((uid) => normal64Bytes(uid)), + }), + ); } catch (err) { - throw new KeeperSdkError( - `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}`, - ResultCodes.NSF_DETAILS_FAILED - ) + throw new KeeperSdkError( + `Failed to fetch record permissions: ${extractErrorMessage(err)}`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); + } + + for (const uid of response.forbiddenRecords ?? []) { + forbiddenRecords.push(webSafe64FromBytes(uid)); } + + for (const entry of response.recordAccesses ?? []) { + const mapped = mapLiveRecordAccessEntry(storage, shareUsers, entry); + if (mapped) recordAccesses.push(mapped); + } + } + + return { recordAccesses, forbiddenRecords }; } -export function folderAccessDisplayRole(entry: DKdFolderAccess): NsfAccessRoleLabel { - if (entry.accessType === Folder.AccessType.AT_OWNER) return NsfAccessRoleLabel.Owner - return formatAccessRoleType(entry.accessRoleType) +export function findUserRecordShareEntry( + storage: InMemoryStorage, + recordUid: string, + email: string, + accountUidStr?: string, + shareUsers?: Map, +): DKdRecordAccess | undefined { + const lower = email.toLowerCase(); + const normalizedUid = accountUidStr?.trim(); + for (const entry of getRecordAccessEntries(storage, recordUid)) { + if (entry.owner || entry.accessType !== Folder.AccessType.AT_USER) continue; + if (normalizedUid && entry.accessTypeUid === normalizedUid) { + return entry; + } + const accessorName = resolveAccessUsername( + storage, + entry.accessTypeUid, + undefined, + shareUsers, + ); + if (accessorName.toLowerCase() === lower) return entry; + } + return undefined; } -export function isFolderShareAdministrator(entry: DKdFolderAccess): boolean { - return ( - entry.accessType === Folder.AccessType.AT_OWNER || - entry.accessRoleType === Folder.AccessRoleType.MANAGER || - entry.accessRoleType === Folder.AccessRoleType.CONTENT_SHARE_MANAGER || - entry.accessRoleType === Folder.AccessRoleType.SHARED_MANAGER - ) +export function findDirectUserRecordShare( + storage: InMemoryStorage, + recordUid: string, + email: string, +): + | { + recordUid: string; + email: string; + accessRoleType: number; + expiration?: number; + } + | undefined { + const entry = findUserRecordShareEntry(storage, recordUid, email); + if (!entry || entry.inherited) return undefined; + return { + recordUid, + email, + accessRoleType: entry.accessRoleType ?? Folder.AccessRoleType.UNRESOLVED, + expiration: entry.tlaProperties?.expiration ?? undefined, + }; } -export function isFolderUserPermission(entry: DKdFolderAccess): boolean { +export function validateShareExpirationTimestamp( + expirationMs: number | null | undefined, + cmdName: string, +): void { + if (expirationMs == null || expirationMs === -1) return; + const minAllowed = Date.now() + MIN_SHARE_EXPIRATION_MS; + if (expirationMs < minAllowed) { + throw new KeeperSdkError( + `[${cmdName}] Share expiration must be at least 1 minute.`, + ResultCodes.NSF_SHARE_FAILED, + ); + } +} + +export function parseShareExpiration( + input: ParseShareExpirationInput, +): number | undefined { + const { + expireAt, + expireIn, + expirationTimestamp, + cmdName = NsfShareCommandName.FolderShare, + } = input; + const expireAtValue = expireAt?.trim(); + const expireInValue = expireIn?.trim(); + + if (expireAtValue && expireInValue) { + throw new KeeperSdkError( + `[${cmdName}] Cannot specify both expire-at and expire-in.`, + ResultCodes.NSF_SHARE_FAILED, + ); + } + + if (expirationTimestamp != null && (expireAtValue || expireInValue)) { + throw new KeeperSdkError( + `[${cmdName}] Cannot specify both expirationTimestamp and expire-at/expire-in.`, + ResultCodes.NSF_SHARE_FAILED, + ); + } + + if (expirationTimestamp != null) { + validateShareExpirationTimestamp(expirationTimestamp, cmdName); + return expirationTimestamp; + } + + const raw = expireAtValue || expireInValue; + if (!raw) return undefined; + + if (raw.toLowerCase() === NSF_SHARE_EXPIRATION_NEVER) return -1; + + if (expireAtValue) { + const normalized = raw.replace("Z", "+00:00"); + const dt = new Date(normalized); + if (Number.isNaN(dt.getTime())) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-at datetime '${raw}'.`, + ResultCodes.NSF_SHARE_FAILED, + ); + } + const expirationMs = dt.getTime(); + validateShareExpirationTimestamp(expirationMs, cmdName); + return expirationMs; + } + + const match = NSF_SHARE_EXPIRATION_RE.exec(raw); + if (!match) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-in period '${raw}'. Use e.g. 30d, 6mo, 1y, 24h, 30mi.`, + ResultCodes.NSF_SHARE_FAILED, + ); + } + + const amount = Number.parseInt(match[1], 10); + const unitMs = NSF_SHARE_EXPIRATION_UNIT_MS[match[2].toLowerCase()]; + if (!unitMs) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-in period '${raw}'.`, + ResultCodes.NSF_SHARE_FAILED, + ); + } + + const expirationMs = Date.now() + amount * unitMs; + validateShareExpirationTimestamp(expirationMs, cmdName); + return expirationMs; +} + +export function parseShareExpirationValue( + value: string, + cmdName: string = NsfShareCommandName.FolderShare, +): number | undefined { + const raw = value.trim(); + if (!raw) return undefined; + if (NSF_SHARE_EXPIRATION_RE.test(raw)) { + return parseShareExpiration({ expireIn: raw, cmdName }); + } + return parseShareExpiration({ expireAt: raw, cmdName }); +} + +export function isShareExpirationNoop( + existingExpiration: number | undefined, + requestedExpiration: number | undefined, +): boolean { + if (requestedExpiration == null) return true; + if (requestedExpiration === -1) { return ( - entry.accessType === Folder.AccessType.AT_USER || - entry.accessType === Folder.AccessType.AT_OWNER - ) + existingExpiration == null || + existingExpiration === -1 || + existingExpiration === 0 + ); + } + if (existingExpiration == null || existingExpiration === 0) return false; + return ( + Math.abs(existingExpiration - requestedExpiration) <= + NSF_TLA_EXPIRATION_TOLERANCE_MS + ); } diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts index ffb33c22..f6089305 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts @@ -1,110 +1,133 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { Records, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { extractErrorMessage, logger } from '../utils' -import { findNestedShareFoldersForRecord } from './nsfHelpers' +import type { Auth } from "@keeper-security/keeperapi"; +import { + Records, + normal64Bytes, + platform, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { extractErrorMessage, logger } from "../utils"; +import { findNestedShareFoldersForRecord } from "./nsfHelpers"; -const UNKNOWN_RECORD_LABEL = 'Unknown' +const UNKNOWN_RECORD_LABEL = "Unknown"; -function decodePayload(value: string | Uint8Array | null | undefined): Uint8Array { - if (!value) return new Uint8Array(0) - if (value instanceof Uint8Array) return value - return normal64Bytes(value) +function decodePayload( + value: string | Uint8Array | null | undefined, +): Uint8Array { + if (!value) return new Uint8Array(0); + if (value instanceof Uint8Array) return value; + return normal64Bytes(value); } async function decryptWithFolderKeys( - storage: InMemoryStorage, - recordUid: string, - encryptedKey: Uint8Array + storage: InMemoryStorage, + recordUid: string, + encryptedKey: Uint8Array, ): Promise { - for (const folderUid of findNestedShareFoldersForRecord(storage, recordUid)) { - const folderKey = await storage.getKeyBytes(folderUid) - if (!folderKey) continue - try { - return await platform.aesGcmDecrypt(encryptedKey, folderKey) - } catch (gcmErr) { - try { - const recordKey = await platform.aesCbcDecrypt(encryptedKey, folderKey, true) - logger.debug( - `NSF record key for ${recordUid} decrypted with CBC via folder ${folderUid} after GCM failed: ${extractErrorMessage(gcmErr)}` - ) - return recordKey - } catch (cbcErr) { - logger.debug( - `NSF record key decrypt failed for record ${recordUid} with folder ${folderUid}: ${extractErrorMessage(cbcErr)} (GCM: ${extractErrorMessage(gcmErr)})` - ) - continue - } - } + for (const folderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + const folderKey = await storage.getKeyBytes(folderUid); + if (!folderKey) continue; + try { + return await platform.aesGcmDecrypt(encryptedKey, folderKey); + } catch (gcmErr) { + try { + const recordKey = await platform.aesCbcDecrypt( + encryptedKey, + folderKey, + true, + ); + logger.debug( + `NSF record key for ${recordUid} decrypted with CBC via folder ${folderUid} after GCM failed: ${extractErrorMessage(gcmErr)}`, + ); + return recordKey; + } catch (cbcErr) { + logger.debug( + `NSF record key decrypt failed for record ${recordUid} with folder ${folderUid}: ${extractErrorMessage(cbcErr)} (GCM: ${extractErrorMessage(gcmErr)})`, + ); + continue; + } } - logger.debug(`NSF record key decrypt failed for ${recordUid}: no folder key succeeded`) - return undefined + } + logger.debug( + `NSF record key decrypt failed for ${recordUid}: no folder key succeeded`, + ); + return undefined; } export async function resolveRecordKeyBytes( - storage: InMemoryStorage, - auth: Auth, - recordUid: string, - encryptedKey?: Uint8Array | null, - keyType?: Records.RecordKeyType | null + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + encryptedKey?: Uint8Array | null, + keyType?: Records.RecordKeyType | null, ): Promise { - const cached = await storage.getKeyBytes(recordUid) - if (cached) return cached + const cached = await storage.getKeyBytes(recordUid); + if (cached) return cached; - if (!encryptedKey?.length || !auth.dataKey) { - return decryptWithFolderKeys(storage, recordUid, encryptedKey ?? new Uint8Array(0)) - } + if (!encryptedKey?.length || !auth.dataKey) { + return decryptWithFolderKeys( + storage, + recordUid, + encryptedKey ?? new Uint8Array(0), + ); + } - try { - if (keyType === Records.RecordKeyType.ENCRYPTED_BY_DATA_KEY) { - return await platform.aesCbcDecrypt(encryptedKey, auth.dataKey, true) - } - return await platform.aesGcmDecrypt(encryptedKey, auth.dataKey) - } catch (err) { - logger.debug( - `NSF record key decrypt with data key failed for ${recordUid}, trying folder keys: ${extractErrorMessage(err)}` - ) - return decryptWithFolderKeys(storage, recordUid, encryptedKey) + try { + if (keyType === Records.RecordKeyType.ENCRYPTED_BY_DATA_KEY) { + return await platform.aesCbcDecrypt(encryptedKey, auth.dataKey, true); } + return await platform.aesGcmDecrypt(encryptedKey, auth.dataKey); + } catch (err) { + logger.debug( + `NSF record key decrypt with data key failed for ${recordUid}, trying folder keys: ${extractErrorMessage(err)}`, + ); + return decryptWithFolderKeys(storage, recordUid, encryptedKey); + } } export async function decryptRecordTitleAndType( - storage: InMemoryStorage, - auth: Auth, - recordUid: string, - recordData: Records.IRecordData + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + recordData: Records.IRecordData, ): Promise<{ title: string; type: string }> { - const uid = recordData.recordUid?.length ? webSafe64FromBytes(recordData.recordUid) : recordUid - const recordKey = await resolveRecordKeyBytes( - storage, - auth, - uid, - recordData.recordKey, - recordData.recordKeyType - ) - if (!recordKey) { - logger.debug(`NSF record key unavailable for ${uid}`) - return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } - } + const uid = recordData.recordUid?.length + ? webSafe64FromBytes(recordData.recordUid) + : recordUid; + const recordKey = await resolveRecordKeyBytes( + storage, + auth, + uid, + recordData.recordKey, + recordData.recordKeyType, + ); + if (!recordKey) { + logger.debug(`NSF record key unavailable for ${uid}`); + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL }; + } - const encryptedData = decodePayload(recordData.encryptedRecordData) - if (!encryptedData.length) { - logger.debug(`NSF record data empty for ${uid}`) - return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } - } + const encryptedData = decodePayload(recordData.encryptedRecordData); + if (!encryptedData.length) { + logger.debug(`NSF record data empty for ${uid}`); + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL }; + } - try { - const decrypted = await platform.aesGcmDecrypt(encryptedData, recordKey) - const parsed = JSON.parse(platform.bytesToString(decrypted).replace(/\s+$/, '')) as { - title?: string - type?: string - } - return { - title: parsed.title?.trim() || UNKNOWN_RECORD_LABEL, - type: parsed.type?.trim() || UNKNOWN_RECORD_LABEL, - } - } catch (err) { - logger.debug(`NSF record title/type decrypt failed for ${uid}: ${extractErrorMessage(err)}`) - return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } - } + try { + const decrypted = await platform.aesGcmDecrypt(encryptedData, recordKey); + const parsed = JSON.parse( + platform.bytesToString(decrypted).replace(/\s+$/, ""), + ) as { + title?: string; + type?: string; + }; + return { + title: parsed.title?.trim() || UNKNOWN_RECORD_LABEL, + type: parsed.type?.trim() || UNKNOWN_RECORD_LABEL, + }; + } catch (err) { + logger.debug( + `NSF record title/type decrypt failed for ${uid}: ${extractErrorMessage(err)}`, + ); + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL }; + } } diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts index b664d279..bf70430c 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts @@ -1,385 +1,424 @@ -import { platform } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { NSF_KNOWN_FIELD_TYPES, NSF_LEGACY_RECORD_TYPES, NSF_STRUCTURED_SUBKEYS } from './nsfConstants' +import { platform } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { + NSF_KNOWN_FIELD_TYPES, + NSF_LEGACY_RECORD_TYPES, + NSF_STRUCTURED_SUBKEYS, +} from "./nsfConstants"; -const MIN_RECORD_PAD_BYTES = 384 -const PAD_BLOCK_SIZE = 16 +const MIN_RECORD_PAD_BYTES = 384; +const PAD_BLOCK_SIZE = 16; export type RecordFieldEntry = { - type: string - label?: string - value: unknown[] -} + type: string; + label?: string; + value: unknown[]; +}; export type BuildNsfRecordDataInput = { - title: string - recordType: string - notes?: string - fieldEntries?: RecordFieldEntry[] - customEntries?: RecordFieldEntry[] -} + title: string; + recordType: string; + notes?: string; + fieldEntries?: RecordFieldEntry[]; + customEntries?: RecordFieldEntry[]; +}; export type ParsedNsfFields = { - fieldEntries: RecordFieldEntry[] - customEntries: RecordFieldEntry[] - hasFileFields: boolean -} + fieldEntries: RecordFieldEntry[]; + customEntries: RecordFieldEntry[]; + hasFileFields: boolean; +}; -type NsfFieldValue = string | string[] | Record | Record[] +type NsfFieldValue = + | string + | string[] + | Record + | Record[]; type FieldAssignment = { - section: 'fields' | 'custom' - fieldType: string - fieldLabel: string - value: NsfFieldValue -} + section: "fields" | "custom"; + fieldType: string; + fieldLabel: string; + value: NsfFieldValue; +}; type ParsedFieldKey = { - section: 'fields' | 'custom' - fieldType: string - fieldLabel: string -} + section: "fields" | "custom"; + fieldType: string; + fieldLabel: string; +}; function stripSurroundingQuotes(value: string): string { - const trimmed = value.trim() - if ( - (trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"')) - ) { - return trimmed.slice(1, -1) - } - return trimmed + const trimmed = value.trim(); + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1); + } + return trimmed; } export function resolveNsfFieldValue(raw: string): NsfFieldValue { - const trimmed = stripSurroundingQuotes(raw) - if (trimmed.startsWith('$JSON')) { - let jsonStr = trimmed.slice(5) - if (jsonStr.startsWith(':')) jsonStr = jsonStr.slice(1) - try { - return JSON.parse(jsonStr) as Record - } catch (err) { - throw new KeeperSdkError( - `Invalid $JSON value: ${extractErrorMessage(err)}`, - ResultCodes.NSF_ADD_FAILED - ) - } + const trimmed = stripSurroundingQuotes(raw); + if (trimmed.startsWith("$JSON")) { + let jsonStr = trimmed.slice(5); + if (jsonStr.startsWith(":")) jsonStr = jsonStr.slice(1); + try { + return JSON.parse(jsonStr) as Record; + } catch (err) { + throw new KeeperSdkError( + `Invalid $JSON value: ${extractErrorMessage(err)}`, + ResultCodes.NSF_ADD_FAILED, + ); } - return trimmed + } + return trimmed; } function toFieldValueArray(value: NsfFieldValue): unknown[] { - if (Array.isArray(value)) return value - if (typeof value === 'object' && value !== null) return [value] - return [value] + if (Array.isArray(value)) return value; + if (typeof value === "object" && value !== null) return [value]; + return [value]; } function cloneFieldEntries(entries: unknown): RecordFieldEntry[] { - if (!Array.isArray(entries)) return [] - return (entries as RecordFieldEntry[]).map((field) => ({ - type: field.type ?? '', - label: field.label, - value: Array.isArray(field.value) ? [...field.value] : [], - })) + if (!Array.isArray(entries)) return []; + return (entries as RecordFieldEntry[]).map((field) => ({ + type: field.type ?? "", + label: field.label, + value: Array.isArray(field.value) ? [...field.value] : [], + })); } function fieldEntryKey(field: RecordFieldEntry): string { - return `${field.type}\0${field.label ?? ''}` + return `${field.type}\0${field.label ?? ""}`; } function isStructuredSubkey(fieldType: string, fieldLabel: string): boolean { - return !!NSF_STRUCTURED_SUBKEYS[fieldType]?.has(fieldLabel) + return !!NSF_STRUCTURED_SUBKEYS[fieldType]?.has(fieldLabel); } function parseFieldKey(rawKey: string): ParsedFieldKey { - let key = rawKey.trim() - let section: 'fields' | 'custom' = 'fields' - - if (key.startsWith('c.')) { - section = 'custom' - key = key.slice(2) - } else if (key.startsWith('f.')) { - key = key.slice(2) - } - - const quoted = key.match(/^"(.+)"$/) - if (quoted) { - return { section: 'custom', fieldType: 'text', fieldLabel: quoted[1] } - } - - const dotIdx = key.indexOf('.') - if (dotIdx > 0) { - const fieldType = key.slice(0, dotIdx).toLowerCase() - const fieldLabel = key.slice(dotIdx + 1) - if (NSF_KNOWN_FIELD_TYPES.has(fieldType)) { - return { section, fieldType, fieldLabel } - } - return { section: 'custom', fieldType: 'text', fieldLabel: key } - } - - const fieldType = key.toLowerCase() + let key = rawKey.trim(); + let section: "fields" | "custom" = "fields"; + + if (key.startsWith("c.")) { + section = "custom"; + key = key.slice(2); + } else if (key.startsWith("f.")) { + key = key.slice(2); + } + + const quoted = key.match(/^"(.+)"$/); + if (quoted) { + return { section: "custom", fieldType: "text", fieldLabel: quoted[1] }; + } + + const dotIdx = key.indexOf("."); + if (dotIdx > 0) { + const fieldType = key.slice(0, dotIdx).toLowerCase(); + const fieldLabel = key.slice(dotIdx + 1); if (NSF_KNOWN_FIELD_TYPES.has(fieldType)) { - return { section: 'fields', fieldType, fieldLabel: '' } + return { section, fieldType, fieldLabel }; } + return { section: "custom", fieldType: "text", fieldLabel: key }; + } - return { section: 'custom', fieldType: 'text', fieldLabel: key } -} - -function splitFieldTokens(input: string, delimiter: 'comma' | 'space'): string[] { - const result: string[] = [] - let current = '' - let inSingleQuote = false - let inDoubleQuote = false - let braceDepth = 0 - - for (let i = 0; i < input.length; i++) { - const ch = input[i] - const prev = i > 0 ? input[i - 1] : '' - - if (ch === "'" && !inDoubleQuote && prev !== '\\') { - inSingleQuote = !inSingleQuote - current += ch - continue - } - if (ch === '"' && !inSingleQuote && prev !== '\\') { - inDoubleQuote = !inDoubleQuote - current += ch - continue - } - if (!inSingleQuote && !inDoubleQuote) { - if (ch === '{') braceDepth++ - else if (ch === '}') braceDepth = Math.max(0, braceDepth - 1) - else if (braceDepth === 0) { - const isDelimiter = delimiter === 'comma' ? ch === ',' : /\s/.test(ch) - if (isDelimiter) { - const token = current.trim() - if (token) result.push(token) - current = '' - if (delimiter === 'space') { - while (i + 1 < input.length && /\s/.test(input[i + 1])) i++ - } - continue - } - } - } - current += ch - } + const fieldType = key.toLowerCase(); + if (NSF_KNOWN_FIELD_TYPES.has(fieldType)) { + return { section: "fields", fieldType, fieldLabel: "" }; + } - const token = current.trim() - if (token) result.push(token) + return { section: "custom", fieldType: "text", fieldLabel: key }; +} - if (inSingleQuote) { - throw new KeeperSdkError('Unclosed single quote in field input.', ResultCodes.NSF_ADD_FAILED) +function splitFieldTokens( + input: string, + delimiter: "comma" | "space", +): string[] { + const result: string[] = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + let braceDepth = 0; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + const prev = i > 0 ? input[i - 1] : ""; + + if (ch === "'" && !inDoubleQuote && prev !== "\\") { + inSingleQuote = !inSingleQuote; + current += ch; + continue; } - if (inDoubleQuote) { - throw new KeeperSdkError('Unclosed double quote in field input.', ResultCodes.NSF_ADD_FAILED) + if (ch === '"' && !inSingleQuote && prev !== "\\") { + inDoubleQuote = !inDoubleQuote; + current += ch; + continue; } - if (braceDepth > 0) { - throw new KeeperSdkError('Unclosed "{" in field input.', ResultCodes.NSF_ADD_FAILED) + if (!inSingleQuote && !inDoubleQuote) { + if (ch === "{") braceDepth++; + else if (ch === "}") braceDepth = Math.max(0, braceDepth - 1); + else if (braceDepth === 0) { + const isDelimiter = delimiter === "comma" ? ch === "," : /\s/.test(ch); + if (isDelimiter) { + const token = current.trim(); + if (token) result.push(token); + current = ""; + if (delimiter === "space") { + while (i + 1 < input.length && /\s/.test(input[i + 1])) i++; + } + continue; + } + } } - - return result + current += ch; + } + + const token = current.trim(); + if (token) result.push(token); + + if (inSingleQuote) { + throw new KeeperSdkError( + "Unclosed single quote in field input.", + ResultCodes.NSF_ADD_FAILED, + ); + } + if (inDoubleQuote) { + throw new KeeperSdkError( + "Unclosed double quote in field input.", + ResultCodes.NSF_ADD_FAILED, + ); + } + if (braceDepth > 0) { + throw new KeeperSdkError( + 'Unclosed "{" in field input.', + ResultCodes.NSF_ADD_FAILED, + ); + } + + return result; } -function parseFieldToken(raw: string, position: number): FieldAssignment | 'file' | undefined { - const field = raw.trim() - if (!field) return undefined - - const separator = field.indexOf('=') - if (separator <= 0) { - throw new KeeperSdkError( - `Invalid field at position ${position}: "${field}" (expected key=value format).`, - ResultCodes.NSF_ADD_FAILED - ) - } - - const key = field.slice(0, separator).trim() - const value = field.slice(separator + 1).trim() - if (!key) { - throw new KeeperSdkError( - `Invalid field at position ${position}: "${field}" (missing field key).`, - ResultCodes.NSF_ADD_FAILED - ) - } - if (key.toLowerCase() === 'file') return 'file' - - const parsedKey = parseFieldKey(key) - try { - return { - section: parsedKey.section, - fieldType: parsedKey.fieldType, - fieldLabel: parsedKey.fieldLabel, - value: resolveNsfFieldValue(value), - } - } catch (err) { - if (err instanceof KeeperSdkError) { - throw new KeeperSdkError( - `Invalid field at position ${position} for key "${key}": ${err.message}`, - err.resultCode - ) - } - throw err +function parseFieldToken( + raw: string, + position: number, +): FieldAssignment | "file" | undefined { + const field = raw.trim(); + if (!field) return undefined; + + const separator = field.indexOf("="); + if (separator <= 0) { + throw new KeeperSdkError( + `Invalid field at position ${position}: "${field}" (expected key=value format).`, + ResultCodes.NSF_ADD_FAILED, + ); + } + + const key = field.slice(0, separator).trim(); + const value = field.slice(separator + 1).trim(); + if (!key) { + throw new KeeperSdkError( + `Invalid field at position ${position}: "${field}" (missing field key).`, + ResultCodes.NSF_ADD_FAILED, + ); + } + if (key.toLowerCase() === "file") return "file"; + + const parsedKey = parseFieldKey(key); + try { + return { + section: parsedKey.section, + fieldType: parsedKey.fieldType, + fieldLabel: parsedKey.fieldLabel, + value: resolveNsfFieldValue(value), + }; + } catch (err) { + if (err instanceof KeeperSdkError) { + throw new KeeperSdkError( + `Invalid field at position ${position} for key "${key}": ${err.message}`, + err.resultCode, + ); } + throw err; + } } -function wrapPlainStructuredValue(fieldType: string, value: string): Record { - if (fieldType === 'address') return { street1: value } - if (fieldType === 'phone') return { number: value, type: 'Mobile' } - if (fieldType === 'name') return { first: value } - return { value } +function wrapPlainStructuredValue( + fieldType: string, + value: string, +): Record { + if (fieldType === "address") return { street1: value }; + if (fieldType === "phone") return { number: value, type: "Mobile" }; + if (fieldType === "name") return { first: value }; + return { value }; } function buildRecordFieldEntries(assignments: FieldAssignment[]): { - fields: RecordFieldEntry[] - custom: RecordFieldEntry[] + fields: RecordFieldEntry[]; + custom: RecordFieldEntry[]; } { - const custom: RecordFieldEntry[] = [] - const labeledFields: RecordFieldEntry[] = [] - const structuredByType = new Map>() - const simpleByType = new Map() - - for (const { section, fieldType, fieldLabel, value } of assignments) { - if (section === 'custom') { - custom.push({ - type: fieldType, - label: fieldLabel, - value: toFieldValueArray(value), - }) - continue - } - - if (fieldLabel && isStructuredSubkey(fieldType, fieldLabel)) { - const existing = structuredByType.get(fieldType) ?? {} - existing[fieldLabel] = typeof value === 'string' ? value : value - structuredByType.set(fieldType, existing) - continue - } - - if (fieldLabel) { - labeledFields.push({ - type: fieldType, - label: fieldLabel, - value: toFieldValueArray(value), - }) - continue - } - - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - structuredByType.set(fieldType, value as Record) - continue - } + const custom: RecordFieldEntry[] = []; + const labeledFields: RecordFieldEntry[] = []; + const structuredByType = new Map>(); + const simpleByType = new Map(); + + for (const { section, fieldType, fieldLabel, value } of assignments) { + if (section === "custom") { + custom.push({ + type: fieldType, + label: fieldLabel, + value: toFieldValueArray(value), + }); + continue; + } - simpleByType.set(fieldType, value) + if (fieldLabel && isStructuredSubkey(fieldType, fieldLabel)) { + const existing = structuredByType.get(fieldType) ?? {}; + existing[fieldLabel] = typeof value === "string" ? value : value; + structuredByType.set(fieldType, existing); + continue; } - const fields: RecordFieldEntry[] = [] - for (const [fieldType, value] of simpleByType) { - if (typeof value === 'string' && NSF_STRUCTURED_SUBKEYS[fieldType]) { - fields.push({ type: fieldType, value: [wrapPlainStructuredValue(fieldType, value)] }) - continue - } - fields.push({ type: fieldType, value: toFieldValueArray(value) }) + if (fieldLabel) { + labeledFields.push({ + type: fieldType, + label: fieldLabel, + value: toFieldValueArray(value), + }); + continue; } - for (const [fieldType, objectValue] of structuredByType) { - fields.push({ type: fieldType, value: [objectValue] }) + + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + structuredByType.set(fieldType, value as Record); + continue; } - fields.push(...labeledFields) - return { fields, custom } + simpleByType.set(fieldType, value); + } + + const fields: RecordFieldEntry[] = []; + for (const [fieldType, value] of simpleByType) { + if (typeof value === "string" && NSF_STRUCTURED_SUBKEYS[fieldType]) { + fields.push({ + type: fieldType, + value: [wrapPlainStructuredValue(fieldType, value)], + }); + continue; + } + fields.push({ type: fieldType, value: toFieldValueArray(value) }); + } + for (const [fieldType, objectValue] of structuredByType) { + fields.push({ type: fieldType, value: [objectValue] }); + } + fields.push(...labeledFields); + + return { fields, custom }; } function mergeFieldEntries( - existing: RecordFieldEntry[], - incoming: RecordFieldEntry[] + existing: RecordFieldEntry[], + incoming: RecordFieldEntry[], ): RecordFieldEntry[] { - const merged = new Map() - for (const field of existing) { - merged.set(fieldEntryKey(field), { - type: field.type ?? '', - label: field.label, - value: Array.isArray(field.value) ? [...field.value] : [], - }) - } - for (const field of incoming) { - merged.set(fieldEntryKey(field), { - type: field.type, - label: field.label, - value: [...field.value], - }) - } - return [...merged.values()] + const merged = new Map(); + for (const field of existing) { + merged.set(fieldEntryKey(field), { + type: field.type ?? "", + label: field.label, + value: Array.isArray(field.value) ? [...field.value] : [], + }); + } + for (const field of incoming) { + merged.set(fieldEntryKey(field), { + type: field.type, + label: field.label, + value: [...field.value], + }); + } + return [...merged.values()]; } function parseNsfFields(rawFields: string[]): ParsedNsfFields { - let hasFileFields = false - const assignments: FieldAssignment[] = [] - - for (let i = 0; i < rawFields.length; i++) { - const parsed = parseFieldToken(rawFields[i], i + 1) - if (!parsed) continue - if (parsed === 'file') { - hasFileFields = true - continue - } - assignments.push(parsed) + let hasFileFields = false; + const assignments: FieldAssignment[] = []; + + for (let i = 0; i < rawFields.length; i++) { + const parsed = parseFieldToken(rawFields[i], i + 1); + if (!parsed) continue; + if (parsed === "file") { + hasFileFields = true; + continue; } + assignments.push(parsed); + } - const { fields, custom } = buildRecordFieldEntries(assignments) - return { fieldEntries: fields, customEntries: custom, hasFileFields } + const { fields, custom } = buildRecordFieldEntries(assignments); + return { fieldEntries: fields, customEntries: custom, hasFileFields }; } export function getPaddedJsonBytes(data: Record): Uint8Array { - const json = JSON.stringify(data) - const paddedLength = Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * PAD_BLOCK_SIZE - return platform.stringToBytes(json.padEnd(paddedLength, ' ')) + const json = JSON.stringify(data); + const paddedLength = + Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * + PAD_BLOCK_SIZE; + return platform.stringToBytes(json.padEnd(paddedLength, " ")); } -export function buildNsfRecordData(input: BuildNsfRecordDataInput): Record { - const title = input.title.trim() - const recordType = input.recordType.trim() - const data: Record = { - type: NSF_LEGACY_RECORD_TYPES.has(recordType) ? 'login' : recordType, - title, - fields: input.fieldEntries ?? [], - custom: input.customEntries ?? [], - } - - const notes = input.notes?.trim() - if (notes) data.notes = notes - return data +export function buildNsfRecordData( + input: BuildNsfRecordDataInput, +): Record { + const title = input.title.trim(); + const recordType = input.recordType.trim(); + const data: Record = { + type: NSF_LEGACY_RECORD_TYPES.has(recordType) ? "login" : recordType, + title, + fields: input.fieldEntries ?? [], + custom: input.customEntries ?? [], + }; + + const notes = input.notes?.trim(); + if (notes) data.notes = notes; + return data; } export function mergeNsfRecordData( - existing: Record, - input: Partial + existing: Record, + input: Partial, ): Record { - const data: Record = { - ...existing, - fields: cloneFieldEntries(existing.fields), - custom: cloneFieldEntries(existing.custom), - } - - if (input.title !== undefined) data.title = input.title.trim() - if (input.recordType !== undefined) { - const recordType = input.recordType.trim() - data.type = NSF_LEGACY_RECORD_TYPES.has(recordType) ? 'login' : recordType - } - if (input.notes !== undefined) data.notes = input.notes - - if (input.fieldEntries?.length) { - data.fields = mergeFieldEntries(data.fields as RecordFieldEntry[], input.fieldEntries) - } - if (input.customEntries?.length) { - data.custom = mergeFieldEntries(data.custom as RecordFieldEntry[], input.customEntries) - } - - return data + const data: Record = { + ...existing, + fields: cloneFieldEntries(existing.fields), + custom: cloneFieldEntries(existing.custom), + }; + + if (input.title !== undefined) data.title = input.title.trim(); + if (input.recordType !== undefined) { + const recordType = input.recordType.trim(); + data.type = NSF_LEGACY_RECORD_TYPES.has(recordType) ? "login" : recordType; + } + if (input.notes !== undefined) data.notes = input.notes; + + if (input.fieldEntries?.length) { + data.fields = mergeFieldEntries( + data.fields as RecordFieldEntry[], + input.fieldEntries, + ); + } + if (input.customEntries?.length) { + data.custom = mergeFieldEntries( + data.custom as RecordFieldEntry[], + input.customEntries, + ); + } + + return data; } export function parseNsfFieldInput(input: string): ParsedNsfFields { - return parseNsfFields(splitFieldTokens(input, 'comma')) + return parseNsfFields(splitFieldTokens(input, "comma")); } export function parseNsfFieldSpaceInput(input: string): ParsedNsfFields { - return parseNsfFields(splitFieldTokens(input, 'space')) + return parseNsfFields(splitFieldTokens(input, "space")); } diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts new file mode 100644 index 00000000..abc79769 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts @@ -0,0 +1,817 @@ +import type { Auth, DKdFolderRecord } from "@keeper-security/keeperapi"; +import { + Folder, + platform, + record, + recordsShareV3Message, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { getRecordTitle } from "../records/RecordUtils"; +import { + buildNsfRevokePermissionFromKeys, + buildNsfSharePermissionFromKeys, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, + preloadNsfUserShareKeys, + type UserShareKeys, +} from "./nsfShareKeys"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { + KeeperDriveKind, + fetchLiveRecordAccessesV3, + ensureNestedShareFolder, + getKeeperDriveFolder, + getKeeperDriveFolders, + getKeeperDriveRecord, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + resolveNsfFolderIdentifier, + resolveNsfRoleName, +} from "./nsfHelpers"; +import { + NSF_RECORD_PERMISSION_ROLES, + NSF_SHARE_BATCH_SIZE, +} from "./nsfConstants"; +import { resolveRecordKeyBytes } from "./nsfRecordCrypto"; +import type { NsfLiveRecordAccessEntry } from "./nsfHelpers"; +import type { + NsfRecordPermissionActionInput, + NsfRecordPermissionFailure, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, +} from "./nsfTypes"; +import { + NsfPermissionFailureCode, + NsfRecordPermissionAction, +} from "./nsfTypes"; + +type NsfRecordAccessEntry = NsfLiveRecordAccessEntry; + +type NsfSharePermissionItem = { + recordUid: string; + email: string; + accessRoleType?: Folder.AccessRoleType; + curRole?: string; + newRole?: string; +}; + +type NsfShareOperationOutcome = { + recordUid: string; + email: string; + success: boolean; + skipped?: boolean; + message?: string; +}; + +type PermissionChangeBuckets = { + updates: NsfSharePermissionItem[]; + creates: NsfSharePermissionItem[]; + revokes: NsfSharePermissionItem[]; + skipped: NsfRecordPermissionPlanItem[]; +}; + +function normalizeAction( + action: NsfRecordPermissionActionInput, +): NsfRecordPermissionAction { + const value = action as NsfRecordPermissionAction; + if ( + value === NsfRecordPermissionAction.Grant || + value === NsfRecordPermissionAction.Revoke + ) { + return value; + } + throw new KeeperSdkError( + `Invalid action '${action}'. Use: grant, revoke.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); +} + +function buildFolderRecordMap( + storage: InMemoryStorage, +): Map> { + const map = new Map>(); + for (const entry of storage.getAll( + KeeperDriveKind.FolderRecord, + )) { + const folderUid = entry.folderUid; + if (!map.has(folderUid)) map.set(folderUid, new Set()); + map.get(folderUid)!.add(entry.recordUid); + } + return map; +} + +function resolveFolderForPermission( + storage: InMemoryStorage, + folderInput?: string, +): { folderUid: string | null; displayName: string } { + const trimmed = folderInput?.trim(); + if (!trimmed) { + return { folderUid: null, displayName: "root" }; + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed); + if (!folderUid) { + throw new KeeperSdkError( + `Folder "${trimmed}" not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } + ensureNestedShareFolder(storage, folderUid, trimmed); + const folder = getKeeperDriveFolder(storage, folderUid); + return { + folderUid, + displayName: folder?.data.name || trimmed, + }; +} + +export function collectNsfRecordUidsInFolder( + storage: InMemoryStorage, + folderUid: string | null, + recursive: boolean, +): Set { + const folders = getKeeperDriveFolders(storage); + const folderUids = new Set(folders.map((folder) => folder.uid)); + const folderRecords = buildFolderRecordMap(storage); + const recordUids = new Set(); + + const walk = (currentUid: string, visited = new Set()): void => { + if (visited.has(currentUid)) return; + visited.add(currentUid); + for (const recordUid of folderRecords.get(currentUid) ?? []) { + recordUids.add(recordUid); + } + if (!recursive) return; + for (const folder of folders) { + if (folder.parentUid !== currentUid) continue; + if (visited.has(folder.uid)) continue; + walk(folder.uid, visited); + } + }; + + if (folderUid) { + walk(folderUid); + return recordUids; + } + + for (const [fuid, records] of folderRecords.entries()) { + if (!folderUids.has(fuid)) { + for (const recordUid of records) recordUids.add(recordUid); + } + } + if (recursive) { + for (const folder of folders) { + walk(folder.uid); + } + } + return recordUids; +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const recordEntry = getKeeperDriveRecord(storage, recordUid); + if (!recordEntry) return ""; + return getRecordTitle(recordEntry).slice(0, 32); +} + +async function getRecordAccessesV3( + auth: Auth, + storage: InMemoryStorage, + recordUids: string[], +): Promise<{ + recordAccesses: NsfRecordAccessEntry[]; + forbiddenRecords: string[]; +}> { + if (recordUids.length === 0) { + throw new KeeperSdkError( + "At least one record UID required.", + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); + } + return fetchLiveRecordAccessesV3(auth, storage, recordUids); +} + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid); + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); + } + return recordKey; +} + +async function buildSharePermission( + storage: InMemoryStorage, + auth: Auth, + item: NsfSharePermissionItem, + userKeysByEmail: Map, +): Promise { + const recordKey = await requireRecordKey(storage, auth, item.recordUid); + const emailKey = item.email.trim().toLowerCase(); + let userKeys = userKeysByEmail.get(emailKey); + if (!userKeys) { + userKeys = await loadUserShareKeysOrInvite( + auth, + item.email, + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); + userKeysByEmail.set(emailKey, userKeys); + } + return buildNsfSharePermissionFromKeys( + item.recordUid, + recordKey, + item.accessRoleType ?? Folder.AccessRoleType.VIEWER, + userKeys, + ); +} + +async function buildRevokePermission( + item: NsfSharePermissionItem, + userKeysByEmail: Map, +): Promise { + const emailKey = item.email.trim().toLowerCase(); + const userKeys = userKeysByEmail.get(emailKey); + if (!userKeys) { + throw new KeeperSdkError( + `User ${item.email} not found`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); + } + return buildNsfRevokePermissionFromKeys(item.recordUid, userKeys); +} + +async function preloadUserShareKeys( + auth: Auth, + items: NsfSharePermissionItem[], + inviteMissing: boolean, +): Promise> { + return preloadNsfUserShareKeys( + auth, + items.map((item) => item.email), + inviteMissing, + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); +} + +async function executeShareBatch( + auth: Auth, + request: record.v3.sharing.IRequest, + statusField: + | "updatedSharingStatus" + | "createdSharingStatus" + | "revokedSharingStatus", +): Promise> { + const response = await auth.executeRest(recordsShareV3Message(request)); + const statuses = response[statusField] ?? []; + return statuses.map((status) => parseRecordSharingStatus(status)); +} + +async function runShareBatch( + auth: Auth, + items: T[], + buildPermission: ( + item: T, + userKeysByEmail: Map, + ) => Promise, + applyToRequest: ( + request: record.v3.sharing.IRequest, + permission: record.v3.sharing.IPermissions, + ) => void, + statusField: + | "updatedSharingStatus" + | "createdSharingStatus" + | "revokedSharingStatus", + inviteMissingKeys: boolean, +): Promise { + const outcomes: NsfShareOperationOutcome[] = []; + + for (let index = 0; index < items.length; index += NSF_SHARE_BATCH_SIZE) { + const chunk = items.slice(index, index + NSF_SHARE_BATCH_SIZE); + const userKeysByEmail = await preloadUserShareKeys( + auth, + chunk, + inviteMissingKeys, + ); + const request: record.v3.sharing.IRequest = {}; + const built: T[] = []; + + for (const item of chunk) { + try { + const permission = await buildPermission(item, userKeysByEmail); + applyToRequest(request, permission); + built.push(item); + } catch (err) { + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: false, + skipped: true, + message: extractErrorMessage(err), + }); + } + } + + if (built.length === 0) continue; + + try { + const statusList = await executeShareBatch(auth, request, statusField); + const statusByRecordUid = new Map( + statusList + .filter((status) => status.recordUid.length > 0) + .map((status) => [status.recordUid, status] as const), + ); + for (const item of built) { + const status = statusByRecordUid.get(item.recordUid); + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: status?.success ?? false, + message: status?.message ?? "No status returned", + }); + } + } catch (err) { + const message = extractErrorMessage(err); + for (const item of built) { + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: false, + message, + }); + } + } + } + + return outcomes; +} + +async function batchUpdateRecordSharesV3( + storage: InMemoryStorage, + auth: Auth, + updates: NsfSharePermissionItem[], +): Promise { + return runShareBatch( + auth, + updates, + (item, userKeysByEmail) => + buildSharePermission(storage, auth, item, userKeysByEmail), + (request, permission) => { + request.updateSharingPermissions = [ + ...(request.updateSharingPermissions ?? []), + permission, + ]; + }, + "updatedSharingStatus", + true, + ); +} + +async function batchCreateRecordSharesV3( + storage: InMemoryStorage, + auth: Auth, + creates: NsfSharePermissionItem[], +): Promise { + return runShareBatch( + auth, + creates, + (item, userKeysByEmail) => + buildSharePermission(storage, auth, item, userKeysByEmail), + (request, permission) => { + request.createSharingPermissions = [ + ...(request.createSharingPermissions ?? []), + permission, + ]; + }, + "createdSharingStatus", + true, + ); +} + +async function batchUnshareRecordsV3( + auth: Auth, + revokes: NsfSharePermissionItem[], +): Promise { + return runShareBatch( + auth, + revokes, + (item, userKeysByEmail) => buildRevokePermission(item, userKeysByEmail), + (request, permission) => { + request.revokeSharingPermissions = [ + ...(request.revokeSharingPermissions ?? []), + permission, + ]; + }, + "revokedSharingStatus", + false, + ); +} + +function computePermissionChanges( + storage: InMemoryStorage, + accesses: NsfRecordAccessEntry[], + forbiddenRecords: string[], + recordUids: Set, + currentUser: string, + action: NsfRecordPermissionAction, + role: string | undefined, + accessRoleType: number | undefined, +): PermissionChangeBuckets { + const updates: NsfSharePermissionItem[] = []; + const creates: NsfSharePermissionItem[] = []; + const revokes: NsfSharePermissionItem[] = []; + const skipped: NsfRecordPermissionPlanItem[] = []; + + const forbidden = new Set(forbiddenRecords); + const ownerFlags = new Map(); + const currentUserLower = currentUser.toLowerCase(); + + for (const access of accesses) { + if (access.accessorName.toLowerCase() === currentUserLower) { + ownerFlags.set(access.recordUid, access.owner || access.canUpdateAccess); + } + } + + for (const recordUid of recordUids) { + if (!forbidden.has(recordUid)) continue; + skipped.push({ + recordUid, + title: recordTitle(storage, recordUid), + email: "", + curRole: "", + reason: "No access — record is forbidden", + }); + } + + for (const access of accesses) { + const recordUid = access.recordUid; + if (!recordUids.has(recordUid) || access.owner) continue; + + const email = access.accessorName; + if (!email || email.toLowerCase() === currentUserLower) continue; + + const curRole = getNsfAccessRoleLabel(access); + const isInherited = access.inherited; + const title = recordTitle(storage, recordUid); + + if (!ownerFlags.get(recordUid)) { + skipped.push({ + recordUid, + title, + email, + curRole, + reason: "Insufficient permission (can_update_access is false)", + }); + continue; + } + + if (action === NsfRecordPermissionAction.Grant) { + if (curRole === role) continue; + const entry: NsfSharePermissionItem = { + recordUid, + email, + curRole, + newRole: role, + accessRoleType, + }; + if (isInherited) { + creates.push(entry); + } else { + updates.push(entry); + } + continue; + } + + if (role && curRole !== role) continue; + if (isInherited) { + skipped.push({ + recordUid, + title, + email, + curRole, + reason: + "Inherited from a shared folder — revoke at the parent shared folder", + }); + continue; + } + revokes.push({ recordUid, email, curRole }); + } + + return { updates, creates, revokes, skipped }; +} + +function planItemFromShare( + storage: InMemoryStorage, + item: NsfSharePermissionItem, + inherited = false, +): NsfRecordPermissionPlanItem { + return { + recordUid: item.recordUid, + title: recordTitle(storage, item.recordUid), + email: item.email, + curRole: inherited + ? `${item.curRole || ""} (inherited)`.trim() + : item.curRole || "", + newRole: item.newRole, + }; +} + +export function buildNsfRecordPermissionPlan( + storage: InMemoryStorage, + buckets: PermissionChangeBuckets, +): NsfRecordPermissionPlan { + return { + grants: [ + ...buckets.updates.map((item) => planItemFromShare(storage, item)), + ...buckets.creates.map((item) => planItemFromShare(storage, item, true)), + ], + revokes: buckets.revokes.map((item) => ({ + recordUid: item.recordUid, + title: recordTitle(storage, item.recordUid), + email: item.email, + curRole: item.curRole || "", + })), + skipped: buckets.skipped, + }; +} + +function formatPermissionPlanTable( + title: string, + headers: string[], + rows: string[][], +): string[] { + if (rows.length === 0) return []; + + const columnCount = headers.length; + const widths = headers.map((header, columnIndex) => { + let width = header.length; + for (const row of rows) { + width = Math.max(width, (row[columnIndex] ?? "").length); + } + return width; + }); + const pad = (cell: string, columnIndex: number) => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]) => + cells.map((cell, columnIndex) => pad(cell, columnIndex)).join(" "); + const rule = widths + .map((width, columnIndex) => pad("-".repeat(width), columnIndex)) + .join(" "); + + return [ + title, + "", + formatRow(headers), + rule, + ...rows.map((row) => formatRow(row)), + "", + ]; +} + +export function formatNsfRecordPermissionRequestHeader( + action: NsfRecordPermissionActionInput, + role: string | undefined, + folderDisplayName: string, + recursive: boolean, +): string { + const normalizedAction = normalizeAction(action); + const scope = recursive ? "and sub-folders" : "only"; + if (normalizedAction === NsfRecordPermissionAction.Grant) { + return `Request to GRANT "${role ?? ""}" permission(s) in "${folderDisplayName}" folder ${scope}`; + } + const roleSuffix = role ? ` matching "${role}"` : ""; + return `Request to REVOKE record permission(s)${roleSuffix} in "${folderDisplayName}" folder ${scope}`; +} + +export function formatNsfRecordPermissionPlan( + plan: NsfRecordPermissionPlan, +): string { + const lines: string[] = []; + + if (plan.skipped.length > 0) { + lines.push( + ...formatPermissionPlanTable( + "SKIP — Record permission(s). Not permitted", + ["Record UID", "Title", "Email", "Current Role", "Reason"], + plan.skipped.map((item) => [ + item.recordUid, + item.title || "—", + item.email || "—", + item.curRole || "—", + item.reason || "Unknown", + ]), + ), + ); + } + + if (plan.grants.length > 0) { + lines.push( + ...formatPermissionPlanTable( + "GRANT Record permission(s)", + ["#", "Record UID", "Title", "Email", "Current Role", "New Role"], + plan.grants.map((item, index) => [ + String(index + 1), + item.recordUid, + item.title || "—", + item.email, + item.curRole || "—", + item.newRole || "—", + ]), + ), + ); + lines.push( + "WARNING: Review the planned changes carefully before confirming.", + ); + lines.push(""); + } + + if (plan.revokes.length > 0) { + lines.push( + ...formatPermissionPlanTable( + "REVOKE — Record share(s)", + ["#", "Record UID", "Title", "Email", "Current Role"], + plan.revokes.map((item, index) => [ + String(index + 1), + item.recordUid, + item.title || "—", + item.email, + item.curRole || "—", + ]), + ), + ); + lines.push( + "WARNING: Review the planned changes carefully before confirming.", + ); + lines.push(""); + } + + return lines.join("\n").trimEnd(); +} + +function mapFailures( + outcomes: NsfShareOperationOutcome[], + defaultCode: string, +): NsfRecordPermissionFailure[] { + return outcomes + .filter((outcome) => !outcome.success) + .map((outcome) => ({ + recordUid: outcome.recordUid, + email: outcome.email, + code: outcome.skipped ? NsfPermissionFailureCode.Skipped : defaultCode, + message: outcome.message || "Unknown error", + })); +} + +export async function updateNestedShareRecordPermissions( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordPermissionInput, +): Promise { + const action = normalizeAction(input.action); + const recursive = input.recursive ?? false; + const dryRun = input.dryRun ?? false; + const normalizedRole = normalizeNsfRecordPermissionRole(input.role); + + if (action === NsfRecordPermissionAction.Grant) { + if (!normalizedRole) { + throw new KeeperSdkError( + "Role is required for grant action.", + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); + } + if ( + !(NSF_RECORD_PERMISSION_ROLES as readonly string[]).includes( + normalizedRole, + ) + ) { + throw new KeeperSdkError( + `Invalid role '${input.role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(", ")}.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); + } + } else if (input.role) { + normalizeNsfRecordPermissionRole(input.role); + } + + const accessRoleType = + action === NsfRecordPermissionAction.Grant && normalizedRole + ? resolveNsfRoleName(normalizedRole) + : undefined; + + const { folderUid, displayName } = resolveFolderForPermission( + storage, + input.folder, + ); + const recordUids = collectNsfRecordUidsInFolder( + storage, + folderUid, + recursive, + ); + if (recordUids.size === 0) { + throw new KeeperSdkError( + "No records found in the specified folder.", + ResultCodes.NSF_NOT_FOUND, + ); + } + + try { + const accessesResult = await getRecordAccessesV3(auth, storage, [ + ...recordUids, + ]); + const buckets = computePermissionChanges( + storage, + accessesResult.recordAccesses, + accessesResult.forbiddenRecords, + recordUids, + auth.username, + action, + normalizedRole, + accessRoleType, + ); + const plan = buildNsfRecordPermissionPlan(storage, buckets); + + if ( + buckets.updates.length === 0 && + buckets.creates.length === 0 && + buckets.revokes.length === 0 + ) { + return { + confirmed: false, + dryRun, + folderDisplayName: displayName, + plan, + grantFailures: [], + revokeFailures: [], + message: plan.skipped.length + ? "No permission changes can be made." + : "No permission changes are needed.", + }; + } + + if (dryRun || !input.force) { + return { + confirmed: false, + dryRun, + folderDisplayName: displayName, + plan, + grantFailures: [], + revokeFailures: [], + message: dryRun + ? undefined + : "Confirmation required. Set force=true to proceed.", + }; + } + + const grantOutcomes: NsfShareOperationOutcome[] = []; + if (buckets.updates.length > 0) { + grantOutcomes.push( + ...(await batchUpdateRecordSharesV3(storage, auth, buckets.updates)), + ); + } + if (buckets.creates.length > 0) { + grantOutcomes.push( + ...(await batchCreateRecordSharesV3(storage, auth, buckets.creates)), + ); + } + + const revokeOutcomes = + buckets.revokes.length > 0 + ? await batchUnshareRecordsV3(auth, buckets.revokes) + : []; + + return { + confirmed: true, + dryRun: false, + folderDisplayName: displayName, + plan, + grantFailures: mapFailures(grantOutcomes, "error"), + revokeFailures: mapFailures(revokeOutcomes, "error"), + message: "Record permission changes applied.", + }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to update nested share record permissions: ${extractErrorMessage(err)}`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED, + ); + } +} + +export function formatNsfRecordPermissionFailures( + failures: NsfRecordPermissionFailure[], + kind: "GRANT" | "REVOKE", +): string { + if (failures.length === 0) return ""; + const lines = [`Failed to ${kind} — Record permission(s)`]; + for (const failure of failures) { + lines.push( + ` ${failure.recordUid} ${failure.email} ${failure.code} ${failure.message}`, + ); + } + return lines.join("\n"); +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts index 2d95a7cf..da6dcd44 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts @@ -1,95 +1,102 @@ -import type { Auth, Records } from '@keeper-security/keeperapi' -import { recordTypesGetMessage } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { NSF_LEGACY_RECORD_TYPES } from './nsfConstants' +import type { Auth, Records } from "@keeper-security/keeperapi"; +import { recordTypesGetMessage } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { NSF_LEGACY_RECORD_TYPES } from "./nsfConstants"; type RecordTypeSchema = { - id?: string - fields?: unknown[] -} + id?: string; + fields?: unknown[]; +}; -const RECORD_TYPE_CACHE_TTL_MS = 5 * 60 * 1000 +const RECORD_TYPE_CACHE_TTL_MS = 5 * 60 * 1000; -let cachedAuth: Auth | undefined -let cachedRecordTypes: Records.IRecordType[] | undefined -let cachedAt = 0 +let cachedAuth: Auth | undefined; +let cachedRecordTypes: Records.IRecordType[] | undefined; +let cachedAt = 0; export function clearNsfRecordTypeCache(): void { - cachedAuth = undefined - cachedRecordTypes = undefined - cachedAt = 0 + cachedAuth = undefined; + cachedRecordTypes = undefined; + cachedAt = 0; } function parseRecordTypeSchema(content: string): RecordTypeSchema | undefined { - try { - const parsed = JSON.parse(content) as Record - if (typeof parsed !== 'object' || parsed === null) return undefined - const id = typeof parsed.$id === 'string' ? parsed.$id : undefined - const fields = Array.isArray(parsed.fields) ? parsed.fields : undefined - return { id, fields } - } catch { - return undefined - } + try { + const parsed = JSON.parse(content) as Record; + if (typeof parsed !== "object" || parsed === null) return undefined; + const id = typeof parsed.$id === "string" ? parsed.$id : undefined; + const fields = Array.isArray(parsed.fields) ? parsed.fields : undefined; + return { id, fields }; + } catch { + return undefined; + } } async function loadRecordTypes(auth: Auth): Promise { - const now = Date.now() - if (cachedAuth === auth && cachedRecordTypes && now - cachedAt < RECORD_TYPE_CACHE_TTL_MS) { - return cachedRecordTypes - } + const now = Date.now(); + if ( + cachedAuth === auth && + cachedRecordTypes && + now - cachedAt < RECORD_TYPE_CACHE_TTL_MS + ) { + return cachedRecordTypes; + } - try { - const response = await auth.executeRest( - recordTypesGetMessage({ - standard: true, - user: true, - enterprise: true, - pam: true, - }) - ) - cachedRecordTypes = response.recordTypes ?? [] - cachedAuth = auth - cachedAt = now - return cachedRecordTypes - } catch (err) { - throw new KeeperSdkError( - `Failed to load record types: ${extractErrorMessage(err)}`, - ResultCodes.NSF_ADD_FAILED - ) - } + try { + const response = await auth.executeRest( + recordTypesGetMessage({ + standard: true, + user: true, + enterprise: true, + pam: true, + }), + ); + cachedRecordTypes = response.recordTypes ?? []; + cachedAuth = auth; + cachedAt = now; + return cachedRecordTypes; + } catch (err) { + throw new KeeperSdkError( + `Failed to load record types: ${extractErrorMessage(err)}`, + ResultCodes.NSF_ADD_FAILED, + ); + } } export async function getNsfRecordTypeFields( - auth: Auth, - recordType: string + auth: Auth, + recordType: string, ): Promise { - const normalized = recordType.trim() - if (!normalized || NSF_LEGACY_RECORD_TYPES.has(normalized)) return undefined + const normalized = recordType.trim(); + if (!normalized || NSF_LEGACY_RECORD_TYPES.has(normalized)) return undefined; - const types = await loadRecordTypes(auth) - for (const entry of types) { - if (!entry.content) continue - const schema = parseRecordTypeSchema(entry.content) - if (schema?.id === normalized && schema.fields?.length) { - return schema.fields - } + const types = await loadRecordTypes(auth); + for (const entry of types) { + if (!entry.content) continue; + const schema = parseRecordTypeSchema(entry.content); + if (schema?.id === normalized && schema.fields?.length) { + return schema.fields; } - return undefined + } + return undefined; } export async function validateNsfRecordType( - auth: Auth, - recordType: string, - resultCode: string = ResultCodes.NSF_ADD_FAILED + auth: Auth, + recordType: string, + resultCode: string = ResultCodes.NSF_ADD_FAILED, ): Promise { - const normalized = recordType.trim() - if (!normalized) { - throw new KeeperSdkError('Record type is required.', resultCode) - } - if (NSF_LEGACY_RECORD_TYPES.has(normalized)) return + const normalized = recordType.trim(); + if (!normalized) { + throw new KeeperSdkError("Record type is required.", resultCode); + } + if (NSF_LEGACY_RECORD_TYPES.has(normalized)) return; - const fields = await getNsfRecordTypeFields(auth, normalized) - if (!fields?.length) { - throw new KeeperSdkError(`Record type "${normalized}" cannot be found.`, resultCode) - } + const fields = await getNsfRecordTypeFields(auth, normalized); + if (!fields?.length) { + throw new KeeperSdkError( + `Record type "${normalized}" cannot be found.`, + resultCode, + ); + } } diff --git a/KeeperSdk/src/nestedShareFolders/nsfShare.ts b/KeeperSdk/src/nestedShareFolders/nsfShare.ts new file mode 100644 index 00000000..c8ad82b0 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShare.ts @@ -0,0 +1,977 @@ +import type { Auth } from "@keeper-security/keeperapi"; +import { + Folder, + normal64Bytes, + platform, + recordsShareV3Message, + folderAccessUpdateMessage, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { getRecordTitle } from "../records/RecordUtils"; +import { + buildNsfRecordSharePermission, + buildNsfRevokePermissionFromKeys, + loadUserShareKeys, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, +} from "./nsfShareKeys"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { collectNsfRecordUidsInFolder } from "./nsfRecordPermission"; +import { resolveRecordKeyBytes } from "./nsfRecordCrypto"; +import { transferNestedShareRecordOwnership } from "./nsfTransferRecord"; +import { + getFolderPermissionsForRole, + NSFShareRoleName, + NsfShareCommandName, +} from "./nsfConstants"; +import { + checkFolderSharePermission, + checkRecordChangeOwnershipPermission, + checkRecordSharePermission, + collectExistingFolderShareTargets, + ensureNestedShareFolder, + ensureNestedShareRecord, + fetchLiveRecordAccessesV3, + findDirectUserRecordShare, + findUserRecordShareEntry, + findFolderAccessEntryOrLive, + loadShareUserMap, + getKeeperDriveRecord, + getNsfRecordPermissionRoleLabel, + isShareExpirationNoop, + parseShareExpiration, + requireAuthAccountUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, + resolveNsfRoleName, +} from "./nsfHelpers"; +import { + encryptNsfFolderKeyForTeam, + fetchNsfTeamPublicKeys, + resolveNsfShareRecipient, +} from "./nsfTeamShare"; +import type { + NsfDirectUserRecordShare, + NsfFolderAccessOperationResult, + NsfFolderShareActionInput, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfResolvedShareRecipient, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, +} from "./nsfTypes"; +import { + NsfFolderShareAction, + NsfFolderShareActionTaken, + NsfRecordShareAction, + NsfRecordShareActionTaken, + NsfResultStatus, +} from "./nsfTypes"; + +const SHARE_ERROR = ResultCodes.NSF_SHARE_FAILED; + +function normalizeFolderAction( + action: NsfFolderShareActionInput = NsfFolderShareAction.Grant, +): NsfFolderShareAction { + const value = action as NsfFolderShareAction; + if ( + value === NsfFolderShareAction.Grant || + value === NsfFolderShareAction.Remove + ) + return value; + throw new KeeperSdkError( + `Invalid action '${action}'. Use: grant, remove.`, + SHARE_ERROR, + ); +} + +function normalizeRecordAction( + action: NsfRecordShareActionInput = NsfRecordShareAction.Grant, +): NsfRecordShareAction { + const value = action as NsfRecordShareAction; + if ( + value === NsfRecordShareAction.Grant || + value === NsfRecordShareAction.Revoke || + value === NsfRecordShareAction.Owner + ) { + return value; + } + throw new KeeperSdkError( + `Invalid action '${action}'. Use: grant, revoke, owner.`, + SHARE_ERROR, + ); +} + +function parseFolderAccessResult( + result: Folder.IFolderAccessResult | null | undefined, + folderUid: string, + recipient: string, +): NsfFolderAccessOperationResult { + if (!result) { + return { + folderUid, + recipient, + success: false, + message: "No folder access result returned", + }; + } + const status = result.status ?? Folder.FolderModifyStatus.SUCCESS; + const statusName = Folder.FolderModifyStatus[status] ?? String(status); + return { + folderUid, + recipient, + success: status === Folder.FolderModifyStatus.SUCCESS, + message: result.message || statusName, + }; +} + +function buildFolderAccessRemoveData( + folderUid: string, + accessTypeUid: Uint8Array, + accessType: Folder.AccessType, +): Folder.IFolderAccessData { + return Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid, + accessType, + }); +} + +async function buildEncryptedFolderKeyForUser( + auth: Auth, + folderKey: Uint8Array, + email: string, +): Promise { + const userKeys = await loadUserShareKeysOrInvite(auth, email, SHARE_ERROR); + if (userKeys.rsaPublicKey?.length) { + const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey); + return Folder.EncryptedDataKey.create({ + encryptedKey: platform.publicEncrypt(folderKey, rsaKeyBase64), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key, + }); + } + if (userKeys.eccPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: await platform.publicEncryptEC( + folderKey, + userKeys.eccPublicKey, + ), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key_ecc, + }); + } + throw new KeeperSdkError( + `No usable public key available for ${email}`, + SHARE_ERROR, + ); +} + +async function buildFolderAccessGrantData( + auth: Auth, + storage: InMemoryStorage, + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, +): Promise { + const folderKey = await storage.getKeyBytes(folderUid); + if (!folderKey) { + throw new KeeperSdkError( + `Folder key not available for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); + } + + const accessType = target.isTeam + ? Folder.AccessType.AT_TEAM + : Folder.AccessType.AT_USER; + let accessTypeUid: Uint8Array; + let encryptedFolderKey: Folder.IEncryptedDataKey; + + if (target.isTeam) { + if (!target.accountUid?.length) { + throw new KeeperSdkError( + `Team ${target.recipient} not found`, + ResultCodes.TEAM_NOT_FOUND, + ); + } + accessTypeUid = target.accountUid; + const teamKeys = await fetchNsfTeamPublicKeys( + auth, + target.recipient, + storage, + ); + encryptedFolderKey = await encryptNsfFolderKeyForTeam(folderKey, teamKeys); + } else { + const userKeys = await loadUserShareKeysOrInvite( + auth, + target.recipient, + SHARE_ERROR, + ); + accessTypeUid = userKeys.accountUid; + encryptedFolderKey = await buildEncryptedFolderKeyForUser( + auth, + folderKey, + target.recipient, + ); + } + + const data = Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid, + accessType, + accessRoleType, + permissions: getFolderPermissionsForRole(accessRoleType), + folderKey: encryptedFolderKey, + }); + + if (expirationTimestamp != null) { + data.tlaProperties = { expiration: expirationTimestamp }; + } + + return data; +} + +function buildFolderAccessUpdateData( + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, +): Folder.IFolderAccessData { + if (!target.accountUid?.length) { + throw new KeeperSdkError( + target.isTeam + ? `Team ${target.recipient} not found` + : `User ${target.recipient} not found`, + target.isTeam ? ResultCodes.TEAM_NOT_FOUND : SHARE_ERROR, + ); + } + + const data = Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid: target.accountUid, + accessType: target.isTeam + ? Folder.AccessType.AT_TEAM + : Folder.AccessType.AT_USER, + accessRoleType, + permissions: getFolderPermissionsForRole(accessRoleType), + }); + if (expirationTimestamp != null) { + data.tlaProperties = { expiration: expirationTimestamp }; + } + return data; +} + +async function resolveFolderShareTargets( + auth: Auth, + storage: InMemoryStorage, + folderUid: string, + recipients: string[], + currentUsername: string, +): Promise { + const targets: NsfResolvedShareRecipient[] = []; + const seen = new Set(); + + for (const raw of recipients) { + const trimmed = raw.trim(); + if (!trimmed) continue; + + if (trimmed === "@existing" || trimmed === "@current") { + for (const entry of collectExistingFolderShareTargets( + storage, + folderUid, + currentUsername, + )) { + const key = `${entry.isTeam ? "team" : "user"}:${entry.recipient.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + targets.push({ + recipient: entry.recipient, + isTeam: entry.isTeam, + accountUid: entry.accountUid + ? normal64Bytes(entry.accountUid) + : undefined, + }); + } + continue; + } + + const classified = await resolveNsfShareRecipient(auth, trimmed); + if (!classified) continue; + const key = `${classified.isTeam ? "team" : "user"}:${classified.recipient.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + + if (!classified.isTeam) { + const userKeys = await loadUserShareKeysOrInvite( + auth, + classified.recipient, + SHARE_ERROR, + ); + targets.push({ + recipient: classified.recipient, + isTeam: false, + accountUid: userKeys.accountUid, + }); + } else { + targets.push(classified); + } + } + + return targets; +} + +async function grantFolderAccess( + storage: InMemoryStorage, + auth: Auth, + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, +): Promise { + const accessType = target.isTeam + ? Folder.AccessType.AT_TEAM + : Folder.AccessType.AT_USER; + const accessTypeUid = target.accountUid + ? webSafe64FromBytes(target.accountUid) + : target.recipient; + + const existing = await findFolderAccessEntryOrLive( + storage, + auth, + folderUid, + accessTypeUid, + accessType, + ); + if ( + existing && + existing.accessRoleType === accessRoleType && + expirationTimestamp == null + ) { + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: true, + actionTaken: NsfFolderShareActionTaken.AlreadyHadAccess, + message: `Already has ${getNsfRecordPermissionRoleLabel(accessRoleType)} access`, + }; + } + + const request = + existing != null + ? buildFolderAccessUpdateData( + folderUid, + target, + accessRoleType, + expirationTimestamp, + ) + : await buildFolderAccessGrantData( + auth, + storage, + folderUid, + target, + accessRoleType, + expirationTimestamp, + ); + + const response = await auth.executeRest( + folderAccessUpdateMessage({ + [existing != null ? "folderAccessUpdates" : "folderAccessAdds"]: [ + request, + ], + }), + ); + + const parsed = parseFolderAccessResult( + response.folderAccessResults?.[0], + folderUid, + target.recipient, + ); + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: parsed.success, + actionTaken: + existing != null + ? NsfFolderShareActionTaken.Updated + : NsfFolderShareActionTaken.Granted, + message: parsed.message, + }; +} + +async function removeFolderAccess( + auth: Auth, + folderUid: string, + target: NsfResolvedShareRecipient, +): Promise { + const accessType = target.isTeam + ? Folder.AccessType.AT_TEAM + : Folder.AccessType.AT_USER; + const accountUid = + target.accountUid ?? + (target.isTeam + ? normal64Bytes(target.recipient) + : (await loadUserShareKeysOrInvite(auth, target.recipient, SHARE_ERROR)) + .accountUid); + + const response = await auth.executeRest( + folderAccessUpdateMessage({ + folderAccessRemoves: [ + buildFolderAccessRemoveData(folderUid, accountUid, accessType), + ], + }), + ); + + const parsed = parseFolderAccessResult( + response.folderAccessResults?.[0], + folderUid, + target.recipient, + ); + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: parsed.success, + actionTaken: NsfFolderShareActionTaken.Removed, + message: parsed.message, + }; +} + +export async function shareNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: ShareNestedShareFolderInput, +): Promise { + const action = normalizeFolderAction(input.action); + const folders = input.folders.map((folder) => folder.trim()).filter(Boolean); + const recipients = input.recipients + .map((recipient) => recipient.trim()) + .filter(Boolean); + + if (folders.length === 0) { + throw new KeeperSdkError("Folder path or UID is required.", SHARE_ERROR); + } + if (recipients.length === 0) { + throw new KeeperSdkError( + "Recipient is required (email, team name/UID, or @existing).", + SHARE_ERROR, + ); + } + + const role = input.role?.trim() || NSFShareRoleName.Viewer; + const accessRoleType = + action === NsfFolderShareAction.Grant + ? resolveNsfRoleName(role) + : undefined; + + const expirationTimestamp = + action === NsfFolderShareAction.Grant + ? parseShareExpiration({ + expireAt: input.expireAt, + expireIn: input.expireIn, + expirationTimestamp: input.expirationTimestamp, + cmdName: NsfShareCommandName.FolderShare, + }) + : undefined; + + const accountUid = requireAuthAccountUid(auth); + const results: NsfFolderShareResultItem[] = []; + + try { + for (const folderArg of folders) { + const folderUid = resolveNsfFolderIdentifier(storage, folderArg); + if (!folderUid) { + throw new KeeperSdkError( + `No such folder: ${folderArg}`, + ResultCodes.NSF_NOT_FOUND, + ); + } + ensureNestedShareFolder(storage, folderUid, folderArg); + checkFolderSharePermission(storage, folderUid, auth.username, accountUid); + + const targets = await resolveFolderShareTargets( + auth, + storage, + folderUid, + recipients, + auth.username, + ); + if (targets.length === 0) { + throw new KeeperSdkError( + `No share targets resolved for folder '${folderArg}'.`, + SHARE_ERROR, + ); + } + + for (const target of targets) { + if (action === NsfFolderShareAction.Remove) { + results.push(await removeFolderAccess(auth, folderUid, target)); + } else { + results.push( + await grantFolderAccess( + storage, + auth, + folderUid, + target, + accessRoleType!, + expirationTimestamp, + ), + ); + } + } + } + + return { results }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to share nested share folder: ${extractErrorMessage(err)}`, + SHARE_ERROR, + ); + } +} + +function resolveRecordUids( + storage: InMemoryStorage, + recordArg: string, + recursive: boolean, +): string[] { + const trimmed = recordArg.trim(); + if (!trimmed) { + throw new KeeperSdkError("Record path or UID is required.", SHARE_ERROR); + } + + if (getKeeperDriveRecord(storage, trimmed)) { + ensureNestedShareRecord(storage, trimmed, trimmed); + return [trimmed]; + } + + const recordUid = resolveNsfRecordIdentifier(storage, trimmed); + if (recordUid) { + ensureNestedShareRecord(storage, recordUid, trimmed); + return [recordUid]; + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed); + if (folderUid) { + ensureNestedShareFolder(storage, folderUid, trimmed); + const recordUids = collectNsfRecordUidsInFolder( + storage, + folderUid, + recursive, + ); + if (recordUids.size === 0) { + throw new KeeperSdkError( + "No records found in the specified folder.", + ResultCodes.NSF_NOT_FOUND, + ); + } + return [...recordUids]; + } + + throw new KeeperSdkError( + `Record or folder '${trimmed}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); +} + +function isShareUpdateNoop( + existing: NsfDirectUserRecordShare, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, +): boolean { + if (existing.accessRoleType !== accessRoleType) return false; + return isShareExpirationNoop(existing.expiration, expirationTimestamp); +} + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid); + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); + } + return recordKey; +} + +async function transferRecordOwnership( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string, +): Promise { + const result = await transferNestedShareRecordOwnership( + storage, + auth, + recordUid, + email, + ); + return { + recordUid, + email, + success: result.success, + actionTaken: NsfRecordShareActionTaken.Owner, + message: result.message, + }; +} + +async function resolveUserRecordShareForRevoke( + auth: Auth, + storage: InMemoryStorage, + recordUid: string, + email: string, +): Promise<{ + hasDirectShare: boolean; + inherited: boolean; + userKeys: Awaited>; +}> { + const userKeys = await loadUserShareKeys(auth, email); + const accountUidStr = webSafe64FromBytes(userKeys.accountUid); + const shareUsers = await loadShareUserMap(auth, storage); + + const storageEntry = findUserRecordShareEntry( + storage, + recordUid, + email, + accountUidStr, + shareUsers, + ); + if (storageEntry) { + return { + hasDirectShare: !storageEntry.inherited, + inherited: !!storageEntry.inherited, + userKeys, + }; + } + + const { recordAccesses } = await fetchLiveRecordAccessesV3(auth, storage, [ + recordUid, + ]); + const liveEntry = recordAccesses.find( + (access) => + access.recordUid === recordUid && + !access.owner && + (access.accessTypeUid === accountUidStr || + access.accessorName.toLowerCase() === email.toLowerCase()), + ); + if (!liveEntry) { + return { hasDirectShare: false, inherited: false, userKeys }; + } + return { + hasDirectShare: !liveEntry.inherited, + inherited: !!liveEntry.inherited, + userKeys, + }; +} + +async function revokeRecordShare( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string, +): Promise { + const shareState = await resolveUserRecordShareForRevoke( + auth, + storage, + recordUid, + email, + ); + if (!shareState.hasDirectShare && !shareState.inherited) { + return { + recordUid, + email, + success: true, + actionTaken: NsfRecordShareActionTaken.NoAccess, + message: "User does not have access to this record", + }; + } + if (shareState.inherited) { + return { + recordUid, + email, + success: true, + actionTaken: NsfRecordShareActionTaken.Skipped, + message: + "Access is inherited from a shared folder — revoke at the parent folder", + }; + } + + const permission = buildNsfRevokePermissionFromKeys( + recordUid, + shareState.userKeys, + ); + const response = await auth.executeRest( + recordsShareV3Message({ revokeSharingPermissions: [permission] }), + ); + const parsed = parseRecordSharingStatus(response.revokedSharingStatus?.[0]); + return { + recordUid, + email, + success: parsed.success, + actionTaken: NsfRecordShareActionTaken.Revoke, + message: parsed.message, + }; +} + +async function grantRecordShare( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, +): Promise { + const existing = findDirectUserRecordShare(storage, recordUid, email); + if ( + existing && + isShareUpdateNoop(existing, accessRoleType, expirationTimestamp) + ) { + return { + recordUid, + email, + success: true, + actionTaken: NsfRecordShareActionTaken.Update, + message: "Already has requested access", + }; + } + + if (existing && expirationTimestamp != null && expirationTimestamp > 0) { + const revokeResult = await revokeRecordShare( + storage, + auth, + recordUid, + email, + ); + if (!revokeResult.success) { + return { ...revokeResult, actionTaken: NsfRecordShareActionTaken.Update }; + } + const recordKey = await requireRecordKey(storage, auth, recordUid); + const permission = await buildNsfRecordSharePermission( + auth, + recordUid, + recordKey, + email, + accessRoleType, + expirationTimestamp, + SHARE_ERROR, + ); + const response = await auth.executeRest( + recordsShareV3Message({ createSharingPermissions: [permission] }), + ); + const parsed = parseRecordSharingStatus(response.createdSharingStatus?.[0]); + return { + recordUid, + email, + success: parsed.success, + actionTaken: NsfRecordShareActionTaken.Grant, + message: parsed.message, + }; + } + + const recordKey = await requireRecordKey(storage, auth, recordUid); + const permission = await buildNsfRecordSharePermission( + auth, + recordUid, + recordKey, + email, + accessRoleType, + expirationTimestamp, + SHARE_ERROR, + ); + + const field = existing + ? "updateSharingPermissions" + : "createSharingPermissions"; + const response = await auth.executeRest( + recordsShareV3Message({ [field]: [permission] }), + ); + const statusField = existing + ? "updatedSharingStatus" + : "createdSharingStatus"; + const parsed = parseRecordSharingStatus(response[statusField]?.[0]); + + return { + recordUid, + email, + success: parsed.success, + actionTaken: existing + ? NsfRecordShareActionTaken.Update + : NsfRecordShareActionTaken.Grant, + message: parsed.message, + }; +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const recordEntry = getKeeperDriveRecord(storage, recordUid); + if (!recordEntry) return ""; + return getRecordTitle(recordEntry).slice(0, 32); +} + +export function formatNsfRecordSharePlan( + plan: NsfRecordSharePlanItem[], + dryRun = false, +): string { + if (plan.length === 0) return "No record share changes planned."; + const lines = ["Record share plan"]; + for (const item of plan) { + let line = ` ${item.recordUid} ${item.title || "—"} ${item.email} ${item.action}${item.role ? ` ${item.role}` : ""}`; + if (dryRun && item.expirationTimestamp != null) { + line += ` expires=${item.expirationTimestamp} ms`; + } + lines.push(line); + } + return lines.join("\n"); +} + +export function formatNsfRecordShareResults( + results: NsfRecordShareResultItem[], +): string { + if (results.length === 0) return ""; + const lines: string[] = []; + for (const item of results) { + lines.push( + `${item.recordUid} ${item.email} ${item.actionTaken} ${item.success ? NsfResultStatus.Success : NsfResultStatus.Failed} ${item.message || ""}`, + ); + } + return lines.join("\n"); +} + +export function formatNsfFolderShareResults( + results: NsfFolderShareResultItem[], +): string { + if (results.length === 0) return ""; + const lines: string[] = []; + for (const item of results) { + lines.push( + `${item.folderUid} ${item.recipient} ${item.actionTaken} ${item.success ? NsfResultStatus.Success : NsfResultStatus.Failed} ${item.message || ""}`, + ); + } + return lines.join("\n"); +} + +export async function shareNestedShareRecord( + storage: InMemoryStorage, + auth: Auth, + input: ShareNestedShareRecordInput, +): Promise { + const action = normalizeRecordAction(input.action); + const emails = input.emails.map((email) => email.trim()).filter(Boolean); + const dryRun = input.dryRun ?? false; + + if (!input.record?.trim()) { + throw new KeeperSdkError("Record path or UID is required.", SHARE_ERROR); + } + if (emails.length === 0) { + throw new KeeperSdkError("Recipient email is required.", SHARE_ERROR); + } + if (action === NsfRecordShareAction.Owner && emails.length > 1) { + throw new KeeperSdkError( + "Ownership can only be transferred to a single account.", + SHARE_ERROR, + ); + } + if (action === NsfRecordShareAction.Grant && !input.role?.trim()) { + throw new KeeperSdkError("Role is required for grant action.", SHARE_ERROR); + } + + const role = input.role?.trim() || NSFShareRoleName.Viewer; + const accessRoleType = + action === NsfRecordShareAction.Grant + ? resolveNsfRoleName(role) + : undefined; + + const expirationTimestamp = + action === NsfRecordShareAction.Grant + ? parseShareExpiration({ + expireAt: input.expireAt, + expireIn: input.expireIn, + expirationTimestamp: input.expirationTimestamp, + cmdName: NsfShareCommandName.RecordShare, + }) + : undefined; + + const recordUids = resolveRecordUids( + storage, + input.record, + input.recursive ?? false, + ); + const accountUid = requireAuthAccountUid(auth); + + for (const recordUid of recordUids) { + checkRecordSharePermission(storage, recordUid, auth.username, accountUid); + if (action === NsfRecordShareAction.Owner) { + checkRecordChangeOwnershipPermission( + storage, + recordUid, + auth.username, + accountUid, + ); + } + } + + const plan: NsfRecordSharePlanItem[] = []; + for (const email of emails) { + for (const recordUid of recordUids) { + plan.push({ + recordUid, + title: recordTitle(storage, recordUid), + email, + action, + role: action === NsfRecordShareAction.Grant ? role : undefined, + expirationTimestamp, + }); + } + } + + if (dryRun) { + return { dryRun: true, plan, results: [] }; + } + + const results: NsfRecordShareResultItem[] = []; + + try { + for (const email of emails) { + for (const recordUid of recordUids) { + if (action === NsfRecordShareAction.Owner) { + results.push( + await transferRecordOwnership(storage, auth, recordUid, email), + ); + } else if (action === NsfRecordShareAction.Revoke) { + results.push( + await revokeRecordShare(storage, auth, recordUid, email), + ); + } else { + results.push( + await grantRecordShare( + storage, + auth, + recordUid, + email, + accessRoleType!, + expirationTimestamp, + ), + ); + } + } + } + + return { dryRun: false, plan, results }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to share nested share record: ${extractErrorMessage(err)}`, + SHARE_ERROR, + ); + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts b/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts new file mode 100644 index 00000000..70249d99 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts @@ -0,0 +1,270 @@ +import type { Auth, Authentication } from "@keeper-security/keeperapi"; +import { + Folder, + common, + getPublicKeysMessage, + normal64Bytes, + platform, + record, + sendShareInviteMessage, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError } from "../utils"; + +const MISSING_PUBLIC_KEY = "missing_public_key"; + +export type UserShareKeys = { + username: string; + accountUid: Uint8Array; + rsaPublicKey: Uint8Array | null; + eccPublicKey: Uint8Array | null; +}; + +function mapUserShareKeysEntry( + email: string, + entry: NonNullable< + Authentication.IGetPublicKeysResponse["keyResponses"] + >[number], +): UserShareKeys | null { + if (!entry || entry.errorCode || !entry.accountUid?.length) return null; + return { + username: entry.username || email, + accountUid: entry.accountUid as Uint8Array, + rsaPublicKey: entry.publicKey?.length + ? (entry.publicKey as Uint8Array) + : null, + eccPublicKey: entry.publicEccKey?.length + ? (entry.publicEccKey as Uint8Array) + : null, + }; +} + +function dedupeNsfShareEmails(emails: string[]): string[] { + const seen = new Set(); + const deduped: string[] = []; + for (const rawEmail of emails) { + const normalized = rawEmail.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + deduped.push(normalized); + } + return deduped; +} + +async function fetchUserShareKeys( + auth: Auth, + email: string, +): Promise { + const response = await auth.executeRest( + getPublicKeysMessage({ usernames: [email] }), + ); + const entry = response.keyResponses?.[0]; + if (!entry) return null; + return mapUserShareKeysEntry(email, entry); +} + +export async function loadUserShareKeys( + auth: Auth, + email: string, +): Promise { + const keys = await fetchUserShareKeys(auth, email); + if (!keys?.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, MISSING_PUBLIC_KEY); + } + return keys; +} + +export async function loadUserShareKeysOrInvite( + auth: Auth, + email: string, + errorCode: string = MISSING_PUBLIC_KEY, +): Promise { + let keys = await fetchUserShareKeys(auth, email); + if (!keys?.accountUid?.length) { + try { + await auth.executeRestAction(sendShareInviteMessage({ email })); + } catch (err) { + throw new KeeperSdkError( + `Failed to invite ${email}: ${extractErrorMessage(err)}`, + errorCode, + ); + } + keys = await fetchUserShareKeys(auth, email); + } + if (!keys?.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode); + } + if (!keys.rsaPublicKey?.length && !keys.eccPublicKey?.length) { + throw new KeeperSdkError( + `No usable public key available for ${email}`, + errorCode, + ); + } + return keys; +} + +export async function loadNsfUserShareKeysMap( + auth: Auth, + emails: string[], +): Promise> { + const deduped = dedupeNsfShareEmails(emails); + const keysByEmail = new Map(); + if (deduped.length === 0) return keysByEmail; + + const response = await auth.executeRest( + getPublicKeysMessage({ usernames: deduped }), + ); + for (const entry of response.keyResponses ?? []) { + const email = (entry.username || "").trim().toLowerCase(); + if (!email) continue; + const keys = mapUserShareKeysEntry(email, entry); + if (keys) keysByEmail.set(email, keys); + } + return keysByEmail; +} + +export async function preloadNsfUserShareKeys( + auth: Auth, + emails: string[], + inviteMissing: boolean, + errorCode: string, +): Promise> { + const keysByEmail = await loadNsfUserShareKeysMap(auth, emails); + if (!inviteMissing) return keysByEmail; + + const uniqueEmails = dedupeNsfShareEmails(emails); + for (const emailKey of uniqueEmails) { + let userKeys = keysByEmail.get(emailKey); + if ( + !userKeys || + (!userKeys.rsaPublicKey?.length && !userKeys.eccPublicKey?.length) + ) { + userKeys = await loadUserShareKeysOrInvite(auth, emailKey, errorCode); + keysByEmail.set(emailKey, userKeys); + } + } + return keysByEmail; +} + +export async function encryptKeyForRecipient( + key: Uint8Array, + userKeys: UserShareKeys, +): Promise<{ encryptedKey: Uint8Array; useEccKey: boolean }> { + if (userKeys.eccPublicKey?.length) { + return { + encryptedKey: await platform.publicEncryptEC(key, userKeys.eccPublicKey), + useEccKey: true, + }; + } + if (userKeys.rsaPublicKey?.length) { + const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey); + return { + encryptedKey: platform.publicEncrypt(key, rsaKeyBase64), + useEccKey: false, + }; + } + throw new KeeperSdkError( + `No usable public key available for ${userKeys.username}`, + MISSING_PUBLIC_KEY, + ); +} + +export function parseRecordSharingStatus( + status: record.v3.sharing.IStatus | null | undefined, +): { recordUid: string; success: boolean; message: string } { + if (!status?.recordUid?.length) { + return { recordUid: "", success: false, message: "No status returned" }; + } + const recordUid = webSafe64FromBytes(status.recordUid); + const sharingStatus = + status.status ?? record.v3.sharing.SharingStatus.SUCCESS; + const statusName = + record.v3.sharing.SharingStatus[sharingStatus] ?? String(sharingStatus); + const success = + sharingStatus === record.v3.sharing.SharingStatus.SUCCESS || + sharingStatus === record.v3.sharing.SharingStatus.PENDING_ACCEPT; + return { recordUid, success, message: status.message || statusName }; +} + +export async function buildNsfSharePermissionFromKeys( + recordUid: string, + recordKey: Uint8Array, + accessRoleType: Folder.AccessRoleType, + userKeys: UserShareKeys, + expirationTimestamp?: number, +): Promise { + const { encryptedKey, useEccKey } = await encryptKeyForRecipient( + recordKey, + userKeys, + ); + const recordUidBytes = normal64Bytes(recordUid); + const rules: Folder.IRecordAccessData = { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + owner: false, + accessRoleType, + }; + if (expirationTimestamp != null) { + rules.tlaProperties = { expiration: expirationTimestamp }; + if (expirationTimestamp > 0) { + rules.tlaProperties.timerNotificationType = + common.tla.TimerNotificationType.NOTIFY_OWNER; + } + } + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + recordKey: encryptedKey, + useEccKey, + rules, + }; +} + +export function buildNsfRevokePermissionFromKeys( + recordUid: string, + userKeys: UserShareKeys, +): record.v3.sharing.IPermissions { + const recordUidBytes = normal64Bytes(recordUid); + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + rules: { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + }, + }; +} + +export async function buildNsfRecordSharePermission( + auth: Auth, + recordUid: string, + recordKey: Uint8Array, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, + errorCode: string = MISSING_PUBLIC_KEY, +): Promise { + const userKeys = await loadUserShareKeysOrInvite(auth, email, errorCode); + return buildNsfSharePermissionFromKeys( + recordUid, + recordKey, + accessRoleType, + userKeys, + expirationTimestamp, + ); +} + +export async function buildNsfRecordRevokePermission( + auth: Auth, + recordUid: string, + email: string, + errorCode: string = MISSING_PUBLIC_KEY, +): Promise { + const userKeys = await loadUserShareKeys(auth, email); + if (!userKeys.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode); + } + return buildNsfRevokePermissionFromKeys(recordUid, userKeys); +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts new file mode 100644 index 00000000..8e763443 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts @@ -0,0 +1,426 @@ +import type { Auth } from "@keeper-security/keeperapi"; +import { + Folder, + folderRecordUpdateMessage, + normal64Bytes, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { getRecordTitle } from "../records/RecordUtils"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { ListNsfFormat, type ListNsfFormatInput } from "./nsfTypes"; +import { + KeeperDriveKind, + checkFolderRemovePermission, + ensureNestedShareRecord, + getKeeperDriveRecord, + getFolderDisplayName, + isNestedShareFolder, + isRootFolderUid, + requireAuthAccountUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; +import type { DKdFolderRecord } from "@keeper-security/keeperapi"; +import type { + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + ListNsfShortcutsOptions, + NsfShortcutRow, +} from "./nsfTypes"; + +const SHORTCUT_ERROR = ResultCodes.NSF_SHORTCUT_FAILED; +const ROOT_FOLDER_LABEL = "root"; + +function isShortcutFolder( + storage: InMemoryStorage, + folderUid: string, +): boolean { + return ( + isRootFolderUid(storage, folderUid) || + isNestedShareFolder(storage, folderUid) + ); +} + +function formatFolderLabel( + storage: InMemoryStorage, + folderUid: string, +): string { + if (isRootFolderUid(storage, folderUid)) { + return `root (${ROOT_FOLDER_LABEL})`; + } + const name = getFolderDisplayName(storage, folderUid); + return `${name} (${folderUid})`; +} + +export function getNsfRecordShortcuts( + storage: InMemoryStorage, +): Map> { + const records = new Map>(); + + for (const entry of storage.getAll( + KeeperDriveKind.FolderRecord, + )) { + const folderUid = entry.folderUid; + if (!isShortcutFolder(storage, folderUid)) continue; + if (!entry.recordUid) continue; + + const folderSet = records.get(entry.recordUid) ?? new Set(); + folderSet.add(folderUid); + records.set(entry.recordUid, folderSet); + } + + return new Map( + [...records.entries()].filter(([, folders]) => folders.size > 1), + ); +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const record = getKeeperDriveRecord(storage, recordUid); + if (!record) return ""; + return getRecordTitle(record).slice(0, 32); +} + +function resolveShortcutRecordUids( + storage: InMemoryStorage, + target: string, + shortcuts: Map>, +): Set { + const trimmed = target.trim(); + if (!trimmed) return new Set(); + + const byUid = shortcuts.get(trimmed); + if (byUid) return new Set([trimmed]); + + const recordUid = resolveNsfRecordIdentifier(storage, trimmed); + if (recordUid && shortcuts.has(recordUid)) { + return new Set([recordUid]); + } + + const lower = trimmed.toLowerCase(); + const titleMatches = [...shortcuts.keys()].filter((uid) => { + const title = recordTitle(storage, uid); + return title.toLowerCase() === lower; + }); + if (titleMatches.length === 1) return new Set(titleMatches); + if (titleMatches.length > 1) { + throw new KeeperSdkError( + `Multiple records matched "${trimmed}". Use a UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES, + ); + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed); + if (folderUid) { + const matches = [...shortcuts.entries()] + .filter(([, folders]) => folders.has(folderUid)) + .map(([recordUid]) => recordUid); + return new Set(matches); + } + + if (recordUid) { + throw new KeeperSdkError( + `Record '${trimmed}' is not linked to multiple folders.`, + SHORTCUT_ERROR, + ); + } + + throw new KeeperSdkError( + `Target '${trimmed}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); +} + +function collectShortcutRows( + storage: InMemoryStorage, + shortcuts: Map>, + target?: string, +): NsfShortcutRow[] { + const recordUids = target?.trim() + ? resolveShortcutRecordUids(storage, target, shortcuts) + : new Set(shortcuts.keys()); + + return [...recordUids] + .sort((a, b) => + recordTitle(storage, a).localeCompare(recordTitle(storage, b)), + ) + .map((recordUid) => ({ + recordUid, + title: recordTitle(storage, recordUid), + folders: [...(shortcuts.get(recordUid) ?? [])] + .sort((a, b) => + formatFolderLabel(storage, a).localeCompare( + formatFolderLabel(storage, b), + ), + ) + .map((folderUid) => formatFolderLabel(storage, folderUid)), + })); +} + +function escapeCsvCell(value: string): string { + if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`; + return value; +} + +export function formatNsfShortcutTable(rows: NsfShortcutRow[]): string { + if (rows.length === 0) return "No multi-folder records found."; + const headers = ["Record UID", "Title", "Folders"]; + const tableRows = rows.map((row) => [ + row.recordUid, + row.title, + row.folders.join("; "), + ]); + const columnWidths = headers.map((header, index) => + Math.max( + header.length, + ...tableRows.map((cells) => (cells[index] || "").length), + ), + ); + const pad = (cell: string, index: number) => + cell + " ".repeat(columnWidths[index] - cell.length); + const formatRow = (cells: string[]) => + cells.map((cell, index) => pad(cell, index)).join(" "); + const rule = columnWidths + .map((width, index) => pad("-".repeat(width), index)) + .join(" "); + return [formatRow(headers), rule, ...tableRows.map(formatRow)].join("\n"); +} + +export function formatNsfShortcutCsv(rows: NsfShortcutRow[]): string { + const lines = ["record_uid,title,folders"]; + for (const row of rows) { + lines.push( + [row.recordUid, row.title, row.folders.join("; ")] + .map(escapeCsvCell) + .join(","), + ); + } + return lines.join("\n"); +} + +export function formatNsfShortcutJson(rows: NsfShortcutRow[]): string { + return JSON.stringify( + rows.map((row) => ({ + record_uid: row.recordUid, + title: row.title, + folders: row.folders, + })), + null, + 2, + ); +} + +export function formatNsfShortcutOutput( + rows: NsfShortcutRow[], + format: ListNsfFormatInput = ListNsfFormat.Table, +): string { + const value = format as ListNsfFormat; + if (value === ListNsfFormat.JSON) return formatNsfShortcutJson(rows); + if (value === ListNsfFormat.CSV) return formatNsfShortcutCsv(rows); + return formatNsfShortcutTable(rows); +} + +export function listNsfShortcuts( + storage: InMemoryStorage, + options: ListNsfShortcutsOptions = {}, +): NsfShortcutRow[] { + const shortcuts = getNsfRecordShortcuts(storage); + return collectShortcutRows(storage, shortcuts, options.target); +} + +function resolveKeepFolderUid( + storage: InMemoryStorage, + folderArg: string | undefined, + defaultFolderUid: string | undefined, +): string { + if (folderArg?.trim()) { + const folderUid = resolveNsfFolderIdentifier(storage, folderArg.trim()); + if (!folderUid) { + throw new KeeperSdkError( + `Folder '${folderArg}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } + if (!isShortcutFolder(storage, folderUid)) { + throw new KeeperSdkError( + `Folder '${folderArg}' is not a nested share folder.`, + ResultCodes.NSF_LEGACY_FOLDER, + ); + } + return folderUid; + } + + if (defaultFolderUid && isNestedShareFolder(storage, defaultFolderUid)) { + return defaultFolderUid; + } + + throw new KeeperSdkError( + "Folder to keep record in is required (or set current folder to an NSF folder).", + ResultCodes.NSF_FOLDER_REQUIRED, + ); +} + +function parseFolderRecordUpdateResult( + response: Folder.IFolderRecordUpdateResponse, + folderUid: string, + recordUid: string, +): KeepNsfShortcutResultItem { + const result = response.folderRecordUpdateResult?.find( + (entry) => + !!entry.recordUid?.length && + webSafe64FromBytes(entry.recordUid) === recordUid, + ); + + if (!result) { + return { + folderUid, + success: false, + message: `No unlink status returned for record ${recordUid}`, + }; + } + + const status = result.status ?? Folder.FolderModifyStatus.SUCCESS; + const statusName = Folder.FolderModifyStatus[status] ?? String(status); + return { + folderUid, + success: status === Folder.FolderModifyStatus.SUCCESS, + message: result.message || statusName, + }; +} + +async function unlinkRecordFromFolder( + auth: Auth, + folderUid: string, + recordUid: string, +): Promise { + const response = await auth.executeRest( + folderRecordUpdateMessage({ + folderUid: normal64Bytes(folderUid), + removeRecords: [{ recordUid: normal64Bytes(recordUid) }], + }), + ); + return parseFolderRecordUpdateResult(response, folderUid, recordUid); +} + +export function formatKeepNsfShortcutPlan( + plan: KeepNsfShortcutPlanItem, +): string { + const lines = [ + "Shortcut keep plan", + ` Record: ${plan.recordUid} ${plan.title || "—"}`, + ` Keep in: ${plan.keepFolderLabel}`, + ]; + if (plan.removeFolderLabels.length === 0) { + lines.push(" Remove from: (none)"); + } else { + lines.push(" Remove from:"); + for (const label of plan.removeFolderLabels) { + lines.push(` ${label}`); + } + } + return lines.join("\n"); +} + +export async function keepNsfShortcut( + storage: InMemoryStorage, + auth: Auth, + input: KeepNsfShortcutInput, + defaultFolderUid?: string, +): Promise { + const recordArg = input.record?.trim(); + if (!recordArg) { + throw new KeeperSdkError( + "Record UID or title is required.", + SHORTCUT_ERROR, + ); + } + + const shortcuts = getNsfRecordShortcuts(storage); + const recordMatches = resolveShortcutRecordUids( + storage, + recordArg, + shortcuts, + ); + if (recordMatches.size !== 1) { + throw new KeeperSdkError( + `Record '${recordArg}' could not be resolved uniquely.`, + recordMatches.size === 0 + ? SHORTCUT_ERROR + : ResultCodes.MULTIPLE_NSF_MATCHES, + ); + } + + const recordUid = [...recordMatches][0]; + ensureNestedShareRecord(storage, recordUid, recordArg); + + const keepFolderUid = resolveKeepFolderUid( + storage, + input.folder, + defaultFolderUid, + ); + const folderSet = shortcuts.get(recordUid); + if (!folderSet || folderSet.size < 2) { + throw new KeeperSdkError( + `Record '${recordArg}' is not linked to multiple folders.`, + SHORTCUT_ERROR, + ); + } + if (!folderSet.has(keepFolderUid)) { + throw new KeeperSdkError( + `Record '${recordArg}' is not in folder '${input.folder ?? keepFolderUid}'.`, + SHORTCUT_ERROR, + ); + } + + const removeFolderUids = [...folderSet].filter( + (folderUid) => folderUid !== keepFolderUid, + ); + const plan: KeepNsfShortcutPlanItem = { + recordUid, + title: recordTitle(storage, recordUid), + keepFolderUid, + keepFolderLabel: formatFolderLabel(storage, keepFolderUid), + removeFolderUids, + removeFolderLabels: removeFolderUids.map((folderUid) => + formatFolderLabel(storage, folderUid), + ), + }; + + if (removeFolderUids.length === 0) { + return { + dryRun: input.dryRun ?? false, + plan, + results: [], + nothingToDo: true, + }; + } + + if (input.dryRun) { + return { dryRun: true, plan, results: [], nothingToDo: false }; + } + + const accountUid = requireAuthAccountUid(auth); + const results: KeepNsfShortcutResultItem[] = []; + + try { + for (const folderUid of removeFolderUids) { + checkFolderRemovePermission( + storage, + folderUid, + recordUid, + auth.username, + accountUid, + ); + results.push(await unlinkRecordFromFolder(auth, folderUid, recordUid)); + } + return { dryRun: false, plan, results, nothingToDo: false }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to keep nested share record shortcut: ${extractErrorMessage(err)}`, + SHORTCUT_ERROR, + ); + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts b/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts new file mode 100644 index 00000000..72f526d1 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts @@ -0,0 +1,253 @@ +import type { Auth } from "@keeper-security/keeperapi"; +import { + Folder, + getShareObjectsMessage, + normal64Bytes, + platform, + teamGetKeysCommand, + webSafe64FromBytes, + type TeamGetKeysResponse, + type Records, +} from "@keeper-security/keeperapi"; +import * as crypto from "crypto"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { + KeeperSdkError, + ResultCodes, + extractErrorMessage, + isValidEmail, + logger, +} from "../utils"; +import { TeamGetKeysResponseKeyType } from "./nsfConstants"; +import type { NsfResolvedShareRecipient, NsfTeamPublicKeys } from "./nsfTypes"; + +const SHARE_ERROR = ResultCodes.NSF_SHARE_FAILED; + +type TeamGetKeyEntry = NonNullable[number]; + +// TODO(browser): Replace Node crypto with platform-safe PKCS#1 public-key derivation +// so team vault-storage fallback works in browser builds. +function deriveRsaPublicKeyFromPkcs1PrivateKey( + privateKeyDer: Uint8Array, +): Uint8Array { + const privateKey = crypto.createPrivateKey({ + key: Buffer.from(privateKeyDer), + format: "der", + type: "pkcs1", + }); + const publicKey = crypto.createPublicKey(privateKey); + return new Uint8Array(publicKey.export({ format: "der", type: "pkcs1" })); +} + +function hasAsymmetricTeamPublicKeys(keys: NsfTeamPublicKeys): boolean { + return Boolean(keys.rsaPublicKey?.length || keys.eccPublicKey?.length); +} + +async function decryptTeamGetKeysEntry( + auth: Auth, + entry: TeamGetKeyEntry, +): Promise> { + if (!entry.key) return {}; + + const keyBytes = normal64Bytes(entry.key); + const partial: Partial = {}; + + switch (entry.type) { + case TeamGetKeysResponseKeyType.EccPublicKey: + partial.eccPublicKey = keyBytes; + break; + case TeamGetKeysResponseKeyType.RsaPublicKey: + partial.rsaPublicKey = keyBytes; + break; + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByDataKey: + if (auth.dataKey?.length) { + partial.aesTeamKey = await platform.aesCbcDecrypt( + keyBytes, + auth.dataKey, + true, + ); + } + break; + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByRsa: + if (auth.privateKey?.length) { + partial.aesTeamKey = platform.privateDecrypt(keyBytes, auth.privateKey); + } + break; + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByDataKeyGcm: + if (auth.dataKey?.length) { + partial.aesTeamKey = await platform.aesGcmDecrypt( + keyBytes, + auth.dataKey, + ); + } + break; + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByEcc: + if (auth.eccPrivateKey?.length) { + partial.aesTeamKey = await platform.privateDecryptEC( + keyBytes, + auth.eccPrivateKey, + ); + } + break; + } + + return partial; +} + +async function parseTeamGetKeysApiResponse( + auth: Auth, + teamUid: string, + response: TeamGetKeysResponse, +): Promise { + const keys: NsfTeamPublicKeys = {}; + + for (const entry of response.keys ?? []) { + if (entry.team_uid && entry.team_uid !== teamUid) continue; + Object.assign(keys, await decryptTeamGetKeysEntry(auth, entry)); + } + + return keys; +} + +async function fetchNsfTeamPublicKeysFromVaultStorage( + storage: InMemoryStorage, + teamUid: string, +): Promise { + const teamPrivateKey = await storage.getKeyBytes(`${teamUid}_priv`); + if (!teamPrivateKey?.length) return {}; + + try { + return { + rsaPublicKey: deriveRsaPublicKeyFromPkcs1PrivateKey(teamPrivateKey), + }; + } catch (err) { + logger.debug( + `Could not derive team RSA public key from vault storage for ${teamUid}: ${extractErrorMessage(err)}`, + ); + return {}; + } +} + +function findMatchingShareTeams( + teams: Records.IShareTeam[], + query: string, +): Records.IShareTeam[] { + const lower = query.toLowerCase(); + return teams.filter((team) => { + const uid = team.teamUid?.length ? webSafe64FromBytes(team.teamUid) : ""; + const name = team.teamname?.trim() ?? ""; + return uid === query || name.toLowerCase() === lower; + }); +} + +export async function fetchNsfTeamPublicKeys( + auth: Auth, + teamUid: string, + storage?: InMemoryStorage, +): Promise { + const trimmed = teamUid.trim(); + if (!trimmed) { + throw new KeeperSdkError( + "Team UID is required.", + ResultCodes.TEAM_NOT_FOUND, + ); + } + + try { + const response = await auth.executeRestCommand( + teamGetKeysCommand({ teams: [trimmed] }), + ); + let keys = await parseTeamGetKeysApiResponse(auth, trimmed, response); + + if (!hasAsymmetricTeamPublicKeys(keys) && storage) { + const storageKeys = await fetchNsfTeamPublicKeysFromVaultStorage( + storage, + trimmed, + ); + keys = { + rsaPublicKey: keys.rsaPublicKey ?? storageKeys.rsaPublicKey, + eccPublicKey: keys.eccPublicKey ?? storageKeys.eccPublicKey, + aesTeamKey: keys.aesTeamKey ?? storageKeys.aesTeamKey, + }; + } + + if (!hasAsymmetricTeamPublicKeys(keys)) { + throw new KeeperSdkError( + `No public key found for team ${trimmed}.`, + ResultCodes.TEAM_NOT_FOUND, + ); + } + + return keys; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to load team keys for ${trimmed}: ${extractErrorMessage(err)}`, + SHARE_ERROR, + ); + } +} + +export async function encryptNsfFolderKeyForTeam( + folderKey: Uint8Array, + teamPublicKeys: NsfTeamPublicKeys, +): Promise { + if (teamPublicKeys.rsaPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: platform.publicEncrypt( + folderKey, + platform.bytesToBase64(teamPublicKeys.rsaPublicKey), + ), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key, + }); + } + if (teamPublicKeys.eccPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: await platform.publicEncryptEC( + folderKey, + teamPublicKeys.eccPublicKey, + ), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key_ecc, + }); + } + throw new KeeperSdkError("No public key found for team.", SHARE_ERROR); +} + +export async function resolveNsfShareRecipient( + auth: Auth, + recipient: string, +): Promise { + const trimmed = recipient.trim(); + if (!trimmed) return null; + + if (isValidEmail(trimmed)) { + return { recipient: trimmed.toLowerCase(), isTeam: false }; + } + + const response = await auth.executeRest(getShareObjectsMessage({})); + const teams = [ + ...(response.shareTeams ?? []), + ...(response.shareMCTeams ?? []), + ]; + const matches = findMatchingShareTeams(teams, trimmed); + + if (matches.length === 1) { + const teamUid = webSafe64FromBytes(matches[0].teamUid!); + return { + recipient: teamUid, + isTeam: true, + accountUid: matches[0].teamUid as Uint8Array, + }; + } + if (matches.length > 1) { + throw new KeeperSdkError( + `Multiple teams match '${trimmed}'. Use the team UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES, + ); + } + + throw new KeeperSdkError( + `Recipient '${trimmed}' could not be resolved as an email or team.`, + SHARE_ERROR, + ); +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts new file mode 100644 index 00000000..e12de992 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts @@ -0,0 +1,157 @@ +import type { Auth } from "@keeper-security/keeperapi"; +import { + Records, + normal64Bytes, + recordsTransferV3Message, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { + encryptKeyForRecipient, + loadUserShareKeysOrInvite, +} from "./nsfShareKeys"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { + checkRecordChangeOwnershipPermission, + ensureNestedShareRecord, + requireAuthAccountUid, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; +import { resolveRecordKeyBytes } from "./nsfRecordCrypto"; +import type { + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, +} from "./nsfTypes"; +import { NsfResultStatus, NsfTransferApiStatus } from "./nsfTypes"; + +const TRANSFER_ERROR = ResultCodes.NSF_TRANSFER_FAILED; + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid); + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); + } + return recordKey; +} + +export async function transferNestedShareRecordOwnership( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + newOwnerEmail: string, +): Promise { + const email = newOwnerEmail.trim(); + if (!email) { + throw new KeeperSdkError("New owner email is required.", TRANSFER_ERROR); + } + + const recordKey = await requireRecordKey(storage, auth, recordUid); + const userKeys = await loadUserShareKeysOrInvite(auth, email, TRANSFER_ERROR); + const { encryptedKey, useEccKey } = await encryptKeyForRecipient( + recordKey, + userKeys, + ); + + const response = await auth.executeRest( + recordsTransferV3Message({ + transferRecords: [ + Records.TransferRecord.create({ + username: email, + recordUid: normal64Bytes(recordUid), + recordKey: encryptedKey, + useEccKey, + }), + ], + }), + ); + + const status = response.transferRecordStatus?.[0]; + const normalized = (status?.status ?? "").trim().toLowerCase(); + const success = normalized === NsfTransferApiStatus.Success; + return { + recordUid, + success, + message: + status?.message || status?.status || "Ownership transfer completed", + }; +} + +export async function transferNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: TransferNestedShareRecordInput, +): Promise { + const records = input.records.map((record) => record.trim()).filter(Boolean); + const newOwnerEmail = input.newOwnerEmail.trim(); + + if (records.length === 0) { + throw new KeeperSdkError( + "At least one record UID or title is required.", + TRANSFER_ERROR, + ); + } + if (!newOwnerEmail) { + throw new KeeperSdkError("New owner email is required.", TRANSFER_ERROR); + } + + const results: TransferNestedShareRecordResultItem[] = []; + const accountUid = requireAuthAccountUid(auth); + + try { + for (const identifier of records) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier); + if (!recordUid) { + throw new KeeperSdkError( + `Record '${identifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } + ensureNestedShareRecord(storage, recordUid, identifier); + checkRecordChangeOwnershipPermission( + storage, + recordUid, + auth.username, + accountUid, + ); + results.push( + await transferNestedShareRecordOwnership( + storage, + auth, + recordUid, + newOwnerEmail, + ), + ); + } + + return { + results, + success: results.every((item) => item.success), + }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to transfer nested share record ownership: ${extractErrorMessage(err)}`, + TRANSFER_ERROR, + ); + } +} + +export function formatTransferNestedShareRecordResults( + results: TransferNestedShareRecordResultItem[], +): string { + if (results.length === 0) return ""; + const lines: string[] = []; + for (const item of results) { + lines.push( + `${item.recordUid} ${item.success ? NsfResultStatus.Success : NsfResultStatus.Failed} ${item.message}`, + ); + } + return lines.join("\n"); +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts index 9a6383d4..790dd3aa 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -1,395 +1,686 @@ -import type { DRecord, Records, record as RecordProto } from '@keeper-security/keeperapi' -import { Folder } from '@keeper-security/keeperapi' -import type { NsfFolderColor } from './nsfConstants' -import type { NsfItemType } from './nsfHelpers' -import type { RecordFieldEntry } from './nsfRecordData' +import type { + DRecord, + Records, + record as RecordProto, +} from "@keeper-security/keeperapi"; +import { Folder } from "@keeper-security/keeperapi"; +import type { NsfFolderColor } from "./nsfConstants"; +import type { NsfItemType } from "./nsfHelpers"; +import type { RecordFieldEntry } from "./nsfRecordData"; export enum NsfAccessRoleLabel { - Owner = 'owner', - Navigator = 'navigator', - Requestor = 'requestor', - Viewer = 'viewer', - SharedManager = 'shared-manager', - ContentManager = 'content-manager', - ContentShareManager = 'content-share-manager', - FullManager = 'full-manager', - Unresolved = 'unresolved', - Unknown = 'unknown', + Owner = "owner", + Navigator = "navigator", + Requestor = "requestor", + Viewer = "viewer", + SharedManager = "shared-manager", + ContentManager = "content-manager", + ContentShareManager = "content-share-manager", + FullManager = "full-manager", + Unresolved = "unresolved", + Unknown = "unknown", } export const NSF_ACCESS_ROLE_LABELS: Record = { - [Folder.AccessRoleType.NAVIGATOR]: NsfAccessRoleLabel.Navigator, - [Folder.AccessRoleType.REQUESTOR]: NsfAccessRoleLabel.Requestor, - [Folder.AccessRoleType.VIEWER]: NsfAccessRoleLabel.Viewer, - [Folder.AccessRoleType.SHARED_MANAGER]: NsfAccessRoleLabel.SharedManager, - [Folder.AccessRoleType.CONTENT_MANAGER]: NsfAccessRoleLabel.ContentManager, - [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: NsfAccessRoleLabel.ContentShareManager, - [Folder.AccessRoleType.MANAGER]: NsfAccessRoleLabel.FullManager, - [Folder.AccessRoleType.UNRESOLVED]: NsfAccessRoleLabel.Unresolved, -} + [Folder.AccessRoleType.NAVIGATOR]: NsfAccessRoleLabel.Navigator, + [Folder.AccessRoleType.REQUESTOR]: NsfAccessRoleLabel.Requestor, + [Folder.AccessRoleType.VIEWER]: NsfAccessRoleLabel.Viewer, + [Folder.AccessRoleType.SHARED_MANAGER]: NsfAccessRoleLabel.SharedManager, + [Folder.AccessRoleType.CONTENT_MANAGER]: NsfAccessRoleLabel.ContentManager, + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: + NsfAccessRoleLabel.ContentShareManager, + [Folder.AccessRoleType.MANAGER]: NsfAccessRoleLabel.FullManager, + [Folder.AccessRoleType.UNRESOLVED]: NsfAccessRoleLabel.Unresolved, +}; export enum NsfObjectKind { - Folder = 'folder', - Record = 'record', + Folder = "folder", + Record = "record", } export enum GetNsfFormat { - Detail = 'detail', - JSON = 'json', + Detail = "detail", + JSON = "json", } -export type GetNsfFormatInput = GetNsfFormat | `${GetNsfFormat}` +export type GetNsfFormatInput = GetNsfFormat | `${GetNsfFormat}`; export type GetNsfOptions = { - format?: GetNsfFormatInput - verbose?: boolean - unmask?: boolean -} + format?: GetNsfFormatInput; + verbose?: boolean; + unmask?: boolean; + includeDag?: boolean; +}; export type NsfFolderAccessRow = { - username: string - role: NsfAccessRoleLabel -} + username: string; + role: NsfAccessRoleLabel; +}; export type NsfFolderPermission = { - accessTypeUid: string - accessType: string - accessRoleType: string - inherited?: boolean - hidden?: boolean -} + accessTypeUid: string; + accessType: string; + accessRoleType: string; + inherited?: boolean; + hidden?: boolean; +}; export type NsfRecordPermission = { - username: string - accountUid?: string - owner: boolean - shareAdmin: boolean - shareable: boolean - editable: boolean - awaitingApproval: boolean - expiration?: number - role?: NsfAccessRoleLabel -} + username: string; + accountUid?: string; + owner: boolean; + shareAdmin: boolean; + shareable: boolean; + editable: boolean; + awaitingApproval: boolean; + expiration?: number; + role?: NsfAccessRoleLabel; +}; export type NsfFolderSummary = { - uid: string - title: string - type: string -} + uid: string; + title: string; + type: string; +}; export type NsfFolderView = { - objectType: NsfObjectKind.Folder - folderUid: string - name: string - parentUid: string - path: string - userPermissions: NsfFolderAccessRow[] - shareAdmins: NsfFolderAccessRow[] - teamPermissions: NsfFolderPermission[] - records: NsfFolderSummary[] -} + objectType: NsfObjectKind.Folder; + folderUid: string; + name: string; + parentUid: string; + path: string; + userPermissions: NsfFolderAccessRow[]; + shareAdmins: NsfFolderAccessRow[]; + teamPermissions: NsfFolderPermission[]; + records: NsfFolderSummary[]; +}; export type NsfRecordFieldView = { - type: string - label?: string - value: string[] -} + type: string; + label?: string; + value: string[]; +}; export type NsfRecordFolderView = { - uid: string - path: string -} + uid: string; + path: string; +}; export type NsfRecordJsonUserPermission = { - username: string - owner: boolean - shareable: boolean - editable: boolean - role: NsfAccessRoleLabel -} + username: string; + owner: boolean; + shareable: boolean; + editable: boolean; + role: NsfAccessRoleLabel; +}; export type NsfRecordJsonView = { - record_uid: string - title: string - type: string - version: number - revision: number - folder?: NsfRecordFolderView - fields: { type: string; value: unknown[] }[] - notes?: string - user_permissions: NsfRecordJsonUserPermission[] - share_admins: string[] -} + record_uid: string; + title: string; + type: string; + version?: number; + revision?: number; + folder?: NsfRecordFolderView; + fields: { type: string; value: unknown[] }[]; + notes?: string; + user_permissions: NsfRecordJsonUserPermission[]; + share_admins: string[]; +}; + +export type NsfFolderJsonView = { + folder_uid: string; + type: "nested_share_folder"; + name: string; + parent_uid: string; + path?: string; + records?: NsfFolderSummary[]; + user_permissions?: { username: string; role: NsfAccessRoleLabel }[]; + share_admins?: { username: string; role: NsfAccessRoleLabel }[]; + team_permissions?: NsfFolderPermission[]; +}; export type NsfRecordView = { - objectType: NsfObjectKind.Record - recordUid: string - title: string - type: string - revision: number - version: number - folder?: NsfRecordFolderView - folderLocation: string - login?: string - password?: string - url?: string - notes?: string - fields: NsfRecordFieldView[] - userPermissions: NsfRecordPermission[] - shareAdmins: string[] -} + objectType: NsfObjectKind.Record; + recordUid: string; + title: string; + type: string; + revision: number; + version: number; + folder?: NsfRecordFolderView; + folderLocation: string; + login?: string; + password?: string; + url?: string; + notes?: string; + fields: NsfRecordFieldView[]; + userPermissions: NsfRecordPermission[]; + shareAdmins: string[]; +}; export type GetNsfResult = - | { kind: NsfObjectKind.Folder; view: NsfFolderView } - | { kind: NsfObjectKind.Record; view: NsfRecordView } + | { kind: NsfObjectKind.Folder; view: NsfFolderView } + | { kind: NsfObjectKind.Record; view: NsfRecordView }; -export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` +export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}`; export type MkdirNsfInput = { - folder: string - color?: NsfFolderColorInput - noInheritPermissions?: boolean - baseFolderUid?: string | null -} + folder: string; + color?: NsfFolderColorInput; + noInheritPermissions?: boolean; + baseFolderUid?: string | null; +}; export type MkdirNsfResult = { - folderUid: string - created: boolean - message?: string -} + folderUid: string; + created: boolean; + message?: string; +}; export type FolderSegmentCreateSpec = { - segmentName: string - color?: NsfFolderColor - inheritPermissions: boolean -} + segmentName: string; + color?: NsfFolderColor; + inheritPermissions: boolean; +}; export type FolderCreatePayload = { - folderUid: string - folderName: string - parentUid: string | null - inheritPermissions: boolean - folderData: Folder.IFolderData -} + folderUid: string; + folderName: string; + parentUid: string | null; + inheritPermissions: boolean; + folderData: Folder.IFolderData; +}; export type AddNsfRecordInput = { - title: string - recordType: string - folder?: string - notes?: string - fieldEntries?: RecordFieldEntry[] - customEntries?: RecordFieldEntry[] - recordData?: Record - force?: boolean - hasFileFields?: boolean -} + title: string; + recordType: string; + folder?: string; + notes?: string; + fieldEntries?: RecordFieldEntry[]; + customEntries?: RecordFieldEntry[]; + recordData?: Record; + force?: boolean; + hasFileFields?: boolean; +}; export type AddNsfRecordsInput = { - records: AddNsfRecordInput[] -} + records: AddNsfRecordInput[]; +}; export type NsfRecordOperationResult = { - recordUid: string - success: boolean - status: string - message?: string - revision?: number -} + recordUid: string; + success: boolean; + status: string; + message?: string; + revision?: number; +}; -export type AddNsfRecordResult = NsfRecordOperationResult +export type AddNsfRecordResult = NsfRecordOperationResult; export type AddNsfRecordsResult = { - added: AddNsfRecordResult[] - revision?: number -} + added: AddNsfRecordResult[]; + revision?: number; +}; export type RecordAddPayload = { - recordUid: string - recordAdd: RecordProto.v3.IRecordAdd -} + recordUid: string; + recordAdd: RecordProto.v3.IRecordAdd; +}; export type UpdateNsfRecordInput = { - records: string[] - title?: string - recordType?: string - notes?: string - fieldEntries?: RecordFieldEntry[] - customEntries?: RecordFieldEntry[] -} + records: string[]; + title?: string; + recordType?: string; + notes?: string; + fieldEntries?: RecordFieldEntry[]; + customEntries?: RecordFieldEntry[]; +}; export type UpdateNsfRecordItemInput = { - record: string - title?: string - recordType?: string - notes?: string - fieldEntries?: RecordFieldEntry[] - customEntries?: RecordFieldEntry[] -} + record: string; + title?: string; + recordType?: string; + notes?: string; + fieldEntries?: RecordFieldEntry[]; + customEntries?: RecordFieldEntry[]; +}; export type UpdateNsfRecordsInput = { - records: UpdateNsfRecordItemInput[] -} + records: UpdateNsfRecordItemInput[]; +}; -export type UpdateNsfRecordResultItem = NsfRecordOperationResult +export type UpdateNsfRecordResultItem = NsfRecordOperationResult; export type UpdateNsfRecordResult = { - updated: UpdateNsfRecordResultItem[] -} + updated: UpdateNsfRecordResultItem[]; +}; export type RecordUpdatePayload = { - recordUid: string - storedRecord: DRecord | undefined - mergedRecordData: Record - recordUpdate: Records.IRecordUpdate -} + recordUid: string; + storedRecord: DRecord | undefined; + mergedRecordData: Record; + recordUpdate: Records.IRecordUpdate; +}; export enum ListNsfFormat { - Table = 'table', - CSV = 'csv', - JSON = 'json', + Table = "table", + CSV = "csv", + JSON = "json", } -export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}` +export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}`; export type ListNsfOptions = { - folders?: boolean - records?: boolean - format?: ListNsfFormatInput -} + folders?: boolean; + records?: boolean; + format?: ListNsfFormatInput; + roeEligible?: boolean; +}; export type ListNsfRow = { - itemType: NsfItemType - uid: string - title: string - type: string - description: string - parentOrFolder: string -} + itemType: NsfItemType; + uid: string; + title: string; + type: string; + description: string; + parentOrFolder: string; +}; export type FormattedListNsfTable = { - headers: string[] - rows: string[][] -} + headers: string[]; + rows: string[][]; +}; export enum NsfRemoveOperation { - OwnerTrash = 'owner-trash', - FolderTrash = 'folder-trash', - Unlink = 'unlink', + OwnerTrash = "owner-trash", + FolderTrash = "folder-trash", + Unlink = "unlink", } -export type NsfRemoveOperationInput = NsfRemoveOperation | `${NsfRemoveOperation}` +export type NsfRemoveOperationInput = + | NsfRemoveOperation + | `${NsfRemoveOperation}`; export type RemoveNsfRecordInput = { - records: string[] - folder?: string - operation?: NsfRemoveOperationInput - force?: boolean - dryRun?: boolean -} + records: string[]; + folder?: string; + operation?: NsfRemoveOperationInput; + force?: boolean; + dryRun?: boolean; +}; export type NsfRemovePreviewItem = { - recordUid: string - folderUid: string - status: string - impact?: { - foldersCount: number - recordsCount: number - affectedUsersCount: number - affectedTeamsCount: number - warnings: string[] - } - error?: { code: number; message: string } -} + recordUid: string; + folderUid: string; + status: string; + impact?: { + foldersCount: number; + recordsCount: number; + affectedUsersCount: number; + affectedTeamsCount: number; + warnings: string[]; + }; + error?: { code: number; message: string }; +}; export type RemoveNsfRecordResult = { - confirmed: boolean - dryRun: boolean - preview: NsfRemovePreviewItem[] - message?: string -} + confirmed: boolean; + dryRun: boolean; + preview: NsfRemovePreviewItem[]; + message?: string; +}; export enum NsfRemoveFolderOperation { - FolderTrash = 'folder-trash', - DeletePermanent = 'delete-permanent', + FolderTrash = "folder-trash", + DeletePermanent = "delete-permanent", } -export type NsfRemoveFolderOperationInput = NsfRemoveFolderOperation | `${NsfRemoveFolderOperation}` +export type NsfRemoveFolderOperationInput = + | NsfRemoveFolderOperation + | `${NsfRemoveFolderOperation}`; export type RemoveNsfFolderInput = { - folders: string[] - operation?: NsfRemoveFolderOperationInput - force?: boolean - dryRun?: boolean - quiet?: boolean -} + folders: string[]; + operation?: NsfRemoveFolderOperationInput; + force?: boolean; + dryRun?: boolean; + quiet?: boolean; +}; export type NsfRemoveFolderPreviewItem = { - folderUid: string - name: string - status: string - impact?: { - foldersCount: number - recordsCount: number - affectedUsersCount: number - affectedTeamsCount: number - warnings: string[] - } - error?: { code: number; message: string } -} + folderUid: string; + name: string; + status: string; + impact?: { + foldersCount: number; + recordsCount: number; + affectedUsersCount: number; + affectedTeamsCount: number; + warnings: string[]; + }; + error?: { code: number; message: string }; +}; export type RemoveNsfFolderResult = { - confirmed: boolean - dryRun: boolean - operation: NsfRemoveFolderOperation - preview: NsfRemoveFolderPreviewItem[] - message?: string -} + confirmed: boolean; + dryRun: boolean; + operation: NsfRemoveFolderOperation; + preview: NsfRemoveFolderPreviewItem[]; + message?: string; +}; export enum GetNsfRecordDetailsFormat { - Table = 'table', - JSON = 'json', + Table = "table", + JSON = "json", } -export type GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat | `${GetNsfRecordDetailsFormat}` +export type GetNsfRecordDetailsFormatInput = + | GetNsfRecordDetailsFormat + | `${GetNsfRecordDetailsFormat}`; export type NsfRecordDetailsItem = { - recordUid: string - title: string - type: string - revision: number - version: number -} + recordUid: string; + title: string; + type: string; + revision: number; + version: number; +}; export type GetNsfRecordDetailsResult = { - data: NsfRecordDetailsItem[] - forbiddenRecords: string[] -} + data: NsfRecordDetailsItem[]; + forbiddenRecords: string[]; +}; export type GetNsfRecordDetailsInput = { - records: string[] - format?: GetNsfRecordDetailsFormatInput -} + records: string[]; + format?: GetNsfRecordDetailsFormatInput; +}; export type LinkNsfRecordResult = { - success: boolean - recordUid: string - folderUid: string - status: string - message: string -} + success: boolean; + recordUid: string; + folderUid: string; + status: string; + message: string; +}; -const ACCESS_ROLE_LABEL_VALUES = new Set(Object.values(NsfAccessRoleLabel)) +const ACCESS_ROLE_LABEL_VALUES = new Set( + Object.values(NsfAccessRoleLabel), +); export function toNsfAccessRoleLabel(role: string): NsfAccessRoleLabel { - if (ACCESS_ROLE_LABEL_VALUES.has(role)) { - return role as NsfAccessRoleLabel - } - return NsfAccessRoleLabel.Unknown + if (ACCESS_ROLE_LABEL_VALUES.has(role)) { + return role as NsfAccessRoleLabel; + } + return NsfAccessRoleLabel.Unknown; } -export function resolveRecordPermissionRole(entry: NsfRecordPermission): NsfAccessRoleLabel { - if (entry.owner) return NsfAccessRoleLabel.Owner - if (entry.shareAdmin) return NsfAccessRoleLabel.SharedManager - if (entry.role) return entry.role - return NsfAccessRoleLabel.ContentManager -} +export function resolveRecordPermissionRole( + entry: NsfRecordPermission, +): NsfAccessRoleLabel { + if (entry.owner) return NsfAccessRoleLabel.Owner; + if (entry.shareAdmin) return NsfAccessRoleLabel.SharedManager; + if (entry.role) return entry.role; + return NsfAccessRoleLabel.ContentManager; +} + +export enum NsfFolderShareAction { + Grant = "grant", + Remove = "remove", +} + +export enum NsfFolderShareActionTaken { + AlreadyHadAccess = "already_had_access", + Updated = "updated", + Granted = "granted", + Removed = "removed", +} + +export enum NsfRecordShareAction { + Grant = "grant", + Revoke = "revoke", + Owner = "owner", +} + +export enum NsfRecordShareActionTaken { + Grant = "grant", + Update = "update", + Revoke = "revoke", + Owner = "owner", + NoAccess = "no_access", + Skipped = "skipped", +} + +export enum NsfRecordPermissionAction { + Grant = "grant", + Revoke = "revoke", +} + +export enum NsfResultStatus { + Success = "success", + Failed = "failed", +} + +export enum NsfTransferApiStatus { + Success = "transfer_record_success", +} + +export enum NsfPermissionFailureCode { + Skipped = "skipped", +} + +export type NsfTeamPublicKeys = { + rsaPublicKey?: Uint8Array; + eccPublicKey?: Uint8Array; + aesTeamKey?: Uint8Array; +}; + +export type NsfResolvedShareRecipient = { + recipient: string; + isTeam: boolean; + accountUid?: Uint8Array; +}; + +export type NsfFolderAccessOperationResult = { + folderUid: string; + recipient: string; + success: boolean; + message: string; +}; + +export type NsfDirectUserRecordShare = { + recordUid: string; + email: string; + accessRoleType: number; + expiration?: number; +}; + +export type NsfFolderShareActionInput = + | NsfFolderShareAction + | `${NsfFolderShareAction}`; + +export type ParseShareExpirationInput = { + expireAt?: string; + expireIn?: string; + expirationTimestamp?: number; + cmdName?: string; +}; + +export type ShareNestedShareFolderInput = { + folders: string[]; + recipients: string[]; + action?: NsfFolderShareActionInput; + role?: string; + expireAt?: string; + expireIn?: string; + expirationTimestamp?: number; +}; + +export type NsfFolderShareResultItem = { + folderUid: string; + recipient: string; + isTeam: boolean; + success: boolean; + actionTaken: NsfFolderShareActionTaken; + message?: string; +}; + +export type ShareNestedShareFolderResult = { + results: NsfFolderShareResultItem[]; +}; + +export type NsfRecordShareActionInput = + | NsfRecordShareAction + | `${NsfRecordShareAction}`; + +export type ShareNestedShareRecordInput = { + record: string; + emails: string[]; + action?: NsfRecordShareActionInput; + role?: string; + recursive?: boolean; + dryRun?: boolean; + /** Absolute expiration (ISO datetime or `never`). Mutually exclusive with expireIn. */ + expireAt?: string; + /** Relative expiration (e.g. `30d`, `6mo`, `1y`, `24h`, `30mi`, or `never`). Mutually exclusive with expireAt. */ + expireIn?: string; + /** Pre-parsed expiration in milliseconds; `-1` = never. Mutually exclusive with expireAt/expireIn. */ + expirationTimestamp?: number; +}; + +export type NsfRecordSharePlanItem = { + recordUid: string; + title: string; + email: string; + action: NsfRecordShareAction; + role?: string; + expirationTimestamp?: number; +}; + +export type NsfRecordShareResultItem = { + recordUid: string; + email: string; + success: boolean; + actionTaken: NsfRecordShareActionTaken; + message?: string; +}; + +export type ShareNestedShareRecordResult = { + dryRun: boolean; + plan: NsfRecordSharePlanItem[]; + results: NsfRecordShareResultItem[]; +}; + +export type NsfRecordPermissionActionInput = + | NsfRecordPermissionAction + | `${NsfRecordPermissionAction}`; + +export type UpdateNsfRecordPermissionInput = { + folder?: string; + action: NsfRecordPermissionActionInput; + role?: string; + recursive?: boolean; + dryRun?: boolean; + force?: boolean; +}; + +export type NsfRecordPermissionPlanItem = { + recordUid: string; + title: string; + email: string; + curRole: string; + newRole?: string; + reason?: string; +}; + +export type NsfRecordPermissionPlan = { + grants: NsfRecordPermissionPlanItem[]; + revokes: NsfRecordPermissionPlanItem[]; + skipped: NsfRecordPermissionPlanItem[]; +}; + +export type NsfRecordPermissionFailure = { + recordUid: string; + email: string; + code: string; + message: string; +}; + +export type UpdateNsfRecordPermissionResult = { + confirmed: boolean; + dryRun: boolean; + folderDisplayName: string; + plan: NsfRecordPermissionPlan; + grantFailures: NsfRecordPermissionFailure[]; + revokeFailures: NsfRecordPermissionFailure[]; + message?: string; +}; + +export type NsfShortcutRow = { + recordUid: string; + title: string; + folders: string[]; +}; + +export type ListNsfShortcutsOptions = { + target?: string; + format?: ListNsfFormatInput; +}; + +export type KeepNsfShortcutInput = { + record: string; + folder?: string; + dryRun?: boolean; +}; + +export type KeepNsfShortcutPlanItem = { + recordUid: string; + title: string; + keepFolderUid: string; + keepFolderLabel: string; + removeFolderUids: string[]; + removeFolderLabels: string[]; +}; + +export type KeepNsfShortcutResultItem = { + folderUid: string; + success: boolean; + message: string; +}; + +export type KeepNsfShortcutResult = { + dryRun: boolean; + plan: KeepNsfShortcutPlanItem; + results: KeepNsfShortcutResultItem[]; + nothingToDo: boolean; +}; + +export type TransferNestedShareRecordInput = { + records: string[]; + newOwnerEmail: string; +}; + +export type TransferNestedShareRecordResultItem = { + recordUid: string; + success: boolean; + message: string; +}; + +export type TransferNestedShareRecordResult = { + results: TransferNestedShareRecordResultItem[]; + success: boolean; +}; + +export type UpdateNsfFolderInput = { + folder: string; + name?: string; + color?: NsfFolderColorInput; + /** When set, updates whether child folders inherit this folder's user permissions. */ + inheritPermissions?: boolean; + quiet?: boolean; +}; + +export type UpdateNsfFolderResult = { + folderUid: string; + updated: boolean; + message?: string; +}; diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts index 6e34567d..fc3b2352 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -1,231 +1,274 @@ -import type { Auth, folder as FolderProto } from '@keeper-security/keeperapi' -import { folder, normal64Bytes, removeFolderMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { NSF_MAX_FOLDER_REMOVALS } from './nsfConstants' +import type { Auth, folder as FolderProto } from "@keeper-security/keeperapi"; import { - checkFolderDeletePermission, - ensureNestedShareFolder, - getFolderDisplayName, - requireAuthAccountUid, - resolveNsfFolderIdentifier, -} from './nsfHelpers' + folder, + normal64Bytes, + removeFolderMessage, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { NSF_MAX_FOLDER_REMOVALS } from "./nsfConstants"; +import { + checkFolderDeletePermission, + ensureNestedShareFolder, + getFolderDisplayName, + requireAuthAccountUid, + resolveNsfFolderIdentifier, +} from "./nsfHelpers"; import { - NsfRemoveFolderOperation, - type NsfRemoveFolderOperationInput, - type NsfRemoveFolderPreviewItem, - type RemoveNsfFolderInput, - type RemoveNsfFolderResult, -} from './nsfTypes' - -const { RemoveAction, FolderOperationType, RemoveStatus } = folder.v3.remove -const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] -const TRASH_ACTION_LABEL = 'moved to trash' -const PERMANENT_DELETE_ACTION_LABEL = 'permanently deleted' - -const OPERATION_MAP: Record = { - [NsfRemoveFolderOperation.FolderTrash]: FolderOperationType.FOLDER_MOVE_TO_FOLDER_TRASH, - [NsfRemoveFolderOperation.DeletePermanent]: FolderOperationType.FOLDER_DELETE_PERMANENT, -} + NsfRemoveFolderOperation, + type NsfRemoveFolderOperationInput, + type NsfRemoveFolderPreviewItem, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from "./nsfTypes"; + +const { RemoveAction, FolderOperationType, RemoveStatus } = folder.v3.remove; +const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS]; +const TRASH_ACTION_LABEL = "moved to trash"; +const PERMANENT_DELETE_ACTION_LABEL = "permanently deleted"; + +const OPERATION_MAP: Record< + NsfRemoveFolderOperation, + FolderProto.v3.remove.FolderOperationType +> = { + [NsfRemoveFolderOperation.FolderTrash]: + FolderOperationType.FOLDER_MOVE_TO_FOLDER_TRASH, + [NsfRemoveFolderOperation.DeletePermanent]: + FolderOperationType.FOLDER_DELETE_PERMANENT, +}; function normalizeOperation( - operation: NsfRemoveFolderOperationInput = NsfRemoveFolderOperation.FolderTrash + operation: NsfRemoveFolderOperationInput = NsfRemoveFolderOperation.FolderTrash, ): NsfRemoveFolderOperation { - const value = operation as NsfRemoveFolderOperation - if (value in OPERATION_MAP) return value - throw new KeeperSdkError( - `Invalid operation '${operation}'. Use: folder-trash, delete-permanent.`, - ResultCodes.NSF_REMOVE_FAILED - ) + const value = operation as NsfRemoveFolderOperation; + if (value in OPERATION_MAP) return value; + throw new KeeperSdkError( + `Invalid operation '${operation}'. Use: folder-trash, delete-permanent.`, + ResultCodes.NSF_REMOVE_FAILED, + ); } type RemovalSpec = { - folderUid: string - name: string - operation: FolderProto.v3.remove.FolderOperationType -} + folderUid: string; + name: string; + operation: FolderProto.v3.remove.FolderOperationType; +}; function buildRemovals( - storage: InMemoryStorage, - auth: Auth, - folderIdentifiers: string[], - operation: NsfRemoveFolderOperation + storage: InMemoryStorage, + auth: Auth, + folderIdentifiers: string[], + operation: NsfRemoveFolderOperation, ): RemovalSpec[] { - if (folderIdentifiers.length === 0) { - throw new KeeperSdkError('Enter the name or UID of at least one folder.', ResultCodes.NSF_NOT_FOUND) - } - if (folderIdentifiers.length > NSF_MAX_FOLDER_REMOVALS) { - throw new KeeperSdkError( - `Maximum ${NSF_MAX_FOLDER_REMOVALS} folders per request.`, - ResultCodes.NSF_TOO_MANY_FOLDERS - ) - } + if (folderIdentifiers.length === 0) { + throw new KeeperSdkError( + "Enter the name or UID of at least one folder.", + ResultCodes.NSF_NOT_FOUND, + ); + } + if (folderIdentifiers.length > NSF_MAX_FOLDER_REMOVALS) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_FOLDER_REMOVALS} folders per request.`, + ResultCodes.NSF_TOO_MANY_FOLDERS, + ); + } - const accountUid = requireAuthAccountUid(auth) + const accountUid = requireAuthAccountUid(auth); - const removals: RemovalSpec[] = [] - for (const identifier of folderIdentifiers) { - const folderUid = resolveNsfFolderIdentifier(storage, identifier) - if (!folderUid) { - throw new KeeperSdkError(`Folder '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) - } - ensureNestedShareFolder(storage, folderUid, identifier) - checkFolderDeletePermission(storage, folderUid, auth.username, accountUid) - removals.push({ - folderUid, - name: getFolderDisplayName(storage, folderUid), - operation: OPERATION_MAP[operation], - }) + const removals: RemovalSpec[] = []; + for (const identifier of folderIdentifiers) { + const folderUid = resolveNsfFolderIdentifier(storage, identifier); + if (!folderUid) { + throw new KeeperSdkError( + `Folder '${identifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); } - return removals + ensureNestedShareFolder(storage, folderUid, identifier); + checkFolderDeletePermission(storage, folderUid, auth.username, accountUid); + removals.push({ + folderUid, + name: getFolderDisplayName(storage, folderUid), + operation: OPERATION_MAP[operation], + }); + } + return removals; } -function toRemovalInput(spec: RemovalSpec): FolderProto.v3.remove.IFolderRemoval { - return { - folderUid: normal64Bytes(spec.folderUid), - operationType: spec.operation, - } +function toRemovalInput( + spec: RemovalSpec, +): FolderProto.v3.remove.IFolderRemoval { + return { + folderUid: normal64Bytes(spec.folderUid), + operationType: spec.operation, + }; } async function executeRemove( - auth: Auth, - removals: RemovalSpec[], - action: FolderProto.v3.remove.RemoveAction, - confirmationToken?: Uint8Array + auth: Auth, + removals: RemovalSpec[], + action: FolderProto.v3.remove.RemoveAction, + confirmationToken?: Uint8Array, ): Promise { - return auth.executeRest( - removeFolderMessage({ - action, - folders: removals.map(toRemovalInput), - confirmationToken, - }) - ) + return auth.executeRest( + removeFolderMessage({ + action, + folders: removals.map(toRemovalInput), + confirmationToken, + }), + ); } -function mapPreviewItem(spec: RemovalSpec, item: FolderProto.v3.remove.IRemoveResult): NsfRemoveFolderPreviewItem { - return { - folderUid: item.itemUid?.length ? webSafe64FromBytes(item.itemUid) : spec.folderUid, - name: spec.name, - status: item.status == null ? 'REMOVE_STATUS_UNKNOWN' : (RemoveStatus[item.status] ?? String(item.status)), - impact: item.impact - ? { - foldersCount: item.impact.foldersCount ?? 0, - recordsCount: item.impact.recordsCount ?? 0, - affectedUsersCount: item.impact.affectedUsersCount ?? 0, - affectedTeamsCount: item.impact.affectedTeamsCount ?? 0, - warnings: [...(item.impact.warnings ?? [])], - } - : undefined, - error: item.error - ? { - code: item.error.code ?? 0, - message: item.error.message ?? '', - } - : undefined, - } +function mapPreviewItem( + spec: RemovalSpec, + item: FolderProto.v3.remove.IRemoveResult, +): NsfRemoveFolderPreviewItem { + return { + folderUid: item.itemUid?.length + ? webSafe64FromBytes(item.itemUid) + : spec.folderUid, + name: spec.name, + status: + item.status == null + ? "REMOVE_STATUS_UNKNOWN" + : (RemoveStatus[item.status] ?? String(item.status)), + impact: item.impact + ? { + foldersCount: item.impact.foldersCount ?? 0, + recordsCount: item.impact.recordsCount ?? 0, + affectedUsersCount: item.impact.affectedUsersCount ?? 0, + affectedTeamsCount: item.impact.affectedTeamsCount ?? 0, + warnings: [...(item.impact.warnings ?? [])], + } + : undefined, + error: item.error + ? { + code: item.error.code ?? 0, + message: item.error.message ?? "", + } + : undefined, + }; } function mapPreview( - removals: RemovalSpec[], - response: FolderProto.v3.remove.IRemoveResponse + removals: RemovalSpec[], + response: FolderProto.v3.remove.IRemoveResponse, ): NsfRemoveFolderPreviewItem[] { - return (response.results ?? []).map((item, index) => { - const spec = removals[index] - if (!spec) { - throw new KeeperSdkError('Folder removal preview mismatch.', ResultCodes.NSF_REMOVE_FAILED) - } - return mapPreviewItem(spec, item) - }) + return (response.results ?? []).map((item, index) => { + const spec = removals[index]; + if (!spec) { + throw new KeeperSdkError( + "Folder removal preview mismatch.", + ResultCodes.NSF_REMOVE_FAILED, + ); + } + return mapPreviewItem(spec, item); + }); } function hasPreviewErrors(preview: NsfRemoveFolderPreviewItem[]): boolean { - return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) + return preview.some( + (item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS, + ); } export function formatRemoveNsfFolderPreview( - preview: NsfRemoveFolderPreviewItem[], - operation: NsfRemoveFolderOperation, - quiet = false + preview: NsfRemoveFolderPreviewItem[], + operation: NsfRemoveFolderOperation, + quiet = false, ): string { - const action = - operation === NsfRemoveFolderOperation.DeletePermanent - ? PERMANENT_DELETE_ACTION_LABEL - : TRASH_ACTION_LABEL - const lines: string[] = [] - - for (const item of preview) { - lines.push(`\nThe following folder will be ${action}:`) - lines.push(` ${item.name} [${item.folderUid}]`) - if (item.impact && !quiet) { - const parts = [ - `sub-folders=${item.impact.foldersCount}`, - `records=${item.impact.recordsCount}`, - `users=${item.impact.affectedUsersCount}`, - `teams=${item.impact.affectedTeamsCount}`, - ] - lines.push(` Impact: ${parts.join(', ')}`) - for (const warning of item.impact.warnings) { - lines.push(` Warning: ${warning}`) - } - } - if (item.error?.message) { - lines.push(` Error: ${item.error.message}`) - } + const action = + operation === NsfRemoveFolderOperation.DeletePermanent + ? PERMANENT_DELETE_ACTION_LABEL + : TRASH_ACTION_LABEL; + const lines: string[] = []; + + for (const item of preview) { + lines.push(`\nThe following folder will be ${action}:`); + lines.push(` ${item.name} [${item.folderUid}]`); + if (item.impact && !quiet) { + const parts = [ + `sub-folders=${item.impact.foldersCount}`, + `records=${item.impact.recordsCount}`, + `users=${item.impact.affectedUsersCount}`, + `teams=${item.impact.affectedTeamsCount}`, + ]; + lines.push(` Impact: ${parts.join(", ")}`); + for (const warning of item.impact.warnings) { + lines.push(` Warning: ${warning}`); + } + } + if (item.error?.message) { + lines.push(` Error: ${item.error.message}`); } + } - return lines.join('\n').trimEnd() + return lines.join("\n").trimEnd(); } export async function removeNestedShareFolders( - storage: InMemoryStorage, - auth: Auth, - input: RemoveNsfFolderInput + storage: InMemoryStorage, + auth: Auth, + input: RemoveNsfFolderInput, ): Promise { - const operation = normalizeOperation(input.operation) - const dryRun = input.dryRun ?? false - const removals = buildRemovals(storage, auth, input.folders, operation) - - try { - const previewResponse = await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_PREVIEW) - const preview = mapPreview(removals, previewResponse) - - if (hasPreviewErrors(preview)) { - throw new KeeperSdkError( - formatRemoveNsfFolderPreview(preview, operation, input.quiet) || 'Folder removal preview failed.', - ResultCodes.NSF_REMOVE_FAILED - ) - } + const operation = normalizeOperation(input.operation); + const dryRun = input.dryRun ?? false; + const removals = buildRemovals(storage, auth, input.folders, operation); - if (dryRun || !previewResponse.confirmationToken?.length) { - return { confirmed: false, dryRun, operation, preview } - } + try { + const previewResponse = await executeRemove( + auth, + removals, + RemoveAction.REMOVE_ACTION_PREVIEW, + ); + const preview = mapPreview(removals, previewResponse); - if (!input.force) { - return { - confirmed: false, - dryRun: false, - operation, - preview, - message: 'Confirmation required. Set force=true to proceed.', - } - } + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError( + formatRemoveNsfFolderPreview(preview, operation, input.quiet) || + "Folder removal preview failed.", + ResultCodes.NSF_REMOVE_FAILED, + ); + } - await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_CONFIRM, previewResponse.confirmationToken) - const actionLabel = - operation === NsfRemoveFolderOperation.DeletePermanent ? 'Permanently deleted' : 'Moved to trash' - return { - confirmed: true, - dryRun: false, - operation, - preview, - message: `${actionLabel} ${removals.length} folder(s).`, - } - } catch (err) { - if (err instanceof KeeperSdkError) throw err - throw new KeeperSdkError( - `Failed to remove nested share folder(s): ${extractErrorMessage(err)}`, - ResultCodes.NSF_REMOVE_FAILED - ) + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, operation, preview }; + } + + if (!input.force) { + return { + confirmed: false, + dryRun: false, + operation, + preview, + message: "Confirmation required. Set force=true to proceed.", + }; } + + await executeRemove( + auth, + removals, + RemoveAction.REMOVE_ACTION_CONFIRM, + previewResponse.confirmationToken, + ); + const actionLabel = + operation === NsfRemoveFolderOperation.DeletePermanent + ? "Permanently deleted" + : "Moved to trash"; + return { + confirmed: true, + dryRun: false, + operation, + preview, + message: `${actionLabel} ${removals.length} folder(s).`, + }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to remove nested share folder(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_REMOVE_FAILED, + ); + } } diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts index 2afae04e..3c9210f1 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -1,234 +1,342 @@ -import type { Auth, folder as FolderProto } from '@keeper-security/keeperapi' -import { folder, normal64Bytes, removeRecordMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import type { Auth, folder as FolderProto } from "@keeper-security/keeperapi"; import { - checkFolderRemovePermission, - checkRecordDeletePermission, - ensureNestedShareRecord, - findNestedShareFoldersForRecord, - isRootFolderUid, - resolveNsfFolderIdentifier, - resolveNsfRecordIdentifier, -} from './nsfHelpers' -import { NSF_MAX_RECORD_BATCH } from './nsfConstants' + folder, + normal64Bytes, + removeRecordMessage, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; import { - NsfRemoveOperation, - type NsfRemoveOperationInput, - type NsfRemovePreviewItem, - type RemoveNsfRecordInput, - type RemoveNsfRecordResult, -} from './nsfTypes' - -const { RemoveAction, RecordOperationType, RemoveStatus } = folder.v3.remove -const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] - -const OPERATION_MAP: Record = { - [NsfRemoveOperation.Unlink]: RecordOperationType.UNLINK_FROM_FOLDER, - [NsfRemoveOperation.FolderTrash]: RecordOperationType.MOVE_TO_FOLDER_TRASH, - [NsfRemoveOperation.OwnerTrash]: RecordOperationType.MOVE_TO_OWNER_TRASH, + checkFolderRemovePermission, + checkRecordDeletePermission, + ensureNestedShareRecord, + findNestedShareFoldersForRecord, + isRootFolderUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; +import { NSF_MAX_RECORD_BATCH } from "./nsfConstants"; + +const { RemoveAction, RecordOperationType, RemoveStatus } = folder.v3.remove; +const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS]; + +export enum NsfRemoveOperation { + OwnerTrash = "owner-trash", + FolderTrash = "folder-trash", + Unlink = "unlink", } -function normalizeOperation(operation: NsfRemoveOperationInput = NsfRemoveOperation.OwnerTrash): NsfRemoveOperation { - const value = operation as NsfRemoveOperation - if (value in OPERATION_MAP) return value - throw new KeeperSdkError( - `Invalid operation '${operation}'. Use: owner-trash, folder-trash, unlink.`, - ResultCodes.NSF_REMOVE_FAILED - ) +export type NsfRemoveOperationInput = + | NsfRemoveOperation + | `${NsfRemoveOperation}`; + +export type RemoveNsfRecordInput = { + records: string[]; + folder?: string; + operation?: NsfRemoveOperationInput; + force?: boolean; + dryRun?: boolean; +}; + +export type NsfRemovePreviewItem = { + recordUid: string; + folderUid: string; + status: string; + impact?: { + foldersCount: number; + recordsCount: number; + affectedUsersCount: number; + affectedTeamsCount: number; + warnings: string[]; + }; + error?: { code: number; message: string }; +}; + +export type RemoveNsfRecordResult = { + confirmed: boolean; + dryRun: boolean; + preview: NsfRemovePreviewItem[]; + message?: string; +}; + +const OPERATION_MAP: Record< + NsfRemoveOperation, + FolderProto.v3.remove.RecordOperationType +> = { + [NsfRemoveOperation.Unlink]: RecordOperationType.UNLINK_FROM_FOLDER, + [NsfRemoveOperation.FolderTrash]: RecordOperationType.MOVE_TO_FOLDER_TRASH, + [NsfRemoveOperation.OwnerTrash]: RecordOperationType.MOVE_TO_OWNER_TRASH, +}; + +function normalizeOperation( + operation: NsfRemoveOperationInput = NsfRemoveOperation.OwnerTrash, +): NsfRemoveOperation { + const value = operation as NsfRemoveOperation; + if (value in OPERATION_MAP) return value; + throw new KeeperSdkError( + `Invalid operation '${operation}'. Use: owner-trash, folder-trash, unlink.`, + ResultCodes.NSF_REMOVE_FAILED, + ); } -function mapPreviewItem(item: FolderProto.v3.remove.IRemoveResult): NsfRemovePreviewItem { - return { - recordUid: item.itemUid?.length ? webSafe64FromBytes(item.itemUid) : '', - folderUid: item.folderUid?.length ? webSafe64FromBytes(item.folderUid) : '', - status: item.status == null ? 'REMOVE_STATUS_UNKNOWN' : (RemoveStatus[item.status] ?? String(item.status)), - impact: item.impact - ? { - foldersCount: item.impact.foldersCount ?? 0, - recordsCount: item.impact.recordsCount ?? 0, - affectedUsersCount: item.impact.affectedUsersCount ?? 0, - affectedTeamsCount: item.impact.affectedTeamsCount ?? 0, - warnings: [...(item.impact.warnings ?? [])], - } - : undefined, - error: item.error - ? { - code: item.error.code ?? 0, - message: item.error.message ?? '', - } - : undefined, - } +function mapPreviewItem( + item: FolderProto.v3.remove.IRemoveResult, +): NsfRemovePreviewItem { + return { + recordUid: item.itemUid?.length ? webSafe64FromBytes(item.itemUid) : "", + folderUid: item.folderUid?.length ? webSafe64FromBytes(item.folderUid) : "", + status: + item.status == null + ? "REMOVE_STATUS_UNKNOWN" + : (RemoveStatus[item.status] ?? String(item.status)), + impact: item.impact + ? { + foldersCount: item.impact.foldersCount ?? 0, + recordsCount: item.impact.recordsCount ?? 0, + affectedUsersCount: item.impact.affectedUsersCount ?? 0, + affectedTeamsCount: item.impact.affectedTeamsCount ?? 0, + warnings: [...(item.impact.warnings ?? [])], + } + : undefined, + error: item.error + ? { + code: item.error.code ?? 0, + message: item.error.message ?? "", + } + : undefined, + }; } function hasPreviewErrors(preview: NsfRemovePreviewItem[]): boolean { - return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) + return preview.some( + (item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS, + ); } type RemovalSpec = { - recordUid: string - folderUid?: string - operation: FolderProto.v3.remove.RecordOperationType -} + recordUid: string; + folderUid?: string; + operation: FolderProto.v3.remove.RecordOperationType; +}; function buildRemovals( - storage: InMemoryStorage, - auth: Auth, - recordIdentifiers: string[], - folderIdentifier: string | undefined, - operation: NsfRemoveOperation + storage: InMemoryStorage, + auth: Auth, + recordIdentifiers: string[], + folderIdentifier: string | undefined, + operation: NsfRemoveOperation, ): RemovalSpec[] { - if (recordIdentifiers.length === 0) { - throw new KeeperSdkError('At least one record UID or title is required.', ResultCodes.NSF_NOT_FOUND) - } - if (recordIdentifiers.length > NSF_MAX_RECORD_BATCH) { - throw new KeeperSdkError(`Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, ResultCodes.NSF_TOO_MANY_RECORDS) - } - if (operation === NsfRemoveOperation.Unlink && !folderIdentifier?.trim()) { - throw new KeeperSdkError( - '--folder is required when operation is "unlink".', - ResultCodes.NSF_FOLDER_REQUIRED - ) - } + if (recordIdentifiers.length === 0) { + throw new KeeperSdkError( + "At least one record UID or title is required.", + ResultCodes.NSF_NOT_FOUND, + ); + } + if (recordIdentifiers.length > NSF_MAX_RECORD_BATCH) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, + ResultCodes.NSF_TOO_MANY_RECORDS, + ); + } + if (operation === NsfRemoveOperation.Unlink && !folderIdentifier?.trim()) { + throw new KeeperSdkError( + '--folder is required when operation is "unlink".', + ResultCodes.NSF_FOLDER_REQUIRED, + ); + } - const folderUid = folderIdentifier ? resolveNsfFolderIdentifier(storage, folderIdentifier) : undefined - if (folderIdentifier && !folderUid) { - throw new KeeperSdkError(`Folder '${folderIdentifier}' not found`, ResultCodes.NSF_NOT_FOUND) - } + const folderUid = folderIdentifier + ? resolveNsfFolderIdentifier(storage, folderIdentifier) + : undefined; + if (folderIdentifier && !folderUid) { + throw new KeeperSdkError( + `Folder '${folderIdentifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } - const accountUid = auth.accountUid - if (!accountUid?.length) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } + const accountUid = auth.accountUid; + if (!accountUid?.length) { + throw new KeeperSdkError( + "Not logged in. Call login() first.", + ResultCodes.NOT_LOGGED_IN, + ); + } - const removals: RemovalSpec[] = [] - for (const identifier of recordIdentifiers) { - const recordUid = resolveNsfRecordIdentifier(storage, identifier) - if (!recordUid) { - throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) - } - ensureNestedShareRecord(storage, recordUid, identifier) - - let ctxFolder = folderUid - if (!ctxFolder) { - const folders = findNestedShareFoldersForRecord(storage, recordUid) - if (folders.length === 0 && operation !== NsfRemoveOperation.OwnerTrash) { - throw new KeeperSdkError( - `No folder context for record '${identifier}'. Use folder option or owner-trash operation.`, - ResultCodes.NSF_NOT_FOUND - ) - } - ctxFolder = folders[0] - } + const removals: RemovalSpec[] = []; + for (const identifier of recordIdentifiers) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier); + if (!recordUid) { + throw new KeeperSdkError( + `Record '${identifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } + ensureNestedShareRecord(storage, recordUid, identifier); - if (operation === NsfRemoveOperation.OwnerTrash) { - checkRecordDeletePermission(storage, recordUid, auth.username, accountUid, ctxFolder) - } else { - if (!ctxFolder || isRootFolderUid(storage, ctxFolder)) { - throw new KeeperSdkError( - `Folder context is required for '${operation}' operation.`, - ResultCodes.NSF_FOLDER_REQUIRED - ) - } - checkFolderRemovePermission(storage, ctxFolder, recordUid, auth.username, accountUid) - } + let ctxFolder = folderUid; + if (!ctxFolder) { + const folders = findNestedShareFoldersForRecord(storage, recordUid); + if (folders.length === 0 && operation !== NsfRemoveOperation.OwnerTrash) { + throw new KeeperSdkError( + `No folder context for record '${identifier}'. Use folder option or owner-trash operation.`, + ResultCodes.NSF_NOT_FOUND, + ); + } + ctxFolder = folders[0]; + } - removals.push({ - recordUid, - folderUid: ctxFolder, - operation: OPERATION_MAP[operation], - }) + if (operation === NsfRemoveOperation.OwnerTrash) { + checkRecordDeletePermission( + storage, + recordUid, + auth.username, + accountUid, + ctxFolder, + ); + } else { + if (!ctxFolder || isRootFolderUid(storage, ctxFolder)) { + throw new KeeperSdkError( + `Folder context is required for '${operation}' operation.`, + ResultCodes.NSF_FOLDER_REQUIRED, + ); + } + checkFolderRemovePermission( + storage, + ctxFolder, + recordUid, + auth.username, + accountUid, + ); } - return removals + + removals.push({ + recordUid, + folderUid: ctxFolder, + operation: OPERATION_MAP[operation], + }); + } + return removals; } async function executeRemove( - auth: Auth, - removals: RemovalSpec[], - action: FolderProto.v3.remove.RemoveAction, - confirmationToken?: Uint8Array + auth: Auth, + removals: RemovalSpec[], + action: FolderProto.v3.remove.RemoveAction, + confirmationToken?: Uint8Array, ): Promise { - return auth.executeRest( - removeRecordMessage({ - action, - records: removals.map((spec) => ({ - recordUid: normal64Bytes(spec.recordUid), - folderUid: spec.folderUid ? normal64Bytes(spec.folderUid) : new Uint8Array(0), - operationType: spec.operation, - })), - confirmationToken, - }) - ) + return auth.executeRest( + removeRecordMessage({ + action, + records: removals.map((spec) => ({ + recordUid: normal64Bytes(spec.recordUid), + folderUid: spec.folderUid + ? normal64Bytes(spec.folderUid) + : new Uint8Array(0), + operationType: spec.operation, + })), + confirmationToken, + }), + ); } -export function collectRemoveNsfWarnings(preview: NsfRemovePreviewItem[]): string[] { - const warnings = new Set() - for (const item of preview) { - for (const warning of item.impact?.warnings ?? []) { - if (warning) warnings.add(warning) - } +export function collectRemoveNsfWarnings( + preview: NsfRemovePreviewItem[], +): string[] { + const warnings = new Set(); + for (const item of preview) { + for (const warning of item.impact?.warnings ?? []) { + if (warning) warnings.add(warning); } - return [...warnings] + } + return [...warnings]; } -export function formatRemoveNsfPreview(preview: NsfRemovePreviewItem[]): string { - const lines: string[] = [] - for (const item of preview) { - lines.push(`Record: ${item.recordUid}`) - if (item.folderUid) lines.push(` Folder: ${item.folderUid}`) - if (item.status !== REMOVE_SUCCESS_STATUS) { - lines.push(` Status: ${item.status}`) - } - if (item.impact) { - lines.push( - ` Impact: folders=${item.impact.foldersCount}, records=${item.impact.recordsCount}, users=${item.impact.affectedUsersCount}, teams=${item.impact.affectedTeamsCount}` - ) - } - if (item.error?.message) { - lines.push(` Error: ${item.error.message}`) - } - lines.push('') +export function formatRemoveNsfPreview( + preview: NsfRemovePreviewItem[], +): string { + const lines: string[] = []; + for (const item of preview) { + lines.push(`Record: ${item.recordUid}`); + if (item.folderUid) lines.push(` Folder: ${item.folderUid}`); + lines.push(` Status: ${item.status}`); + if (item.impact) { + lines.push( + ` Impact: folders=${item.impact.foldersCount}, records=${item.impact.recordsCount}, users=${item.impact.affectedUsersCount}, teams=${item.impact.affectedTeamsCount}`, + ); + for (const warning of item.impact.warnings) { + lines.push(` Warning: ${warning}`); + } } - return lines.join('\n').trimEnd() + if (item.error?.message) { + lines.push(` Error: ${item.error.message}`); + } + lines.push(""); + } + return lines.join("\n").trimEnd(); } export async function removeNestedShareRecords( - storage: InMemoryStorage, - auth: Auth, - input: RemoveNsfRecordInput + storage: InMemoryStorage, + auth: Auth, + input: RemoveNsfRecordInput, ): Promise { - const operation = normalizeOperation(input.operation) - const dryRun = input.dryRun ?? false - const removals = buildRemovals(storage, auth, input.records, input.folder, operation) - - try { - const previewResponse = await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_PREVIEW) - const preview = (previewResponse.results ?? []).map(mapPreviewItem) + const operation = normalizeOperation(input.operation); + const dryRun = input.dryRun ?? false; + const removals = buildRemovals( + storage, + auth, + input.records, + input.folder, + operation, + ); - if (hasPreviewErrors(preview)) { - throw new KeeperSdkError(formatRemoveNsfPreview(preview) || 'Removal preview failed.', ResultCodes.NSF_REMOVE_FAILED) - } + try { + const previewResponse = await executeRemove( + auth, + removals, + RemoveAction.REMOVE_ACTION_PREVIEW, + ); + const preview = (previewResponse.results ?? []).map(mapPreviewItem); - if (dryRun || !previewResponse.confirmationToken?.length) { - return { confirmed: false, dryRun, preview } - } + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError( + formatRemoveNsfPreview(preview) || "Removal preview failed.", + ResultCodes.NSF_REMOVE_FAILED, + ); + } - if (!input.force) { - return { confirmed: false, dryRun: false, preview, message: 'Confirmation required. Set force=true to proceed.' } - } + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, preview }; + } - await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_CONFIRM, previewResponse.confirmationToken) - return { - confirmed: true, - dryRun: false, - preview, - message: `Removed ${removals.length} record(s).`, - } - } catch (err) { - if (err instanceof KeeperSdkError) throw err - throw new KeeperSdkError( - `Failed to remove nested share record(s): ${extractErrorMessage(err)}`, - ResultCodes.NSF_REMOVE_FAILED - ) + if (!input.force) { + return { + confirmed: false, + dryRun: false, + preview, + message: "Confirmation required. Set force=true to proceed.", + }; } + + await executeRemove( + auth, + removals, + RemoveAction.REMOVE_ACTION_CONFIRM, + previewResponse.confirmationToken, + ); + return { + confirmed: true, + dryRun: false, + preview, + message: `Removed ${removals.length} record(s).`, + }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to remove nested share record(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_REMOVE_FAILED, + ); + } } diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts new file mode 100644 index 00000000..5f01c0c3 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts @@ -0,0 +1,414 @@ +import type { Auth } from "@keeper-security/keeperapi"; +import { + Folder, + folderUpdateMessage, + normal64Bytes, + platform, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { + NSF_FOLDER_COLORS, + NSF_MAX_FOLDER_UPDATES, + type NsfFolderColor, +} from "./nsfConstants"; +import { + checkFolderEditPermission, + checkFolderSharePermission, + ensureNestedShareFolder, + getFolderDisplayName, + getKeeperDriveFolder, + parseFolderModifyStatus, + patchNsfFolderInheritPermissions, + patchNsfFolderMetadata, + requireAuthAccountUid, + resolveKeeperDriveParentUid, + resolveNsfFolderIdentifier, +} from "./nsfHelpers"; +import type { NsfFolderColorInput } from "./mkdirNsf"; + +type NsfFolderMetadata = { + name: string; + color?: string; +}; + +export type UpdateNsfFolderInput = { + folder: string; + name?: string; + color?: NsfFolderColorInput; + /** When set, updates whether child folders inherit this folder's user permissions. */ + inheritPermissions?: boolean; + quiet?: boolean; +}; + +export type UpdateNsfFolderResult = { + folderUid: string; + updated: boolean; + message?: string; +}; + +export type UpdateNsfFolderBatchItem = { + folder: string; + name?: string; + color?: NsfFolderColorInput; + inheritPermissions?: boolean; +}; + +export type UpdateNsfFolderBatchResultItem = { + folder: string; + folderUid: string; + updated: boolean; + status?: Folder.FolderModifyStatus; + message?: string; +}; + +export type UpdateNsfFoldersResult = { + results: UpdateNsfFolderBatchResultItem[]; + anyUpdated: boolean; +}; + +type PreparedFolderUpdate = { + identifier: string; + folderUid: string; + displayName: string; + newName?: string; + color?: NsfFolderColor; + inheritPermissions?: boolean; + metadata: NsfFolderMetadata; + folderData: Folder.IFolderData; +}; + +function normalizeColor(color: NsfFolderColorInput): NsfFolderColor { + if (!(NSF_FOLDER_COLORS as readonly string[]).includes(color)) { + throw new KeeperSdkError( + `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(", ")}.`, + ResultCodes.NSF_UPDATE_FAILED, + ); + } + return color; +} + +function readExistingMetadata( + folderUid: string, + storage: InMemoryStorage, +): NsfFolderMetadata { + const folder = getKeeperDriveFolder(storage, folderUid); + if (!folder) { + throw new KeeperSdkError( + `Folder '${folderUid}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } + const data = folder.data as NsfFolderMetadata; + return { + name: data.name || "", + color: data.color, + }; +} + +function mergeFolderMetadata( + existing: NsfFolderMetadata, + newName: string | undefined, + color: NsfFolderColor | undefined, +): NsfFolderMetadata { + const metadata: NsfFolderMetadata = { + name: newName !== undefined ? newName : existing.name, + }; + if (color !== undefined) { + if (color !== "none") metadata.color = color; + } else if (existing.color && existing.color !== "none") { + metadata.color = existing.color; + } + return metadata; +} + +function normalizeUpdateName(name: string | undefined): string | undefined { + if (name === undefined) return undefined; + const trimmed = name.trim(); + if (!trimmed) { + throw new KeeperSdkError( + "Folder name cannot be empty", + ResultCodes.NSF_UPDATE_FAILED, + ); + } + return trimmed; +} + +function assertHasUpdateFields(input: { + name?: string; + color?: NsfFolderColorInput; + inheritPermissions?: boolean; +}): void { + if ( + input.name === undefined && + input.color === undefined && + input.inheritPermissions === undefined + ) { + throw new KeeperSdkError( + "New folder name, color, and/or inheritPermissions parameters are required.", + ResultCodes.NSF_UPDATE_FAILED, + ); + } +} + +async function buildUpdateFolderData( + storage: InMemoryStorage, + folderUid: string, + metadata: NsfFolderMetadata, + inheritPermissions?: boolean, +): Promise { + const folderKey = await storage.getKeyBytes(folderUid); + if (!folderKey) { + throw new KeeperSdkError( + `Folder key not available for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); + } + + const encryptedData = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(metadata)), + folderKey, + ); + + const stored = getKeeperDriveFolder(storage, folderUid); + const resolvedParentUid = resolveKeeperDriveParentUid( + storage, + stored?.parentUid, + ); + + const folderData = Folder.FolderData.create({ + folderUid: normal64Bytes(folderUid), + parentUid: resolvedParentUid + ? normal64Bytes(resolvedParentUid) + : undefined, + data: encryptedData, + }); + + if (inheritPermissions !== undefined) { + folderData.inheritUserPermissions = inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE; + } + + return folderData; +} + +function buildSuccessMessage( + folderDisplayName: string, + newName: string | undefined, + color: NsfFolderColor | undefined, + inheritPermissions: boolean | undefined, + quiet?: boolean, +): string | undefined { + if (quiet) return undefined; + if (newName !== undefined) { + return `Folder "${folderDisplayName}" has been renamed to "${newName}".`; + } + if (color !== undefined) { + return `Folder "${folderDisplayName}" color has been updated.`; + } + if (inheritPermissions !== undefined) { + return `Folder "${folderDisplayName}" inherit permissions has been updated.`; + } + return `Folder "${folderDisplayName}" has been updated.`; +} + +async function prepareFolderUpdate( + storage: InMemoryStorage, + auth: Auth, + input: { + folder: string; + name?: string; + color?: NsfFolderColorInput; + inheritPermissions?: boolean; + }, +): Promise { + const folderArg = input.folder?.trim(); + if (!folderArg) { + throw new KeeperSdkError( + "Enter the path or UID of existing folder.", + ResultCodes.NSF_UPDATE_FAILED, + ); + } + + assertHasUpdateFields(input); + const newName = normalizeUpdateName(input.name); + const color = + input.color !== undefined ? normalizeColor(input.color) : undefined; + const inheritPermissions = input.inheritPermissions; + + const folderUid = resolveNsfFolderIdentifier(storage, folderArg); + if (!folderUid) { + throw new KeeperSdkError( + `Folder '${folderArg}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } + + ensureNestedShareFolder(storage, folderUid, folderArg); + const accountUid = requireAuthAccountUid(auth); + + if (newName !== undefined || color !== undefined) { + checkFolderEditPermission(storage, folderUid, auth.username, accountUid); + } + if (inheritPermissions !== undefined) { + checkFolderSharePermission(storage, folderUid, auth.username, accountUid); + } + + const displayName = getFolderDisplayName(storage, folderUid); + const existing = readExistingMetadata(folderUid, storage); + const metadata = mergeFolderMetadata(existing, newName, color); + const folderData = await buildUpdateFolderData( + storage, + folderUid, + metadata, + inheritPermissions, + ); + + return { + identifier: folderArg, + folderUid, + displayName, + newName, + color, + inheritPermissions, + metadata, + folderData, + }; +} + +async function persistFolderUpdate( + storage: InMemoryStorage, + prepared: PreparedFolderUpdate, +): Promise { + // Always refresh local folder data to match the encrypted payload we sent. + await patchNsfFolderMetadata( + storage, + prepared.folderUid, + prepared.metadata, + ); + if (prepared.inheritPermissions !== undefined) { + await patchNsfFolderInheritPermissions( + storage, + prepared.folderUid, + prepared.inheritPermissions, + ); + } +} + +export async function updateNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfFolderInput, +): Promise { + try { + const prepared = await prepareFolderUpdate(storage, auth, input); + const response = await auth.executeRest( + folderUpdateMessage({ folderData: [prepared.folderData] }), + ); + parseFolderModifyStatus( + response.folderUpdateResults?.[0], + ResultCodes.NSF_UPDATE_FAILED, + ); + + await persistFolderUpdate(storage, prepared); + + return { + folderUid: prepared.folderUid, + updated: true, + message: buildSuccessMessage( + prepared.displayName, + prepared.newName, + prepared.color, + prepared.inheritPermissions, + input.quiet, + ), + }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to update nested share folder: ${extractErrorMessage(err)}`, + ResultCodes.NSF_UPDATE_FAILED, + ); + } +} + +/** + * Batch-update Nested Share Folders via `vault/folders/v3/update`. + * Chunks into requests of at most {@link NSF_MAX_FOLDER_UPDATES} (100) folders. + * Each item must include folderUid and either encrypted data and/or inheritUserPermissions; + * parentUid is included from local storage as required by the API. + */ +export async function updateNestedShareFolders( + storage: InMemoryStorage, + auth: Auth, + updates: UpdateNsfFolderBatchItem[], +): Promise { + if (!updates?.length) { + throw new KeeperSdkError( + "At least one folder update is required.", + ResultCodes.NSF_UPDATE_FAILED, + ); + } + + try { + const preparedAll: PreparedFolderUpdate[] = []; + for (const item of updates) { + preparedAll.push(await prepareFolderUpdate(storage, auth, item)); + } + + const results: UpdateNsfFolderBatchResultItem[] = []; + let anyUpdated = false; + + for (let i = 0; i < preparedAll.length; i += NSF_MAX_FOLDER_UPDATES) { + const batch = preparedAll.slice(i, i + NSF_MAX_FOLDER_UPDATES); + const response = await auth.executeRest( + folderUpdateMessage({ + folderData: batch.map((item) => item.folderData), + }), + ); + const remoteResults = response.folderUpdateResults ?? []; + + for (let j = 0; j < batch.length; j++) { + const prepared = batch[j]; + const modifyResult = remoteResults[j]; + const status = + modifyResult?.status ?? Folder.FolderModifyStatus.SUCCESS; + const success = status === Folder.FolderModifyStatus.SUCCESS; + const statusName = + Folder.FolderModifyStatus[status] ?? String(status); + const message = + modifyResult?.message || + (success + ? buildSuccessMessage( + prepared.displayName, + prepared.newName, + prepared.color, + prepared.inheritPermissions, + ) + : `Folder operation failed (${statusName}).`); + + if (success) { + anyUpdated = true; + await persistFolderUpdate(storage, prepared); + } + + results.push({ + folder: prepared.identifier, + folderUid: prepared.folderUid, + updated: success, + status, + message: message || undefined, + }); + } + } + + return { results, anyUpdated }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to update nested share folders: ${extractErrorMessage(err)}`, + ResultCodes.NSF_UPDATE_FAILED, + ); + } +} diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts index 8829ed38..7de09bac 100644 --- a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -1,196 +1,231 @@ -import type { Auth, DRecord } from '@keeper-security/keeperapi' -import { keeperDriveRecordsUpdate, normal64Bytes, platform } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { VaultObjectKind } from '../folders/folderHelpers' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { NSF_MAX_RECORD_BATCH } from './nsfConstants' -import { resolveRecordKeyBytes } from './nsfRecordCrypto' -import { getPaddedJsonBytes, mergeNsfRecordData } from './nsfRecordData' +import type { Auth, DRecord } from "@keeper-security/keeperapi"; import { - checkRecordEditPermission, - ensureNestedShareRecord, - nsfToNumber, - parseRecordModifyStatus, - requireAuthAccountUid, - resolveNsfRecordIdentifier, -} from './nsfHelpers' -import { validateNsfRecordType } from './nsfRecordTypes' + keeperDriveRecordsUpdate, + normal64Bytes, + platform, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { VaultObjectKind } from "../folders/folderHelpers"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { NSF_MAX_RECORD_BATCH } from "./nsfConstants"; +import { resolveRecordKeyBytes } from "./nsfRecordCrypto"; +import { getPaddedJsonBytes, mergeNsfRecordData } from "./nsfRecordData"; +import { + checkRecordEditPermission, + ensureNestedShareRecord, + nsfToNumber, + parseRecordModifyStatus, + requireAuthAccountUid, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; +import { validateNsfRecordType } from "./nsfRecordTypes"; import type { - RecordUpdatePayload, - UpdateNsfRecordInput, - UpdateNsfRecordItemInput, - UpdateNsfRecordsInput, - UpdateNsfRecordResult, - UpdateNsfRecordResultItem, -} from './nsfTypes' - -type UpdateNsfRecordChanges = Omit - -function loadStoredRecordData(storage: InMemoryStorage, recordUid: string): Record { - const record = storage.getByUid(VaultObjectKind.Record, recordUid) - if (record?.data && typeof record.data === 'object') { - return structuredClone(record.data) as Record - } - return { fields: [] } + RecordUpdatePayload, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, +} from "./nsfTypes"; + +type UpdateNsfRecordChanges = Omit; + +function loadStoredRecordData( + storage: InMemoryStorage, + recordUid: string, +): Record { + const record = storage.getByUid(VaultObjectKind.Record, recordUid); + if (record?.data && typeof record.data === "object") { + return structuredClone(record.data) as Record; + } + return { fields: [] }; } function isPerRecordUpdateInput( - input: UpdateNsfRecordInput | UpdateNsfRecordsInput + input: UpdateNsfRecordInput | UpdateNsfRecordsInput, ): input is UpdateNsfRecordsInput { - const first = input.records[0] - return first != null && typeof first === 'object' && 'record' in first + const first = input.records[0]; + return first != null && typeof first === "object" && "record" in first; } -function toUpdateItems(input: UpdateNsfRecordInput | UpdateNsfRecordsInput): UpdateNsfRecordItemInput[] { - if (isPerRecordUpdateInput(input)) { - return input.records - } - const { records, ...changes } = input - return records.map((record) => ({ record, ...changes })) +function toUpdateItems( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput, +): UpdateNsfRecordItemInput[] { + if (isPerRecordUpdateInput(input)) { + return input.records; + } + const { records, ...changes } = input; + return records.map((record) => ({ record, ...changes })); } function hasUpdateChanges(changes: UpdateNsfRecordChanges): boolean { - return !!( - changes.title?.trim() || - changes.recordType?.trim() || - changes.notes?.trim() || - changes.fieldEntries?.length || - changes.customEntries?.length - ) + return !!( + changes.title?.trim() || + changes.recordType?.trim() || + changes.notes?.trim() || + changes.fieldEntries?.length || + changes.customEntries?.length + ); } async function buildRecordUpdatePayload( - storage: InMemoryStorage, - auth: Auth, - recordUid: string, - changes: UpdateNsfRecordChanges + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + changes: UpdateNsfRecordChanges, ): Promise { - const storedRecord = storage.getByUid(VaultObjectKind.Record, recordUid) - const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) - if (!recordKey) { - throw new KeeperSdkError( - `Record key not available for ${recordUid}. Run sync() first.`, - ResultCodes.NSF_MISSING_KEY - ) - } - - const mergedRecordData = mergeNsfRecordData(loadStoredRecordData(storage, recordUid), changes) - return { - recordUid, - storedRecord, - mergedRecordData, - recordUpdate: { - recordUid: normal64Bytes(recordUid), - clientModifiedTime: Date.now(), - revision: storedRecord?.revision ?? 0, - data: await platform.aesGcmEncrypt(getPaddedJsonBytes(mergedRecordData), recordKey), - }, - } + const storedRecord = storage.getByUid( + VaultObjectKind.Record, + recordUid, + ); + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid); + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY, + ); + } + + const mergedRecordData = mergeNsfRecordData( + loadStoredRecordData(storage, recordUid), + changes, + ); + return { + recordUid, + storedRecord, + mergedRecordData, + recordUpdate: { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + revision: storedRecord?.revision ?? 0, + data: await platform.aesGcmEncrypt( + getPaddedJsonBytes(mergedRecordData), + recordKey, + ), + }, + }; } export async function updateNestedShareRecords( - storage: InMemoryStorage, - auth: Auth, - input: UpdateNsfRecordInput | UpdateNsfRecordsInput + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordInput | UpdateNsfRecordsInput, ): Promise { - const items = toUpdateItems(input) - if (items.length === 0) { - throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) - } - if (items.length > NSF_MAX_RECORD_BATCH) { - throw new KeeperSdkError( - `Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, - ResultCodes.NSF_TOO_MANY_RECORDS - ) + const items = toUpdateItems(input); + if (items.length === 0) { + throw new KeeperSdkError( + "Record UID is required.", + ResultCodes.NSF_UPDATE_FAILED, + ); + } + if (items.length > NSF_MAX_RECORD_BATCH) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, + ResultCodes.NSF_TOO_MANY_RECORDS, + ); + } + + for (const item of items) { + if (!hasUpdateChanges(item)) { + throw new KeeperSdkError( + `At least one field to update is required for record '${item.record}'.`, + ResultCodes.NSF_UPDATE_FAILED, + ); } + } - for (const item of items) { - if (!hasUpdateChanges(item)) { - throw new KeeperSdkError( - `At least one field to update is required for record '${item.record}'.`, - ResultCodes.NSF_UPDATE_FAILED - ) - } + const recordTypes = new Set(); + for (const item of items) { + if (item.recordType?.trim()) { + recordTypes.add(item.recordType.trim()); } - - const recordTypes = new Set() + } + for (const recordType of recordTypes) { + await validateNsfRecordType( + auth, + recordType, + ResultCodes.NSF_UPDATE_FAILED, + ); + } + + const accountUid = requireAuthAccountUid(auth); + + try { + const payloads: RecordUpdatePayload[] = []; for (const item of items) { - if (item.recordType?.trim()) { - recordTypes.add(item.recordType.trim()) - } - } - for (const recordType of recordTypes) { - await validateNsfRecordType(auth, recordType, ResultCodes.NSF_UPDATE_FAILED) + const { record: identifier, ...changes } = item; + const recordUid = resolveNsfRecordIdentifier(storage, identifier); + if (!recordUid) { + throw new KeeperSdkError( + `Record '${identifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } + ensureNestedShareRecord(storage, recordUid, identifier); + checkRecordEditPermission(storage, recordUid, auth.username, accountUid); + payloads.push( + await buildRecordUpdatePayload(storage, auth, recordUid, changes), + ); } - const accountUid = requireAuthAccountUid(auth) - - try { - const payloads: RecordUpdatePayload[] = [] - for (const item of items) { - const { record: identifier, ...changes } = item - const recordUid = resolveNsfRecordIdentifier(storage, identifier) - if (!recordUid) { - throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) - } - ensureNestedShareRecord(storage, recordUid, identifier) - checkRecordEditPermission(storage, recordUid, auth.username, accountUid) - payloads.push(await buildRecordUpdatePayload(storage, auth, recordUid, changes)) - } - - const response = await auth.executeRest( - keeperDriveRecordsUpdate({ - records: payloads.map((entry) => entry.recordUpdate), - clientTime: Date.now(), - }) - ) - const revision = nsfToNumber(response.revision) - - const updated: UpdateNsfRecordResultItem[] = [] - for (let index = 0; index < payloads.length; index++) { - const payload = payloads[index] - const { statusName, message } = parseRecordModifyStatus( - response.records?.[index], - ResultCodes.NSF_UPDATE_FAILED - ) - const itemRevision = revision ?? payload.storedRecord?.revision - - if (payload.storedRecord) { - await storage.put({ - ...payload.storedRecord, - data: payload.mergedRecordData, - revision: itemRevision ?? payload.storedRecord.revision, - clientModifiedTime: Date.now(), - }) - } - - updated.push({ - recordUid: payload.recordUid, - success: true, - status: statusName, - message, - revision: itemRevision, - }) - } - - return { updated } - } catch (err) { - if (err instanceof KeeperSdkError) throw err - throw new KeeperSdkError( - `Failed to update nested share record(s): ${extractErrorMessage(err)}`, - ResultCodes.NSF_UPDATE_FAILED - ) + const response = await auth.executeRest( + keeperDriveRecordsUpdate({ + records: payloads.map((entry) => entry.recordUpdate), + clientTime: Date.now(), + }), + ); + const revision = nsfToNumber(response.revision); + + const updated: UpdateNsfRecordResultItem[] = []; + for (let index = 0; index < payloads.length; index++) { + const payload = payloads[index]; + const { statusName, message } = parseRecordModifyStatus( + response.records?.[index], + ResultCodes.NSF_UPDATE_FAILED, + ); + const itemRevision = revision ?? payload.storedRecord?.revision; + + if (payload.storedRecord) { + await storage.put({ + ...payload.storedRecord, + data: payload.mergedRecordData, + revision: itemRevision ?? payload.storedRecord.revision, + clientModifiedTime: Date.now(), + }); + } + + updated.push({ + recordUid: payload.recordUid, + success: true, + status: statusName, + message, + revision: itemRevision, + }); } + + return { updated }; + } catch (err) { + if (err instanceof KeeperSdkError) throw err; + throw new KeeperSdkError( + `Failed to update nested share record(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_UPDATE_FAILED, + ); + } } export async function updateNestedShareRecord( - storage: InMemoryStorage, - auth: Auth, - input: UpdateNsfRecordItemInput + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordItemInput, ): Promise { - const { updated } = await updateNestedShareRecords(storage, auth, { records: [input] }) - if (!updated[0]) { - throw new KeeperSdkError('Failed to update nested share record.', ResultCodes.NSF_UPDATE_FAILED) - } - return updated[0] + const { updated } = await updateNestedShareRecords(storage, auth, { + records: [input], + }); + if (!updated[0]) { + throw new KeeperSdkError( + "Failed to update nested share record.", + ResultCodes.NSF_UPDATE_FAILED, + ); + } + return updated[0]; } diff --git a/KeeperSdk/src/platform/browser/platform.ts b/KeeperSdk/src/platform/browser/platform.ts new file mode 100644 index 00000000..7e331185 --- /dev/null +++ b/KeeperSdk/src/platform/browser/platform.ts @@ -0,0 +1,84 @@ +import type { AuthUI3 } from "@keeper-security/keeperapi"; +import { UnavailableAuthUI } from "../../auth/UnavailableAuthUI"; +import { KeeperSdkError, ResultCodes } from "../../utils"; +import type { ConfigLoader } from "../../auth/config"; +import * as asmCrypto from "asmcrypto.js"; +import type { SdkPlatform, SdkReadline } from "../types"; + +type HmacCtor = new (key: Uint8Array) => { + process(data: Uint8Array): void; + finish(): void; + result: Uint8Array; +}; + +const asm = asmCrypto as typeof asmCrypto & { + HmacSha1: HmacCtor; + HmacSha256: HmacCtor; + HmacSha512: HmacCtor; +}; + +const HMAC_IMPL: Record<"sha1" | "sha256" | "sha512", HmacCtor> = { + sha1: asm.HmacSha1, + sha256: asm.HmacSha256, + sha512: asm.HmacSha512, +}; + +const BROWSER_READLINE_MSG = + "Interactive readline is not available in the browser. Use keeper-shell password transport or a custom authUI."; + +const BROWSER_FILE_CONFIG_MSG = + "File-based Keeper config (~/.keeper) is not available in the browser. Pass an in-memory ConfigLoader to SessionManager or KeeperVault.sessionStorage."; + +class BrowserReadline implements SdkReadline { + question(_prompt: string): Promise { + return Promise.reject( + new KeeperSdkError(BROWSER_READLINE_MSG, ResultCodes.USER_CANCELLED), + ); + } + close(): void { + /* noop */ + } +} + +export const browserSdkPlatform: SdkPlatform = { + runtime: "browser", + + delay(ms: number): Promise { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); + }, + + createReadline(): SdkReadline { + return new BrowserReadline(); + }, + + hmac(algorithm, key, data) { + const Ctor = HMAC_IMPL[algorithm]; + if (!Ctor) { + throw new KeeperSdkError( + `Unsupported HMAC algorithm: ${algorithm}`, + ResultCodes.UNSUPPORTED_2FA_CHANNEL, + ); + } + const h = new Ctor(key); + h.process(data); + h.finish(); + return h.result; + }, + + createFileConfigLoader(): ConfigLoader { + throw new KeeperSdkError( + BROWSER_FILE_CONFIG_MSG, + ResultCodes.NOT_LOGGED_IN, + ); + }, + + createAuthUI(useConsoleAuth: boolean): AuthUI3 { + if (useConsoleAuth) { + throw new KeeperSdkError( + "ConsoleAuthUI (readline) is not available in the browser. Set useConsoleAuth: false and provide authUI, or use keeper-shell.", + ResultCodes.USER_CANCELLED, + ); + } + return new UnavailableAuthUI(); + }, +}; diff --git a/KeeperSdk/src/platform/index.ts b/KeeperSdk/src/platform/index.ts new file mode 100644 index 00000000..6b47cbfd --- /dev/null +++ b/KeeperSdk/src/platform/index.ts @@ -0,0 +1,22 @@ +import type { SdkPlatform } from "./types"; + +let active: SdkPlatform | undefined; + +export type { SdkPlatform, SdkReadline, SdkRuntime } from "./types"; + +export function connectSdkPlatform(platform: SdkPlatform): void { + active = platform; +} + +export function getSdkPlatform(): SdkPlatform { + if (!active) { + throw new Error( + "Keeper SDK platform is not initialized. Import @keeper-security/keeper-sdk-javascript or /browser entry first.", + ); + } + return active; +} + +export function isSdkPlatformConnected(): boolean { + return active !== undefined; +} diff --git a/KeeperSdk/src/platform/node/platform.ts b/KeeperSdk/src/platform/node/platform.ts new file mode 100644 index 00000000..15b0d6cb --- /dev/null +++ b/KeeperSdk/src/platform/node/platform.ts @@ -0,0 +1,44 @@ +import { createHmac } from "crypto"; +import readline from "readline/promises"; +import type { AuthUI3 } from "@keeper-security/keeperapi"; +import { ConsoleAuthUI } from "../../auth/ConsoleAuthUI"; +import { UnavailableAuthUI } from "../../auth/UnavailableAuthUI"; +import { FileConfigLoader } from "../../auth/node/FileConfigLoader"; +import type { ConfigLoader } from "../../auth/config"; +import type { SdkPlatform, SdkReadline } from "../types"; + +function nodeReadline(input?: unknown, output?: unknown): SdkReadline { + const rl = readline.createInterface({ + input: (input ?? process.stdin) as NodeJS.ReadableStream, + output: (output ?? process.stdout) as NodeJS.WritableStream, + }); + return { + question: (prompt) => rl.question(prompt), + close: () => rl.close(), + }; +} + +export const nodeSdkPlatform: SdkPlatform = { + runtime: "node", + + delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, + + createReadline: nodeReadline, + + hmac(algorithm, key, data) { + const algo = algorithm.toLowerCase(); + return new Uint8Array( + createHmac(algo, Buffer.from(key)).update(data).digest(), + ); + }, + + createFileConfigLoader(configDir?: string): ConfigLoader { + return new FileConfigLoader(configDir); + }, + + createAuthUI(useConsoleAuth: boolean): AuthUI3 { + return useConsoleAuth ? new ConsoleAuthUI() : new UnavailableAuthUI(); + }, +}; diff --git a/KeeperSdk/src/platform/types.ts b/KeeperSdk/src/platform/types.ts new file mode 100644 index 00000000..a9087a04 --- /dev/null +++ b/KeeperSdk/src/platform/types.ts @@ -0,0 +1,29 @@ +import type { AuthUI3 } from "@keeper-security/keeperapi"; +import type { ConfigLoader } from "../auth/config"; + +export type SdkRuntime = "node" | "browser"; + +export interface SdkReadline { + question(prompt: string): Promise; + close(): void; +} + +/** Small platform surface for KeeperSdk (auth UI, config, TOTP, timers). */ +export interface SdkPlatform { + readonly runtime: SdkRuntime; + + delay(ms: number): Promise; + + createReadline(input?: unknown, output?: unknown): SdkReadline; + + /** HMAC digest (e.g. SHA-1 for TOTP). */ + hmac( + algorithm: "sha1" | "sha256" | "sha512", + key: Uint8Array, + data: Uint8Array, + ): Uint8Array; + + createFileConfigLoader(configDir?: string): ConfigLoader; + + createAuthUI(useConsoleAuth: boolean): AuthUI3; +} diff --git a/KeeperSdk/src/records/RecordOperations.ts b/KeeperSdk/src/records/RecordOperations.ts index acee9afa..98375c70 100644 --- a/KeeperSdk/src/records/RecordOperations.ts +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -1,549 +1,650 @@ import { - Auth, - Records, - platform, - generateEncryptionKey, - generateUidBytes, - webSafe64FromBytes, - recordsAddMessage, - recordsUpdateMessage, - recordPreDeleteCommand, - recordDeleteCommand, - recordAddCommand, - moveCommand, - getRecordHistoryCommand, - normal64Bytes, -} from '@keeper-security/keeperapi' + Auth, + Records, + platform, + generateEncryptionKey, + generateUidBytes, + webSafe64FromBytes, + recordsAddMessage, + recordsUpdateMessage, + recordPreDeleteCommand, + recordDeleteCommand, + recordAddCommand, + moveCommand, + getRecordHistoryCommand, + normal64Bytes, +} from "@keeper-security/keeperapi"; import type { - DSharedFolder, - DSharedFolderFolder, - DSharedFolderRecord, - DUserFolder, - DRecord, - KeeperPreDeleteResponse, - MoveObject, - MoveRequest, - TransitionKeyObject, - RecordPreDeleteObject, - GetRecordHistoryResponse, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, logger } from '../utils' -import { RecordVersion } from './RecordUtils' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { DeleteResolution, FolderKind, VaultObjectKind } from '../folders/folderHelpers' + DSharedFolder, + DSharedFolderFolder, + DSharedFolderRecord, + DUserFolder, + DRecord, + KeeperPreDeleteResponse, + MoveObject, + MoveRequest, + TransitionKeyObject, + RecordPreDeleteObject, + GetRecordHistoryResponse, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, logger } from "../utils"; +import { RecordVersion } from "./RecordUtils"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { + DeleteResolution, + FolderKind, + VaultObjectKind, +} from "../folders/folderHelpers"; enum ResultCode { - Success = 'success', - OK = 'OK', + Success = "success", + OK = "OK", } -const MIN_RECORD_PAD_BYTES = 384 -const PAD_BLOCK_SIZE = 16 +const MIN_RECORD_PAD_BYTES = 384; +const PAD_BLOCK_SIZE = 16; function getPaddedJsonBytes(data: Record): Uint8Array { - const json = JSON.stringify(data) - const paddedLength = Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * PAD_BLOCK_SIZE - const padded = json.padEnd(paddedLength, ' ') - return new TextEncoder().encode(padded) + const json = JSON.stringify(data); + const paddedLength = + Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * + PAD_BLOCK_SIZE; + const padded = json.padEnd(paddedLength, " "); + return new TextEncoder().encode(padded); } export type PasswordRecordData = { - title: string - login?: string - password?: string - url?: string - notes?: string - custom?: { name: string; value: string; type?: string }[] - totp?: string -} - -function buildLegacyPasswordExtra(data: PasswordRecordData, recordUid: string): Record { - const totp = data.totp?.trim() - if (!totp) { - return {} - } - return { - files: [], - fields: [ - { - id: recordUid, - field_type: 'totp', - field_title: 'One-Time Password', - data: totp, - }, - ], - } + title: string; + login?: string; + password?: string; + url?: string; + notes?: string; + custom?: { name: string; value: string; type?: string }[]; + totp?: string; +}; + +function buildLegacyPasswordExtra( + data: PasswordRecordData, + recordUid: string, +): Record { + const totp = data.totp?.trim(); + if (!totp) { + return {}; + } + return { + files: [], + fields: [ + { + id: recordUid, + field_type: "totp", + field_title: "One-Time Password", + data: totp, + }, + ], + }; } export type RecordFieldInput = { - type: string - value: any[] - label?: string -} + type: string; + value: any[]; + label?: string; +}; export type TypedRecordData = { - type: string - title: string - fields?: RecordFieldInput[] - custom?: RecordFieldInput[] - notes?: string -} + type: string; + title: string; + fields?: RecordFieldInput[]; + custom?: RecordFieldInput[]; + notes?: string; +}; export type NewRecordInput = - | { version: 2; data: PasswordRecordData; folderUid?: string } - | { version: 3; data: TypedRecordData; folderUid?: string } + | { version: 2; data: PasswordRecordData; folderUid?: string } + | { version: 3; data: TypedRecordData; folderUid?: string }; export type AddRecordResult = { - recordUid: string - success: boolean - status?: string -} + recordUid: string; + success: boolean; + status?: string; +}; export type UpdateRecordResult = { - recordUid: string - success: boolean - status?: string -} + recordUid: string; + success: boolean; + status?: string; +}; export type DeleteRecordResult = { - recordUid: string - success: boolean - message?: string -} + recordUid: string; + success: boolean; + message?: string; +}; export async function addRecord( - auth: Auth, - input: NewRecordInput + auth: Auth, + input: NewRecordInput, ): Promise { - if (!input.data.title || !input.data.title.trim()) { - throw new KeeperSdkError('Record title is required.', 'missing_record_title') - } - if (input.version === 3 && !input.data.type?.trim()) { - throw new KeeperSdkError('Record type is required for v3 records.', 'missing_record_type') - } - if (input.version === 2) { - return addPasswordRecord(auth, input.data, input.folderUid) - } - return addTypedRecord(auth, input.data, input.folderUid) + if (!input.data.title || !input.data.title.trim()) { + throw new KeeperSdkError( + "Record title is required.", + "missing_record_title", + ); + } + if (input.version === 3 && !input.data.type?.trim()) { + throw new KeeperSdkError( + "Record type is required for v3 records.", + "missing_record_type", + ); + } + if (input.version === 2) { + return addPasswordRecord(auth, input.data, input.folderUid); + } + return addTypedRecord(auth, input.data, input.folderUid); } async function addPasswordRecord( - auth: Auth, - data: PasswordRecordData, - folderUid?: string + auth: Auth, + data: PasswordRecordData, + folderUid?: string, ): Promise { - const recordUidBytes = generateUidBytes() - const recordKey = generateEncryptionKey() - const recordUid = webSafe64FromBytes(recordUidBytes) - - const recordDataJson = JSON.stringify({ - title: data.title || '', - secret1: data.login || '', - secret2: data.password || '', - link: data.url || '', - notes: data.notes || '', - custom: (data.custom || []).map((c) => ({ - name: c.name, - value: c.value, - type: c.type || 'text', - })), - }) - - const extraJson = JSON.stringify(buildLegacyPasswordExtra(data, recordUid)) - - const dataBytes = new TextEncoder().encode(recordDataJson) - const extraBytes = new TextEncoder().encode(extraJson) - - const encryptedData = await platform.aesCbcEncrypt(dataBytes, recordKey, true) - const encryptedExtra = await platform.aesCbcEncrypt(extraBytes, recordKey, true) - const encryptedRecordKey = await platform.aesCbcEncrypt(recordKey, auth.dataKey!, true) - - const cmd = recordAddCommand({ - record_uid: recordUid, - record_key: webSafe64FromBytes(encryptedRecordKey), - record_type: 'password', - folder_type: FolderKind.UserFolder, - how_long_ago: 0, - folder_uid: folderUid || '', - folder_key: '', - data: webSafe64FromBytes(encryptedData), - extra: webSafe64FromBytes(encryptedExtra), - non_shared_data: '', - file_ids: [], - }) - - const response = await auth.executeRestCommand(cmd) - - return { - recordUid, - success: response.result_code === ResultCode.Success, - status: response.result_code, - } + const recordUidBytes = generateUidBytes(); + const recordKey = generateEncryptionKey(); + const recordUid = webSafe64FromBytes(recordUidBytes); + + const recordDataJson = JSON.stringify({ + title: data.title || "", + secret1: data.login || "", + secret2: data.password || "", + link: data.url || "", + notes: data.notes || "", + custom: (data.custom || []).map((c) => ({ + name: c.name, + value: c.value, + type: c.type || "text", + })), + }); + + const extraJson = JSON.stringify(buildLegacyPasswordExtra(data, recordUid)); + + const dataBytes = new TextEncoder().encode(recordDataJson); + const extraBytes = new TextEncoder().encode(extraJson); + + const encryptedData = await platform.aesCbcEncrypt( + dataBytes, + recordKey, + true, + ); + const encryptedExtra = await platform.aesCbcEncrypt( + extraBytes, + recordKey, + true, + ); + const encryptedRecordKey = await platform.aesCbcEncrypt( + recordKey, + auth.dataKey!, + true, + ); + + const cmd = recordAddCommand({ + record_uid: recordUid, + record_key: webSafe64FromBytes(encryptedRecordKey), + record_type: "password", + folder_type: FolderKind.UserFolder, + how_long_ago: 0, + folder_uid: folderUid || "", + folder_key: "", + data: webSafe64FromBytes(encryptedData), + extra: webSafe64FromBytes(encryptedExtra), + non_shared_data: "", + file_ids: [], + }); + + const response = await auth.executeRestCommand(cmd); + + return { + recordUid, + success: response.result_code === ResultCode.Success, + status: response.result_code, + }; } async function addTypedRecord( - auth: Auth, - data: TypedRecordData, - folderUid?: string + auth: Auth, + data: TypedRecordData, + folderUid?: string, ): Promise { - const recordUidBytes = generateUidBytes() - const recordKey = generateEncryptionKey() - const recordUid = webSafe64FromBytes(recordUidBytes) - - const recordPayload = { - type: data.type, - title: data.title, - fields: data.fields || [], - custom: data.custom || [], - notes: data.notes || '', - } - - const dataBytes = getPaddedJsonBytes(recordPayload) - const encryptedData = await platform.aesGcmEncrypt(dataBytes, recordKey) - const encryptedRecordKey = await platform.aesGcmEncrypt(recordKey, auth.dataKey!) - - const recordAdd: Records.IRecordAdd = { - recordUid: recordUidBytes, - recordKey: encryptedRecordKey, - clientModifiedTime: Date.now(), - data: encryptedData, - folderType: Records.RecordFolderType.user_folder, - } - - if (folderUid) { - recordAdd.folderUid = normal64Bytes(folderUid) - } - - const request: Records.IRecordsAddRequest = { - records: [recordAdd], - clientTime: Date.now(), - } - - const msg = recordsAddMessage(request) - const response = await auth.executeRest(msg) - - const recordStatus = response.records?.[0] - const success = - recordStatus?.status === Records.RecordModifyResult.RS_SUCCESS || - !recordStatus?.status - - return { - recordUid, - success, - status: recordStatus?.status != null - ? Records.RecordModifyResult[recordStatus.status] - : ResultCode.OK, - } + const recordUidBytes = generateUidBytes(); + const recordKey = generateEncryptionKey(); + const recordUid = webSafe64FromBytes(recordUidBytes); + + const recordPayload = { + type: data.type, + title: data.title, + fields: data.fields || [], + custom: data.custom || [], + notes: data.notes || "", + }; + + const dataBytes = getPaddedJsonBytes(recordPayload); + const encryptedData = await platform.aesGcmEncrypt(dataBytes, recordKey); + const encryptedRecordKey = await platform.aesGcmEncrypt( + recordKey, + auth.dataKey!, + ); + + const recordAdd: Records.IRecordAdd = { + recordUid: recordUidBytes, + recordKey: encryptedRecordKey, + clientModifiedTime: Date.now(), + data: encryptedData, + folderType: Records.RecordFolderType.user_folder, + }; + + if (folderUid) { + recordAdd.folderUid = normal64Bytes(folderUid); + } + + const request: Records.IRecordsAddRequest = { + records: [recordAdd], + clientTime: Date.now(), + }; + + const msg = recordsAddMessage(request); + const response = await auth.executeRest(msg); + + const recordStatus = response.records?.[0]; + const success = + recordStatus?.status === Records.RecordModifyResult.RS_SUCCESS || + !recordStatus?.status; + + return { + recordUid, + success, + status: + recordStatus?.status != null + ? Records.RecordModifyResult[recordStatus.status] + : ResultCode.OK, + }; } export async function updateRecord( - auth: Auth, - recordUid: string, - data: TypedRecordData, - revision: number, - recordKey: Uint8Array + auth: Auth, + recordUid: string, + data: TypedRecordData, + revision: number, + recordKey: Uint8Array, ): Promise { - if (!data.title || !data.title.trim()) { - throw new KeeperSdkError('Record title is required.', 'missing_record_title') - } - if (!data.type?.trim()) { - throw new KeeperSdkError('Record type is required.', 'missing_record_type') - } - const recordUidBytes = normal64Bytes(recordUid) - - const recordPayload = { - type: data.type, - title: data.title, - fields: data.fields || [], - custom: data.custom || [], - notes: data.notes || '', - } - - const dataBytes = getPaddedJsonBytes(recordPayload) - const encryptedData = await platform.aesGcmEncrypt(dataBytes, recordKey) - - const recordUpdate: Records.IRecordUpdate = { - recordUid: recordUidBytes, - clientModifiedTime: Date.now(), - revision, - data: encryptedData, - } - - const request: Records.IRecordsUpdateRequest = { - records: [recordUpdate], - clientTime: Date.now(), - } - - const msg = recordsUpdateMessage(request) - const response = await auth.executeRest(msg) - - const recordStatus = response.records?.[0] - const success = - recordStatus?.status === Records.RecordModifyResult.RS_SUCCESS || - !recordStatus?.status - - return { - recordUid, - success, - status: recordStatus?.status != null - ? Records.RecordModifyResult[recordStatus.status] - : ResultCode.OK, - } + if (!data.title || !data.title.trim()) { + throw new KeeperSdkError( + "Record title is required.", + "missing_record_title", + ); + } + if (!data.type?.trim()) { + throw new KeeperSdkError("Record type is required.", "missing_record_type"); + } + const recordUidBytes = normal64Bytes(recordUid); + + const recordPayload = { + type: data.type, + title: data.title, + fields: data.fields || [], + custom: data.custom || [], + notes: data.notes || "", + }; + + const dataBytes = getPaddedJsonBytes(recordPayload); + const encryptedData = await platform.aesGcmEncrypt(dataBytes, recordKey); + + const recordUpdate: Records.IRecordUpdate = { + recordUid: recordUidBytes, + clientModifiedTime: Date.now(), + revision, + data: encryptedData, + }; + + const request: Records.IRecordsUpdateRequest = { + records: [recordUpdate], + clientTime: Date.now(), + }; + + const msg = recordsUpdateMessage(request); + const response = await auth.executeRest(msg); + + const recordStatus = response.records?.[0]; + const success = + recordStatus?.status === Records.RecordModifyResult.RS_SUCCESS || + !recordStatus?.status; + + return { + recordUid, + success, + status: + recordStatus?.status != null + ? Records.RecordModifyResult[recordStatus.status] + : ResultCode.OK, + }; } export async function deleteRecord( - auth: Auth, - recordUid: string + auth: Auth, + storage: InMemoryStorage, + recordUid: string, ): Promise { - const preDeleteRequest = { - objects: [ - { - object_uid: recordUid, - object_type: VaultObjectKind.Record, - from_uid: '', - from_type: FolderKind.UserFolder, - delete_resolution: DeleteResolution.Unlink, - } as RecordPreDeleteObject, - ], - } - - let preDeleteResponse: KeeperPreDeleteResponse - try { - const preDeleteCmd = recordPreDeleteCommand(preDeleteRequest) - preDeleteResponse = await auth.executeRestCommand(preDeleteCmd) - } catch (err) { - return { recordUid, success: false, message: extractErrorMessage(err) } - } - - const token = preDeleteResponse?.pre_delete_response?.pre_delete_token - if (!token) { - return { - recordUid, - success: false, - message: preDeleteResponse?.message || preDeleteResponse?.result_code || 'pre_delete failed: no token', - } - } - - try { - const deleteCmd = recordDeleteCommand({ pre_delete_token: token }) - await auth.executeRestCommand(deleteCmd) - } catch (err) { - return { recordUid, success: false, message: extractErrorMessage(err) } - } - - return { recordUid, success: true, message: ResultCode.Success } + const folderLinks = await findAllRecordFolderLinks(recordUid, storage); + const objects: RecordPreDeleteObject[] = folderLinks.map((src) => ({ + object_uid: recordUid, + object_type: "record", + from_uid: src.uid || "", + from_type: src.folderType, + delete_resolution: DeleteResolution.Unlink, + })); + + const preDeleteRequest = { objects }; + + let preDeleteResponse: KeeperPreDeleteResponse; + try { + const preDeleteCmd = recordPreDeleteCommand(preDeleteRequest); + preDeleteResponse = await auth.executeRestCommand(preDeleteCmd); + } catch (err) { + return { recordUid, success: false, message: extractErrorMessage(err) }; + } + + const token = preDeleteResponse?.pre_delete_response?.pre_delete_token; + if (!token) { + return { + recordUid, + success: false, + message: + preDeleteResponse?.message || + preDeleteResponse?.result_code || + "pre_delete failed: no token", + }; + } + + try { + const deleteCmd = recordDeleteCommand({ pre_delete_token: token }); + await auth.executeRestCommand(deleteCmd); + } catch (err) { + return { recordUid, success: false, message: extractErrorMessage(err) }; + } + + try { + await storage.delete(VaultObjectKind.Record, recordUid); + for (const src of folderLinks) { + if (src.uid) { + await storage.removeDependencies({ [src.uid]: new Set([recordUid]) }); + } + } + } catch (err) { + logger.debug( + `Failed to purge local record ${recordUid}:`, + extractErrorMessage(err), + ); + } + + return { recordUid, success: true, message: ResultCode.Success }; } export type HistoryEntry = { - revision: number - version: number - userName: string - clientModifiedTime: number - data: Record | null -} + revision: number; + version: number; + userName: string; + clientModifiedTime: number; + data: Record | null; +}; export type RecordHistoryResult = { - recordUid: string - history: HistoryEntry[] -} + recordUid: string; + history: HistoryEntry[]; +}; export async function getRecordHistory( - auth: Auth, - recordUid: string, - recordKey: Uint8Array + auth: Auth, + recordUid: string, + recordKey: Uint8Array, ): Promise { - const cmd = getRecordHistoryCommand({ - record_uid: recordUid, - client_time: Date.now(), - }) - - let response: GetRecordHistoryResponse - try { - response = await auth.executeRestCommand(cmd) - } catch (err) { - throw KeeperSdkError.from(err) - } - - const rawHistory = response.history || [] - const history: HistoryEntry[] = [] - - for (const entry of rawHistory) { - let decryptedData: Record | null = null - - if (entry.data) { - try { - const dataBytes = normal64Bytes(entry.data) - const version = entry.version || 0 - let decrypted: Uint8Array - - if (version <= RecordVersion.Legacy) { - decrypted = await platform.aesCbcDecrypt(dataBytes, recordKey, true) - } else { - decrypted = await platform.aesGcmDecrypt(dataBytes, recordKey) - } - - decryptedData = JSON.parse(platform.bytesToString(decrypted)) - } catch (err) { - logger.debug(`Failed to decrypt history revision ${entry.revision}:`, extractErrorMessage(err)) - decryptedData = null - } + const cmd = getRecordHistoryCommand({ + record_uid: recordUid, + client_time: Date.now(), + }); + + let response: GetRecordHistoryResponse; + try { + response = await auth.executeRestCommand(cmd); + } catch (err) { + throw KeeperSdkError.from(err); + } + + const rawHistory = response.history || []; + const history: HistoryEntry[] = []; + + for (const entry of rawHistory) { + let decryptedData: Record | null = null; + + if (entry.data) { + try { + const dataBytes = normal64Bytes(entry.data); + const version = entry.version || 0; + let decrypted: Uint8Array; + + if (version <= RecordVersion.Legacy) { + decrypted = await platform.aesCbcDecrypt(dataBytes, recordKey, true); + } else { + decrypted = await platform.aesGcmDecrypt(dataBytes, recordKey); } - history.push({ - revision: entry.revision, - version: entry.version, - userName: entry.user_name || '', - clientModifiedTime: entry.client_modified_time || 0, - data: decryptedData, - }) - } - - return { recordUid, history } + decryptedData = JSON.parse(platform.bytesToString(decrypted)); + } catch (err) { + logger.debug( + `Failed to decrypt history revision ${entry.revision}:`, + extractErrorMessage(err), + ); + decryptedData = null; + } + } + + history.push({ + revision: entry.revision, + version: entry.version, + userName: entry.user_name || "", + clientModifiedTime: entry.client_modified_time || 0, + data: decryptedData, + }); + } + + return { recordUid, history }; } export type MoveRecordInput = { - recordUid: string - dstFolderUid: string - srcFolderUid?: string - link?: boolean - canEdit?: boolean - canShare?: boolean -} + recordUid: string; + dstFolderUid: string; + srcFolderUid?: string; + link?: boolean; + canEdit?: boolean; + canShare?: boolean; +}; export type MoveRecordResult = { - recordUid: string - success: boolean - message: string -} + recordUid: string; + success: boolean; + message: string; +}; type FolderInfo = { - uid: string - folderType: FolderKind - scopeUid: string -} + uid: string; + folderType: FolderKind; + scopeUid: string; +}; function resolveFolder(uid: string, storage: InMemoryStorage): FolderInfo { - if (!uid) { - return { uid: '', folderType: FolderKind.UserFolder, scopeUid: '' } - } + if (!uid) { + return { uid: "", folderType: FolderKind.UserFolder, scopeUid: "" }; + } + + if (storage.getByUid(FolderKind.UserFolder, uid)) { + return { uid, folderType: FolderKind.UserFolder, scopeUid: "" }; + } + + if (storage.getByUid(FolderKind.SharedFolder, uid)) { + return { uid, folderType: FolderKind.SharedFolder, scopeUid: uid }; + } + + const sfFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + uid, + ); + if (sfFolder) { + return { + uid, + folderType: FolderKind.SharedFolderFolder, + scopeUid: sfFolder.sharedFolderUid, + }; + } - if (storage.getByUid(FolderKind.UserFolder, uid)) { - return { uid, folderType: FolderKind.UserFolder, scopeUid: '' } - } + return { uid, folderType: FolderKind.UserFolder, scopeUid: "" }; +} - if (storage.getByUid(FolderKind.SharedFolder, uid)) { - return { uid, folderType: FolderKind.SharedFolder, scopeUid: uid } +async function findAllRecordFolderLinks( + recordUid: string, + storage: InMemoryStorage, +): Promise { + const links: FolderInfo[] = []; + const seen = new Set(); + + const folderKinds = [ + FolderKind.UserFolder, + FolderKind.SharedFolder, + FolderKind.SharedFolderFolder, + ] as const; + + for (const kind of folderKinds) { + for (const folder of storage.getAll< + DUserFolder | DSharedFolder | DSharedFolderFolder + >(kind)) { + const dependencies = (await storage.getDependencies(folder.uid)) || []; + if ( + dependencies.some( + (dependency) => + dependency.kind === VaultObjectKind.Record && + dependency.uid === recordUid, + ) + ) { + const info = resolveFolder(folder.uid, storage); + const key = `${info.folderType}:${info.uid}`; + if (!seen.has(key)) { + seen.add(key); + links.push(info); + } + } } + } - const sfFolder = storage.getByUid(FolderKind.SharedFolderFolder, uid) - if (sfFolder) { - return { uid, folderType: FolderKind.SharedFolderFolder, scopeUid: sfFolder.sharedFolderUid } + const sharedFolderRecord = storage + .getAll(VaultObjectKind.SharedFolderRecord) + .find((candidate) => candidate.recordUid === recordUid); + if (sharedFolderRecord) { + const info = resolveFolder(sharedFolderRecord.sharedFolderUid, storage); + const key = `${info.folderType}:${info.uid}`; + if (!seen.has(key)) { + seen.add(key); + links.push(info); } + } - return { uid, folderType: FolderKind.UserFolder, scopeUid: '' } -} + if (links.length === 0) { + links.push(resolveFolder("", storage)); + } -async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorage): Promise { - const folderKinds = [ - FolderKind.UserFolder, - FolderKind.SharedFolder, - FolderKind.SharedFolderFolder, - ] as const - - for (const kind of folderKinds) { - for (const folder of storage.getAll(kind)) { - const dependencies = (await storage.getDependencies(folder.uid)) || [] - if ( - dependencies.some( - (dependency) => dependency.kind === VaultObjectKind.Record && dependency.uid === recordUid - ) - ) { - return folder.uid - } - } - } + return links; +} - const sharedFolderRecord = storage - .getAll(VaultObjectKind.SharedFolderRecord) - .find((candidate) => candidate.recordUid === recordUid) - return sharedFolderRecord ? sharedFolderRecord.sharedFolderUid : '' +async function findRecordSourceFolder( + recordUid: string, + storage: InMemoryStorage, +): Promise { + const links = await findAllRecordFolderLinks(recordUid, storage); + return links[0]?.uid ?? ""; } export async function moveRecord( - auth: Auth, - storage: InMemoryStorage, - input: MoveRecordInput + auth: Auth, + storage: InMemoryStorage, + input: MoveRecordInput, ): Promise { - const { - recordUid, - dstFolderUid, - link = false, - canEdit, - canShare, - } = input - - const dst = resolveFolder(dstFolderUid, storage) - - const srcUid = - input.srcFolderUid !== undefined ? input.srcFolderUid : await findRecordSourceFolder(recordUid, storage) - const src = resolveFolder(srcUid, storage) - - const moveObj: MoveObject = { - uid: recordUid, - type: VaultObjectKind.Record, - cascade: false, - from_type: src.folderType, - from_uid: src.uid || undefined, - can_edit: canEdit, - can_reshare: canShare, - } + const { recordUid, dstFolderUid, link = false, canEdit, canShare } = input; - const transitionKeys: TransitionKeyObject[] = [] + const dst = resolveFolder(dstFolderUid, storage); - if (src.scopeUid !== dst.scopeUid) { - const recordKey = await storage.getKeyBytes(recordUid) - if (!recordKey) { - return { recordUid, success: false, message: 'Record key not found' } - } - - let dstKey: Uint8Array | undefined - if (dst.scopeUid) { - dstKey = await storage.getKeyBytes(dst.scopeUid) - } else { - dstKey = auth.dataKey - } + const srcUid = + input.srcFolderUid !== undefined + ? input.srcFolderUid + : await findRecordSourceFolder(recordUid, storage); + const src = resolveFolder(srcUid, storage); - if (!dstKey) { - return { recordUid, success: false, message: 'Destination folder key not found' } - } + const moveObj: MoveObject = { + uid: recordUid, + type: VaultObjectKind.Record, + cascade: false, + from_type: src.folderType, + from_uid: src.uid || undefined, + can_edit: canEdit, + can_reshare: canShare, + }; - const record = storage.getByUid(VaultObjectKind.Record, recordUid) - const version = record?.version || RecordVersion.Typed + const transitionKeys: TransitionKeyObject[] = []; - let encryptedKey: Uint8Array - if (version >= RecordVersion.Typed) { - encryptedKey = await platform.aesGcmEncrypt(recordKey, dstKey) - } else { - encryptedKey = await platform.aesCbcEncrypt(recordKey, dstKey, true) - } - - transitionKeys.push({ uid: recordUid, key: webSafe64FromBytes(encryptedKey) }) + if (src.scopeUid !== dst.scopeUid) { + const recordKey = await storage.getKeyBytes(recordUid); + if (!recordKey) { + return { recordUid, success: false, message: "Record key not found" }; } - const request: MoveRequest = { - to_type: dst.folderType, - to_uid: dst.uid || undefined, - link, - move: [moveObj], - transition_keys: transitionKeys, + let dstKey: Uint8Array | undefined; + if (dst.scopeUid) { + dstKey = await storage.getKeyBytes(dst.scopeUid); + } else { + dstKey = auth.dataKey; } - try { - const cmd = moveCommand(request) - await auth.executeRestCommand(cmd) - } catch (err) { - return { recordUid, success: false, message: extractErrorMessage(err) } - } - - return { recordUid, success: true, message: 'Record moved successfully' } + if (!dstKey) { + return { + recordUid, + success: false, + message: "Destination folder key not found", + }; + } + + const record = storage.getByUid(VaultObjectKind.Record, recordUid); + const version = record?.version || RecordVersion.Typed; + + let encryptedKey: Uint8Array; + if (version >= RecordVersion.Typed) { + encryptedKey = await platform.aesGcmEncrypt(recordKey, dstKey); + } else { + encryptedKey = await platform.aesCbcEncrypt(recordKey, dstKey, true); + } + + transitionKeys.push({ + uid: recordUid, + key: webSafe64FromBytes(encryptedKey), + }); + } + + const request: MoveRequest = { + to_type: dst.folderType, + to_uid: dst.uid || undefined, + link, + move: [moveObj], + transition_keys: transitionKeys, + }; + + try { + const cmd = moveCommand(request); + await auth.executeRestCommand(cmd); + } catch (err) { + return { recordUid, success: false, message: extractErrorMessage(err) }; + } + + return { recordUid, success: true, message: "Record moved successfully" }; } diff --git a/KeeperSdk/src/records/RecordUtils.ts b/KeeperSdk/src/records/RecordUtils.ts index 74630faa..d296d1c5 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -1,282 +1,471 @@ -import type { DRecord } from '@keeper-security/keeperapi' -import { getTotpCode } from './Totp' +import type { DRecord } from "@keeper-security/keeperapi"; +import { getTotpCode } from "./Totp"; enum FieldType { - Login = 'login', - Password = 'password', - Url = 'url', - Note = 'note', - Text = 'text', + Login = "login", + Password = "password", + Url = "url", + Note = "note", + Text = "text", } export enum RecordVersion { - Legacy = 2, - Typed = 3, + Legacy = 2, + Typed = 3, } -const TOTP_FIELD_TYPES = new Set(['totp', 'oneTimeCode', 'otp']) -const MASKED_VALUE = '********' -const RECORD_SEPARATOR = '-'.repeat(50) +const TOTP_FIELD_TYPES = new Set(["totp", "oneTimeCode", "otp"]); +const MASKED_VALUE = "********"; +const RECORD_SEPARATOR = "-".repeat(50); type RecordField = { - type: string - value: any[] - label?: string - required?: boolean - privacyScreen?: boolean - enforceGeneration?: boolean - complexity?: { - length?: number - caps?: number - lowercase?: number - digits?: number - special?: number - } -} + type: string; + value: any[]; + label?: string; + required?: boolean; + privacyScreen?: boolean; + enforceGeneration?: boolean; + complexity?: { + length?: number; + caps?: number; + lowercase?: number; + digits?: number; + special?: number; + }; +}; type LegacyExtraField = { - type?: string - field_type?: string - value?: unknown - data?: unknown - label?: string - field_title?: string -} + type?: string; + field_type?: string; + value?: unknown; + data?: unknown; + label?: string; + field_title?: string; +}; function toFieldValueArray(v: unknown): any[] { - if (v == null) return [] - return Array.isArray(v) ? v : [v] + if (v == null || v === "") return []; + return Array.isArray(v) ? v : [v]; } -function getLegacyExtraFields(record: DRecord): RecordField[] { - const raw = record.extra - if (raw == null) return [] - let extra: { fields?: LegacyExtraField[] } - if (typeof raw === 'string') { - try { - extra = JSON.parse(raw) - } catch { - return [] - } - } else if (typeof raw === 'object') { - extra = raw - } else { - return [] +function normalizeTypedFieldValue(value: unknown): any[] { + if (value == null || value === "") return []; + if (Array.isArray(value)) return value; + return [value]; +} + +function fieldHasValue(field: RecordField): boolean { + return field.value.some( + (v) => + extractTotpUrlFromValue(v) != null || formatRawFieldValue(v).length > 0, + ); +} + +function formatRawFieldValue(v: unknown): string { + if (v == null) return ""; + if (typeof v === "string") return v.trim(); + if (typeof v === "number" || typeof v === "boolean") return String(v); + return JSON.stringify(v); +} + +/** Extract otpauth URL or raw TOTP secret from a field value. */ +function extractTotpUrlFromValue(v: unknown): string | undefined { + if (v == null) return undefined; + if (typeof v === "string") { + const trimmed = v.trim(); + return trimmed || undefined; + } + if (typeof v === "object" && !Array.isArray(v)) { + const obj = v as Record; + for (const key of [ + "url", + "otpauth", + "otpAuth", + "totp", + "value", + "data", + "secret", + ]) { + const val = obj[key]; + if (typeof val === "string" && val.trim()) return val.trim(); + } + } + return undefined; +} + +function getExtraTotpUrl(record: DRecord): string | undefined { + for (const field of getLegacyExtraFields(record)) { + if (field.type !== "totp") continue; + for (const v of field.value) { + const url = extractTotpUrlFromValue(v); + if (url) return url; } - if (!Array.isArray(extra.fields)) return [] - const out: RecordField[] = [] - for (const f of extra.fields) { - const typeName = f.type || f.field_type || FieldType.Text - const rawVal = f.value !== undefined && f.value !== null ? f.value : f.data - out.push({ - type: typeName, - value: toFieldValueArray(rawVal), - label: f.label || f.field_title, - }) + } + return undefined; +} + +function getLegacyExtraFields(record: DRecord): RecordField[] { + const raw = record.extra; + if (raw == null) return []; + let extra: { fields?: LegacyExtraField[] }; + if (typeof raw === "string") { + try { + extra = JSON.parse(raw); + } catch { + return []; } - return out + } else if (typeof raw === "object") { + extra = raw; + } else { + return []; + } + if (!Array.isArray(extra.fields)) return []; + const out: RecordField[] = []; + for (const f of extra.fields) { + const typeName = f.type || f.field_type || FieldType.Text; + const rawVal = f.value !== undefined && f.value !== null ? f.value : f.data; + out.push({ + type: typeName, + value: toFieldValueArray(rawVal), + label: f.label || f.field_title, + }); + } + return out; } export function getRecordTitle(record: DRecord): string { - if (!record.data) return '(no data)' - if (typeof record.data === 'string') { - try { - const parsed = JSON.parse(record.data) - return parsed.title || '(untitled)' - } catch { - return '(parse error)' - } + if (!record.data) return "(no data)"; + if (typeof record.data === "string") { + try { + const parsed = JSON.parse(record.data); + return parsed.title || "(untitled)"; + } catch { + return "(parse error)"; } - return record.data.title || '(untitled)' + } + return record.data.title || "(untitled)"; } export function getRecordType(record: DRecord): string { - if (record.version <= RecordVersion.Legacy) return 'legacy' - if (!record.data) return 'unknown' - return record.data.type || 'unknown' + if (record.version <= RecordVersion.Legacy) return "legacy"; + if (!record.data) return "unknown"; + return record.data.type || "unknown"; } export function getRecordFields(record: DRecord): RecordField[] { - if (!record.data) return [] - - if (record.version <= RecordVersion.Legacy) { - const fields: RecordField[] = [] - const d = record.data - if (d.secret1) fields.push({ type: FieldType.Login, value: [d.secret1] }) - if (d.secret2) fields.push({ type: FieldType.Password, value: [d.secret2] }) - if (d.link) fields.push({ type: FieldType.Url, value: [d.link] }) - if (d.notes) fields.push({ type: FieldType.Note, value: [d.notes] }) - if (Array.isArray(d.custom)) { - for (const c of d.custom) { - if (!c) continue - fields.push({ - type: c.type || FieldType.Text, - value: c.value != null && c.value !== '' ? [c.value] : [], - label: c.name, - }) - } - } - const extraFields = getLegacyExtraFields(record) - fields.push(...extraFields) - return fields + if (!record.data) return []; + + if (record.version <= RecordVersion.Legacy) { + const fields: RecordField[] = []; + const d = record.data; + if (d.secret1) fields.push({ type: FieldType.Login, value: [d.secret1] }); + if (d.secret2) + fields.push({ type: FieldType.Password, value: [d.secret2] }); + if (d.link) fields.push({ type: FieldType.Url, value: [d.link] }); + if (d.notes) fields.push({ type: FieldType.Note, value: [d.notes] }); + if (Array.isArray(d.custom)) { + for (const c of d.custom) { + if (!c) continue; + fields.push({ + type: c.type || FieldType.Text, + value: c.value != null && c.value !== "" ? [c.value] : [], + label: c.name, + }); + } } - - const fields: RecordField[] = [] - if (Array.isArray(record.data.fields)) { - for (const f of record.data.fields) { - fields.push({ - type: f.type || FieldType.Text, - value: Array.isArray(f.value) ? f.value : [f.value], - label: f.label, - required: f.required, - privacyScreen: f.privacyScreen, - enforceGeneration: f.enforceGeneration, - complexity: f.complexity, - }) - } + const extraFields = getLegacyExtraFields(record); + fields.push(...extraFields); + return fields; + } + + const fields: RecordField[] = []; + if (Array.isArray(record.data.fields)) { + for (const f of record.data.fields) { + fields.push({ + type: f.type || FieldType.Text, + value: normalizeTypedFieldValue(f.value), + label: f.label, + required: f.required, + privacyScreen: f.privacyScreen, + enforceGeneration: f.enforceGeneration, + complexity: f.complexity, + }); } - if (Array.isArray(record.data.custom)) { - for (const f of record.data.custom) { - fields.push({ - type: f.type || FieldType.Text, - value: Array.isArray(f.value) ? f.value : [f.value], - label: f.label, - required: f.required, - privacyScreen: f.privacyScreen, - enforceGeneration: f.enforceGeneration, - complexity: f.complexity, - }) - } + } + if (Array.isArray(record.data.custom)) { + for (const f of record.data.custom) { + fields.push({ + type: f.type || FieldType.Text, + value: normalizeTypedFieldValue(f.value), + label: f.label, + required: f.required, + privacyScreen: f.privacyScreen, + enforceGeneration: f.enforceGeneration, + complexity: f.complexity, + }); } - return fields + } + return fields; } export type RecordSummary = { - login?: string - password?: string - url?: string - fields: RecordField[] -} + login?: string; + password?: string; + url?: string; + fields: RecordField[]; +}; export function getRecordSummary(record: DRecord): RecordSummary { - const fields = getRecordFields(record) - if (record.version <= RecordVersion.Legacy) { - return { - login: record.data?.secret1, - password: record.data?.secret2, - url: record.data?.link, - fields, - } + const fields = getRecordFields(record); + if (record.version <= RecordVersion.Legacy) { + return { + login: record.data?.secret1, + password: record.data?.secret2, + url: record.data?.link, + fields, + }; + } + + let login: string | undefined; + let password: string | undefined; + let url: string | undefined; + + for (const f of fields) { + if (!login && f.type === FieldType.Login && f.value.length > 0) { + login = String(f.value[0]); + } else if ( + !password && + f.type === FieldType.Password && + f.value.length > 0 + ) { + password = String(f.value[0]); + } else if (!url && f.type === FieldType.Url && f.value.length > 0) { + const val = f.value[0]; + url = typeof val === "string" ? val : val?.value || val?.url; } + } - let login: string | undefined - let password: string | undefined - let url: string | undefined - - for (const f of fields) { - if (!login && f.type === FieldType.Login && f.value.length > 0) { - login = String(f.value[0]) - } else if (!password && f.type === FieldType.Password && f.value.length > 0) { - password = String(f.value[0]) - } else if (!url && f.type === FieldType.Url && f.value.length > 0) { - const val = f.value[0] - url = typeof val === 'string' ? val : val?.value || val?.url - } - } - - return { login, password, url, fields } + return { login, password, url, fields }; } export function getRecordTotpUrl(record: DRecord): string | undefined { - for (const field of getRecordFields(record)) { - if (!TOTP_FIELD_TYPES.has(field.type)) continue - for (const v of field.value) { - if (typeof v === 'string' && v.trim()) return v.trim() - } + for (const field of getRecordFields(record)) { + if (!TOTP_FIELD_TYPES.has(field.type)) continue; + for (const v of field.value) { + const url = extractTotpUrlFromValue(v); + if (url) return url; } - return undefined + } + return getExtraTotpUrl(record); } export function getRecordPassword(record: DRecord): string | undefined { - return getRecordSummary(record).password + return getRecordSummary(record).password; } export function getRecordLogin(record: DRecord): string | undefined { - return getRecordSummary(record).login + return getRecordSummary(record).login; } export function getRecordUrl(record: DRecord): string | undefined { - return getRecordSummary(record).url + return getRecordSummary(record).url; } -const wordCache = new WeakMap() +export function getRecordDescription(record: DRecord): string { + if (record.version === 6) return "PAM Configuration"; -export function searchRecords(records: DRecord[], criteria: string): DRecord[] { - if (!criteria.trim()) return records + const summary = getRecordSummary(record); + const parts: string[] = []; + if (summary.login) parts.push(summary.login); + if (summary.url) parts.push(summary.url); + return parts.length > 0 ? parts.join(" @ ") : ""; +} - const searchWords = criteria.toLowerCase().split(/\s+/) +export function getRecordCategory(record: DRecord): "Classic" | "Nested" { + return record.isKeeperDriveData ? "Nested" : "Classic"; +} - return records.filter((record) => { - let words = wordCache.get(record) - if (!words) { - words = collectRecordWords(record) - wordCache.set(record, words) - } - return searchWords.every((sw) => words!.some((w) => w.includes(sw))) - }) +const wordCache = new WeakMap(); + +export function searchRecords(records: DRecord[], criteria: string): DRecord[] { + const trimmed = criteria.trim(); + if (!trimmed) return records; + + const searchWords = trimmed + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 0); + + return records.filter((record) => { + const uidLower = record.uid?.toLowerCase() ?? ""; + if (uidLower && searchWords.every((sw) => uidLower.includes(sw))) { + return true; + } + + let words = wordCache.get(record); + if (!words) { + words = collectRecordWords(record); + wordCache.set(record, words); + } + return searchWords.every((sw) => words!.some((w) => w.includes(sw))); + }); } function collectRecordWords(record: DRecord): string[] { - const words: string[] = [] - const title = getRecordTitle(record) - if (title) words.push(...title.toLowerCase().split(/\s+/)) - - for (const field of getRecordFields(record)) { - if (field.label) words.push(field.label.toLowerCase()) - for (const v of field.value) { - if (typeof v === 'string') { - words.push(...v.toLowerCase().split(/\s+/)) - } else if (v && typeof v === 'object') { - for (const val of Object.values(v)) { - if (typeof val === 'string') { - words.push(...val.toLowerCase().split(/\s+/)) - } - } - } + const words: string[] = []; + const title = getRecordTitle(record); + if (title) words.push(...title.toLowerCase().split(/\s+/)); + + for (const field of getRecordFields(record)) { + if (field.label) words.push(field.label.toLowerCase()); + for (const v of field.value) { + if (typeof v === "string") { + words.push(...v.toLowerCase().split(/\s+/)); + } else if (v && typeof v === "object") { + for (const val of Object.values(v)) { + if (typeof val === "string") { + words.push(...val.toLowerCase().split(/\s+/)); + } } + } } + } - words.push(record.uid) - return words + words.push(record.uid.toLowerCase()); + return words; } -export function formatRecord(record: DRecord, showDetails = false): string { - const summary = getRecordSummary(record) - const lines: string[] = [ - RECORD_SEPARATOR, - `Title: ${getRecordTitle(record)}`, - `Record UID: ${record.uid}`, - `Record Type: ${getRecordType(record)}`, - ] - - if (summary.login) lines.push(`Username: ${summary.login}`) - if (summary.url) lines.push(`URL: ${summary.url}`) - - if (showDetails) { - for (const field of summary.fields) { - if (field.type === FieldType.Login || field.type === FieldType.Url) continue - const isTotp = TOTP_FIELD_TYPES.has(field.type) - const isSensitive = field.type === FieldType.Password || isTotp - const label = isTotp ? 'TOTP URL' : field.label || field.type - lines.push(`${label}: ${isSensitive ? MASKED_VALUE : field.value.join(', ')}`) - } +export type FormatRecordOptions = { + showDetails?: boolean; + unmask?: boolean; +}; + +function resolveFormatRecordOptions( + showDetailsOrOptions?: boolean | FormatRecordOptions, +): { + showDetails: boolean; + unmask: boolean; +} { + if (typeof showDetailsOrOptions === "boolean") { + return { showDetails: showDetailsOrOptions, unmask: false }; + } + return { + showDetails: showDetailsOrOptions?.showDetails ?? false, + unmask: showDetailsOrOptions?.unmask ?? false, + }; +} - const totpUrl = getRecordTotpUrl(record) - const code = totpUrl ? getTotpCode(totpUrl) : null - if (code) { - lines.push(`Two Factor Code: ${code.code} valid for ${code.secondsRemaining} sec`) - } +function formatFieldValue(field: RecordField, unmask: boolean): string { + if (!fieldHasValue(field)) return ""; + const isTotp = TOTP_FIELD_TYPES.has(field.type); + const isSensitive = + field.type === FieldType.Password || isTotp || field.privacyScreen === true; + if (!isSensitive || unmask) { + return field.value + .map((v) => formatRawFieldValue(v)) + .filter(Boolean) + .join(", "); + } + return MASKED_VALUE; +} + +function appendTotpFields( + fields: { name: string; value: unknown }[], + record: DRecord, + unmask: boolean, +): void { + const totpUrl = getRecordTotpUrl(record); + if (!totpUrl) return; + fields.push({ name: "TOTP URL", value: unmask ? totpUrl : MASKED_VALUE }); + const code = getTotpCode(totpUrl); + if (code) { + fields.push({ + name: "Two Factor Code", + value: `${code.code} (valid for ${code.secondsRemaining} sec)`, + }); + } +} + +export function formatRecordFields( + record: DRecord, + unmask: boolean, +): { name: string; value: unknown }[] { + const summary = getRecordSummary(record); + const fields: { name: string; value: unknown }[] = [ + { name: "title", value: getRecordTitle(record) }, + { name: "record_uid", value: record.uid }, + { name: "version", value: record.version }, + { name: "record_type", value: getRecordType(record) }, + ]; + if (summary.login) fields.push({ name: "login", value: summary.login }); + if (summary.password) { + fields.push({ + name: "password", + value: unmask ? summary.password : MASKED_VALUE, + }); + } + if (summary.url) fields.push({ name: "login_url", value: summary.url }); + for (const field of summary.fields) { + if (field.type === FieldType.Login || field.type === FieldType.Url) + continue; + if (field.type === FieldType.Password) continue; + if (TOTP_FIELD_TYPES.has(field.type)) continue; + if (!fieldHasValue(field)) continue; + const label = (field.label || field.type) + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + fields.push({ name: label, value: formatFieldValue(field, unmask) }); + } + appendTotpFields(fields, record, unmask); + const notes = + record.version <= RecordVersion.Legacy ? record.data?.notes : undefined; + if (notes) fields.push({ name: "Notes", value: notes }); + return fields; +} + +export function formatRecord( + record: DRecord, + showDetailsOrOptions?: boolean | FormatRecordOptions, +): string { + const { showDetails, unmask } = + resolveFormatRecordOptions(showDetailsOrOptions); + const summary = getRecordSummary(record); + const lines: string[] = [ + RECORD_SEPARATOR, + `Title: ${getRecordTitle(record)}`, + `Record UID: ${record.uid}`, + `Record Type: ${getRecordType(record)}`, + ]; + + if (summary.login) lines.push(`Username: ${summary.login}`); + if (summary.url) lines.push(`URL: ${summary.url}`); + if (summary.password) { + lines.push(`Password: ${unmask ? summary.password : MASKED_VALUE}`); + } + + if (showDetails) { + for (const field of summary.fields) { + if (field.type === FieldType.Login || field.type === FieldType.Url) + continue; + if (field.type === FieldType.Password) continue; + if (TOTP_FIELD_TYPES.has(field.type)) continue; + if (!fieldHasValue(field)) continue; + const label = field.label || field.type; + const value = formatFieldValue(field, unmask); + if (value) lines.push(`${label}: ${value}`); + } + + const totpUrl = getRecordTotpUrl(record); + if (totpUrl) { + lines.push(`TOTP URL: ${unmask ? totpUrl : MASKED_VALUE}`); + const code = getTotpCode(totpUrl); + if (code) { + lines.push( + `Two Factor Code: ${code.code} valid for ${code.secondsRemaining} sec`, + ); + } } + } - return lines.join('\n') + return lines.join("\n"); } diff --git a/KeeperSdk/src/records/Totp.ts b/KeeperSdk/src/records/Totp.ts index 1ba67d53..acd9c99a 100644 --- a/KeeperSdk/src/records/Totp.ts +++ b/KeeperSdk/src/records/Totp.ts @@ -1,119 +1,123 @@ -import { createHmac } from 'crypto' +import { getSdkPlatform } from "../platform"; -export type TotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512' +export type TotpAlgorithm = "SHA1" | "SHA256" | "SHA512"; export type TotpParams = { - secret: string - algorithm: TotpAlgorithm - digits: number - period: number -} + secret: string; + algorithm: TotpAlgorithm; + digits: number; + period: number; +}; export type TotpCode = { - code: string - secondsRemaining: number - period: number -} + code: string; + secondsRemaining: number; + period: number; +}; -const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' -const DEFAULT_DIGITS = 6 -const DEFAULT_PERIOD = 30 -const DEFAULT_ALGORITHM: TotpAlgorithm = 'SHA1' -const UINT32_MAX = 0x100000000 +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +const DEFAULT_DIGITS = 6; +const DEFAULT_PERIOD = 30; +const DEFAULT_ALGORITHM: TotpAlgorithm = "SHA1"; +const UINT32_MAX = 0x100000000; function decodeBase32(input: string): Uint8Array { - const noWhitespace = input.replace(/\s+/g, '') - let endIndex = noWhitespace.length - while (endIndex > 0 && noWhitespace.charCodeAt(endIndex - 1) === 0x3d) endIndex-- - const cleaned = noWhitespace.slice(0, endIndex).toUpperCase() - const out: number[] = [] - let buffer = 0 - let bits = 0 - for (const ch of cleaned) { - const idx = BASE32_ALPHABET.indexOf(ch) - if (idx < 0) throw new Error(`Invalid base32 character "${ch}" in TOTP secret`) - buffer = (buffer << 5) | idx - bits += 5 - if (bits >= 8) { - bits -= 8 - out.push((buffer >> bits) & 0xff) - } + const cleaned = input.replace(/=+$/g, "").replace(/\s+/g, "").toUpperCase(); + const out: number[] = []; + let buffer = 0; + let bits = 0; + for (const ch of cleaned) { + const idx = BASE32_ALPHABET.indexOf(ch); + if (idx < 0) + throw new Error(`Invalid base32 character "${ch}" in TOTP secret`); + buffer = (buffer << 5) | idx; + bits += 5; + if (bits >= 8) { + bits -= 8; + out.push((buffer >> bits) & 0xff); } - return Uint8Array.from(out) + } + return Uint8Array.from(out); } function normalizeAlgorithm(value: string | null | undefined): TotpAlgorithm { - const upper = (value || DEFAULT_ALGORITHM).toUpperCase() - return upper === 'SHA256' || upper === 'SHA512' ? upper : DEFAULT_ALGORITHM + const upper = (value || DEFAULT_ALGORITHM).toUpperCase(); + return upper === "SHA256" || upper === "SHA512" ? upper : DEFAULT_ALGORITHM; } function parsePositiveInt(value: string | null, fallback: number): number { - const parsed = parseInt(value || '', 10) - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback + const parsed = parseInt(value || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } export function parseTotpUrl(url: string): TotpParams | null { - if (!url?.trim()) return null - let params: URLSearchParams - try { - if (url.startsWith('otpauth://')) { - const u = new URL(url) - if (u.hostname.toLowerCase() !== 'totp') return null - params = u.searchParams - } else { - params = new URLSearchParams(url) - } - } catch { - return null - } - const secret = (params.get('secret') || '').trim() - if (!secret) return null - return { - secret, - algorithm: normalizeAlgorithm(params.get('algorithm')), - digits: parsePositiveInt(params.get('digits'), DEFAULT_DIGITS), - period: parsePositiveInt(params.get('period'), DEFAULT_PERIOD), + if (!url?.trim()) return null; + let params: URLSearchParams; + try { + if (url.startsWith("otpauth://")) { + const u = new URL(url); + if (u.hostname.toLowerCase() !== "totp") return null; + params = u.searchParams; + } else { + params = new URLSearchParams(url); } + } catch { + return null; + } + const secret = (params.get("secret") || "").trim(); + if (!secret) return null; + return { + secret, + algorithm: normalizeAlgorithm(params.get("algorithm")), + digits: parsePositiveInt(params.get("digits"), DEFAULT_DIGITS), + period: parsePositiveInt(params.get("period"), DEFAULT_PERIOD), + }; } -function counterToBuffer(counter: number): Buffer { - const buf = Buffer.alloc(8) - buf.writeUInt32BE(Math.floor(counter / UINT32_MAX), 0) - buf.writeUInt32BE(counter % UINT32_MAX, 4) - return buf +function counterToBuffer(counter: number): Uint8Array { + const buf = new Uint8Array(8); + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + view.setUint32(0, Math.floor(counter / UINT32_MAX), false); + view.setUint32(4, counter >>> 0, false); + return buf; } -export function getTotpCode(urlOrParams: string | TotpParams, now: number = Date.now()): TotpCode | null { - const params = typeof urlOrParams === 'string' ? parseTotpUrl(urlOrParams) : urlOrParams - if (!params) return null - if (!Number.isFinite(params.period) || params.period <= 0) return null - if (!Number.isFinite(params.digits) || params.digits <= 0) return null +export function getTotpCode( + urlOrParams: string | TotpParams, + now: number = Date.now(), +): TotpCode | null { + const params = + typeof urlOrParams === "string" ? parseTotpUrl(urlOrParams) : urlOrParams; + if (!params) return null; + if (!Number.isFinite(params.period) || params.period <= 0) return null; + if (!Number.isFinite(params.digits) || params.digits <= 0) return null; - let key: Uint8Array - try { - key = decodeBase32(params.secret) - } catch { - return null - } - if (key.length === 0) return null + let key: Uint8Array; + try { + key = decodeBase32(params.secret); + } catch { + return null; + } + if (key.length === 0) return null; - const seconds = Math.floor(now / 1000) - const counter = Math.floor(seconds / params.period) - const secondsRemaining = params.period - (seconds % params.period) + const seconds = Math.floor(now / 1000); + const counter = Math.floor(seconds / params.period); + const secondsRemaining = params.period - (seconds % params.period); - const digest = createHmac(params.algorithm.toLowerCase(), Buffer.from(key)) - .update(counterToBuffer(counter)) - .digest() + const algo = params.algorithm.toLowerCase() as "sha1" | "sha256" | "sha512"; + const digest = getSdkPlatform().hmac(algo, key, counterToBuffer(counter)); - if (digest.length === 0) return null - const offset = digest[digest.length - 1] & 0x0f - if (offset + 3 >= digest.length) return null - const binary = - ((digest[offset] & 0x7f) << 24) | - ((digest[offset + 1] & 0xff) << 16) | - ((digest[offset + 2] & 0xff) << 8) | - (digest[offset + 3] & 0xff) + if (digest.length === 0) return null; + const offset = digest[digest.length - 1] & 0x0f; + if (offset + 3 >= digest.length) return null; + const binary = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); - const code = (binary % 10 ** params.digits).toString().padStart(params.digits, '0') - return { code, secondsRemaining, period: params.period } + const code = (binary % 10 ** params.digits) + .toString() + .padStart(params.digits, "0"); + return { code, secondsRemaining, period: params.period }; } diff --git a/KeeperSdk/src/records/listRecordsTable.ts b/KeeperSdk/src/records/listRecordsTable.ts new file mode 100644 index 00000000..866e5196 --- /dev/null +++ b/KeeperSdk/src/records/listRecordsTable.ts @@ -0,0 +1,103 @@ +import type { DRecord } from "@keeper-security/keeperapi"; +import { + getRecordCategory, + getRecordDescription, + getRecordTitle, + getRecordType, +} from "./RecordUtils"; + +const DEFAULT_COLUMN_WIDTH = 40; +const MIN_TRUNCATE_PREFIX = 3; + +export type FormattedRecordsListTable = { + headers: string[]; + rows: string[][]; +}; + +function truncateText(text: string, maxLength: number | null): string { + if (!text) return ""; + if (maxLength == null || text.length <= maxLength) return text; + if (maxLength <= MIN_TRUNCATE_PREFIX) return text.slice(0, maxLength); + return `${text.slice(0, maxLength - MIN_TRUNCATE_PREFIX)}...`; +} + +function compareByTitle(recordA: DRecord, recordB: DRecord): number { + const titleA = getRecordTitle(recordA); + const titleB = getRecordTitle(recordB); + return titleA.localeCompare(titleB, undefined, { sensitivity: "base" }); +} + +export function formatRecordsListTable( + records: DRecord[], + options: { verbose?: boolean; columnWidth?: number } = {}, +): FormattedRecordsListTable { + const { verbose = false, columnWidth = DEFAULT_COLUMN_WIDTH } = options; + const maxWidth = verbose ? null : columnWidth; + const sorted = [...records].sort(compareByTitle); + const headers = [ + "#", + "Record uid", + "Type", + "Title", + "Description", + "Shared", + "Record category", + ]; + const rows = sorted.map((record, index) => { + const uid = truncateText(record.uid || "(unknown uid)", maxWidth); + const type = truncateText(getRecordType(record), maxWidth); + const title = truncateText(getRecordTitle(record), maxWidth); + const description = truncateText(getRecordDescription(record), maxWidth); + const shared = record.shared ? "True" : "False"; + const category = getRecordCategory(record); + return [String(index + 1), uid, type, title, description, shared, category]; + }); + return { headers, rows }; +} + +export function renderRecordsListAsciiTable( + table: FormattedRecordsListTable, + options: { minColWidth?: number } = {}, +): string { + const { minColWidth = 2 } = options; + const { headers, rows } = table; + const columnCount = headers.length; + const columnWidths: number[] = new Array(columnCount).fill(0); + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + columnWidths[columnIndex] = Math.max( + headers[columnIndex].length, + minColWidth, + ); + } + for (const row of rows) { + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + const cell = row[columnIndex] || ""; + columnWidths[columnIndex] = Math.max( + columnWidths[columnIndex], + cell.length, + minColWidth, + ); + } + } + const padCell = (cell: string, columnIndex: number) => + cell + " ".repeat(columnWidths[columnIndex] - cell.length); + const formatRow = (cells: string[]) => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + const ruleRow = Array.from({ length: columnCount }, (_unused, columnIndex) => + "-".repeat(columnWidths[columnIndex]), + ) + .map((dashes, columnIndex) => padCell(dashes, columnIndex)) + .join(" "); + const lines: string[] = [formatRow(headers), ruleRow]; + for (const row of rows) { + lines.push(formatRow(row)); + } + return lines.join("\n"); +} + +export function renderRecordsListTable( + records: DRecord[], + options: { verbose?: boolean; columnWidth?: number } = {}, +): string { + return renderRecordsListAsciiTable(formatRecordsListTable(records, options)); +} diff --git a/KeeperSdk/src/roles/RoleManager.ts b/KeeperSdk/src/roles/RoleManager.ts index 673bb2c7..12ce1ae7 100644 --- a/KeeperSdk/src/roles/RoleManager.ts +++ b/KeeperSdk/src/roles/RoleManager.ts @@ -1,144 +1,303 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' -import { EnterpriseDataManager } from '../teams/enterpriseData' -import { formatRolesTable, listRoles, renderRolesAsciiTable } from './listRoles' +import type { Auth } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; +import { EnterpriseDataManager } from "../teams/enterpriseData"; +import { + formatRolesTable, + listRoles, + renderRolesAsciiTable, +} from "./listRoles"; import type { - FormatRolesTableOptions, - FormattedRolesTable, - ListRoleRow, - ListRolesOptions, -} from './roleTypes' + FormatRolesTableOptions, + FormattedRolesTable, + ListRoleRow, + ListRolesOptions, +} from "./roleTypes"; +import { + formatRoleView, + roleViewTable, + viewRole, + type FormatRoleViewOptions, + type FormattedRoleViewTable, + type RoleView, +} from "./viewRole"; +import { + addRoles, + formatAddRoleResult, + renderAddRoleAsciiTable, + type AddRoleInput, + type AddRoleResult, + type FormattedAddRoleTable, +} from "./addRole"; +import { + updateRoles, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + type UpdateRoleInput, + type UpdateRoleResult, + type FormattedUpdateRoleTable, +} from "./updateRole"; +import { + deleteRoles, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + type DeleteRoleInput, + type DeleteRoleResult, + type FormattedDeleteRoleTable, +} from "./deleteRole"; import { - formatRoleView, - roleViewTable, - viewRole, - type FormatRoleViewOptions, - type FormattedRoleViewTable, - type RoleView, -} from './viewRole' + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + type SetRoleEnforcementsInput, + type SetRoleEnforcementsResult, + type FormattedRoleEnforcementTable, +} from "./roleEnforcement"; import { - addRoles, - formatAddRoleResult, - renderAddRoleAsciiTable, - type AddRoleInput, - type AddRoleResult, - type FormattedAddRoleTable, -} from './addRole' + copyRoles, + formatCopyRoleResult, + renderCopyRoleAsciiTable, + type CopyRoleInput, + type CopyRoleResult, + type FormattedCopyRoleTable, +} from "./copyRole"; import { - updateRoles, - formatUpdateRoleResult, - renderUpdateRoleAsciiTable, - type UpdateRoleInput, - type UpdateRoleResult, - type FormattedUpdateRoleTable, -} from './updateRole' + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + type AddUsersToRolesInput, + type RemoveUsersFromRolesInput, + type RoleUserResult, + type FormattedRoleUserTable, +} from "./roleUser"; import { - deleteRoles, - formatDeleteRoleResult, - renderDeleteRoleAsciiTable, - type DeleteRoleInput, - type DeleteRoleResult, - type FormattedDeleteRoleTable, -} from './deleteRole' + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + type ManageRoleNodesInput, + type ManageRoleNodesResult, + type FormattedManageRoleNodesTable, +} from "./manageNode"; +import { + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + type ChangeRolePrivilegesInput, + type ChangeRolePrivilegesResult, + type FormattedRolePrivilegeTable, +} from "./rolePrivilege"; -export type AuthProvider = () => Auth +export type AuthProvider = () => Auth; export class RoleManager { - private readonly authProvider: AuthProvider - private enterpriseData: EnterpriseDataManager | null = null + private readonly authProvider: AuthProvider; + private enterpriseData: EnterpriseDataManager | null = null; - constructor(authProvider: AuthProvider) { - this.authProvider = authProvider - } + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider; + } - public async listRoles(options: ListRolesOptions = {}): Promise { - return listRoles(this.requireAuth(), { - ...options, - enterpriseData: options.enterpriseData ?? this.getEnterpriseData(), - }) - } + public async listRoles( + options: ListRolesOptions = {}, + ): Promise { + return listRoles(this.requireAuth(), { + ...options, + enterpriseData: options.enterpriseData ?? this.getEnterpriseData(), + }); + } - public formatRolesTable(rows: ListRoleRow[], options: FormatRolesTableOptions = {}): FormattedRolesTable { - return formatRolesTable(rows, options) - } + public formatRolesTable( + rows: ListRoleRow[], + options: FormatRolesTableOptions = {}, + ): FormattedRolesTable { + return formatRolesTable(rows, options); + } - public renderRolesAsciiTable( - table: FormattedRolesTable, - options: { minColWidth?: number } = {} - ): string { - return renderRolesAsciiTable(table, options) - } + public renderRolesAsciiTable( + table: FormattedRolesTable, + options: { minColWidth?: number } = {}, + ): string { + return renderRolesAsciiTable(table, options); + } - public async viewRole(identifier: string): Promise { - return viewRole(this.requireAuth(), identifier) - } + public async viewRole(identifier: string): Promise { + return viewRole(this.requireAuth(), identifier); + } - public formatRoleView(view: RoleView, options: FormatRoleViewOptions = {}): FormattedRoleViewTable { - return formatRoleView(view, options) - } + public formatRoleView( + view: RoleView, + options: FormatRoleViewOptions = {}, + ): FormattedRoleViewTable { + return formatRoleView(view, options); + } - public roleViewTable(table: FormattedRoleViewTable): string { - return roleViewTable(table) - } + public roleViewTable(table: FormattedRoleViewTable): string { + return roleViewTable(table); + } - public async addRoles(input: AddRoleInput): Promise { - const result = await addRoles(this.requireAuth(), input) - if (result.created > 0) this.invalidateEnterpriseData() - return result - } + public async addRoles(input: AddRoleInput): Promise { + const result = await addRoles(this.requireAuth(), input); + if (result.created > 0) this.invalidateEnterpriseData(); + return result; + } - public formatAddRoleResult(result: AddRoleResult): FormattedAddRoleTable { - return formatAddRoleResult(result) - } + public formatAddRoleResult(result: AddRoleResult): FormattedAddRoleTable { + return formatAddRoleResult(result); + } - public renderAddRoleAsciiTable(table: FormattedAddRoleTable): string { - return renderAddRoleAsciiTable(table) - } + public renderAddRoleAsciiTable(table: FormattedAddRoleTable): string { + return renderAddRoleAsciiTable(table); + } - public async updateRoles(input: UpdateRoleInput): Promise { - const result = await updateRoles(this.requireAuth(), input) - if (result.updated > 0) this.invalidateEnterpriseData() - return result - } + public async updateRoles(input: UpdateRoleInput): Promise { + const result = await updateRoles(this.requireAuth(), input); + if (result.updated > 0) this.invalidateEnterpriseData(); + return result; + } - public formatUpdateRoleResult(result: UpdateRoleResult): FormattedUpdateRoleTable { - return formatUpdateRoleResult(result) - } + public formatUpdateRoleResult( + result: UpdateRoleResult, + ): FormattedUpdateRoleTable { + return formatUpdateRoleResult(result); + } - public renderUpdateRoleAsciiTable(table: FormattedUpdateRoleTable): string { - return renderUpdateRoleAsciiTable(table) - } + public renderUpdateRoleAsciiTable(table: FormattedUpdateRoleTable): string { + return renderUpdateRoleAsciiTable(table); + } - public async deleteRoles(input: DeleteRoleInput): Promise { - const result = await deleteRoles(this.requireAuth(), input) - if (result.deleted > 0) this.invalidateEnterpriseData() - return result - } + public async deleteRoles(input: DeleteRoleInput): Promise { + const result = await deleteRoles(this.requireAuth(), input); + if (result.deleted > 0) this.invalidateEnterpriseData(); + return result; + } - public formatDeleteRoleResult(result: DeleteRoleResult): FormattedDeleteRoleTable { - return formatDeleteRoleResult(result) - } + public formatDeleteRoleResult( + result: DeleteRoleResult, + ): FormattedDeleteRoleTable { + return formatDeleteRoleResult(result); + } - public renderDeleteRoleAsciiTable(table: FormattedDeleteRoleTable): string { - return renderDeleteRoleAsciiTable(table) - } + public renderDeleteRoleAsciiTable(table: FormattedDeleteRoleTable): string { + return renderDeleteRoleAsciiTable(table); + } - private getEnterpriseData(): EnterpriseDataManager { - if (!this.enterpriseData) { - this.enterpriseData = new EnterpriseDataManager(this.requireAuth()) - } - return this.enterpriseData - } + public async setRoleEnforcements( + input: SetRoleEnforcementsInput, + ): Promise { + const result = await setRoleEnforcements(this.requireAuth(), input); + if (result.applied > 0) this.invalidateEnterpriseData(); + return result; + } + + public formatSetRoleEnforcementsResult( + result: SetRoleEnforcementsResult, + ): FormattedRoleEnforcementTable { + return formatSetRoleEnforcementsResult(result); + } + + public renderRoleEnforcementAsciiTable( + table: FormattedRoleEnforcementTable, + ): string { + return renderRoleEnforcementAsciiTable(table); + } + + public async copyRoles(input: CopyRoleInput): Promise { + const result = await copyRoles(this.requireAuth(), input); + if (result.success) this.invalidateEnterpriseData(); + return result; + } + + public formatCopyRoleResult(result: CopyRoleResult): FormattedCopyRoleTable { + return formatCopyRoleResult(result); + } + + public renderCopyRoleAsciiTable(table: FormattedCopyRoleTable): string { + return renderCopyRoleAsciiTable(table); + } + + public async addUsersToRoles( + input: AddUsersToRolesInput, + ): Promise { + const result = await addUsersToRoles(this.requireAuth(), input); + if (result.succeeded > 0) this.invalidateEnterpriseData(); + return result; + } + + public async removeUsersFromRoles( + input: RemoveUsersFromRolesInput, + ): Promise { + const result = await removeUsersFromRoles(this.requireAuth(), input); + if (result.succeeded > 0) this.invalidateEnterpriseData(); + return result; + } - private invalidateEnterpriseData(): void { - this.enterpriseData?.clearCache() + public formatRoleUserResult(result: RoleUserResult): FormattedRoleUserTable { + return formatRoleUserResult(result); + } + + public renderRoleUserAsciiTable(table: FormattedRoleUserTable): string { + return renderRoleUserAsciiTable(table); + } + + public async manageRoleNodes( + input: ManageRoleNodesInput, + ): Promise { + const result = await manageRoleNodes(this.requireAuth(), input); + if (result.succeeded > 0) this.invalidateEnterpriseData(); + return result; + } + + public formatManageRoleNodesResult( + result: ManageRoleNodesResult, + ): FormattedManageRoleNodesTable { + return formatManageRoleNodesResult(result); + } + + public renderManageRoleNodesAsciiTable( + table: FormattedManageRoleNodesTable, + ): string { + return renderManageRoleNodesAsciiTable(table); + } + + public async changeRolePrivileges( + input: ChangeRolePrivilegesInput, + ): Promise { + const result = await changeRolePrivileges(this.requireAuth(), input); + if (result.succeeded > 0) this.invalidateEnterpriseData(); + return result; + } + + public formatChangeRolePrivilegesResult( + result: ChangeRolePrivilegesResult, + ): FormattedRolePrivilegeTable { + return formatChangeRolePrivilegesResult(result); + } + + public renderChangeRolePrivilegesAsciiTable( + table: FormattedRolePrivilegeTable, + ): string { + return renderChangeRolePrivilegesAsciiTable(table); + } + + private getEnterpriseData(): EnterpriseDataManager { + if (!this.enterpriseData) { + this.enterpriseData = new EnterpriseDataManager(this.requireAuth()); } + return this.enterpriseData; + } + + private invalidateEnterpriseData(): void { + this.enterpriseData?.clearCache(); + } - private requireAuth(): Auth { - const auth = this.authProvider() - if (!auth) { - throw new KeeperSdkError('You are not logged in. Please log in first.', ResultCodes.NOT_LOGGED_IN) - } - return auth + private requireAuth(): Auth { + const auth = this.authProvider(); + if (!auth) { + throw new KeeperSdkError( + "You are not logged in. Please log in first.", + ResultCodes.NOT_LOGGED_IN, + ); } + return auth; + } } diff --git a/KeeperSdk/src/roles/addRole.ts b/KeeperSdk/src/roles/addRole.ts index 4804110c..6e915a74 100644 --- a/KeeperSdk/src/roles/addRole.ts +++ b/KeeperSdk/src/roles/addRole.ts @@ -1,230 +1,322 @@ import { - encryptObjectForStorage, - enterpriseAllocateIdsCommand, - roleAddCommand, - type Auth, - type RoleEditRequest, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' -import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseRole } from '../teams/enterpriseData' + encryptObjectForStorage, + enterpriseAllocateIdsCommand, + roleAddCommand, + type Auth, + type RoleEditRequest, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; import { - applyDecryptedNodeNames, - applyEnterpriseNameToRoot, - parentNeedsNameLookup, - resolveParentNode, -} from '../teams/teamUtils' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRole, +} from "../teams/enterpriseData"; import { - applyDecryptedRoleNames, - applyRoleEnforcements, - assertCommandSucceeded, - buildRoleResultRows, - buildRolesByLowerName, - nodePathOrFallback, - normalizeIdentifiers, - parseEnforcements, - renderRoleResultTable, - resolveToggle, - ROLE_TABLE_HEADERS, - validateRoleName, - type RoleToggleInput, -} from './roleUtils' - -const ADD_ROLE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Nodes, EnterpriseDataInclude.Roles] + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from "../teams/teamUtils"; +import { + applyDecryptedRoleNames, + applyRoleEnforcements, + assertCommandSucceeded, + buildRoleResultRows, + buildRolesByLowerName, + nodePathOrFallback, + normalizeIdentifiers, + parseEnforcements, + renderRoleResultTable, + resolveToggle, + ROLE_TABLE_HEADERS, + validateRoleName, + type RoleToggleInput, +} from "./roleUtils"; + +const ADD_ROLE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, +]; export enum AddRoleStatus { - Created = 'created', - Skipped = 'skipped', - Failed = 'failed', + Created = "created", + Skipped = "skipped", + Failed = "failed", } export enum AddRoleSkipReason { - AlreadyExistsInParent = 'already_exists_in_parent', - ExistsElsewhereDeclined = 'exists_elsewhere_declined', + AlreadyExistsInParent = "already_exists_in_parent", + ExistsElsewhereDeclined = "exists_elsewhere_declined", } export type AddRoleConflictPrompt = { - roleName: string - parentNodeId: number - parentNodeName: string - existingRoleId: number - existingRoleName: string - existingNodeId: number -} + roleName: string; + parentNodeId: number; + parentNodeName: string; + existingRoleId: number; + existingRoleName: string; + existingNodeId: number; +}; -export type AddRoleConfirm = (prompt: AddRoleConflictPrompt) => boolean | Promise +export type AddRoleConfirm = ( + prompt: AddRoleConflictPrompt, +) => boolean | Promise; export type AddRoleInput = { - roles: string[] - parent?: string | number | null - newUser?: RoleToggleInput - visibleBelow?: RoleToggleInput - enforcements?: string[] - force?: boolean - confirm?: AddRoleConfirm -} + roles: string[]; + parent?: string | number | null; + newUser?: RoleToggleInput; + visibleBelow?: RoleToggleInput; + enforcements?: string[]; + force?: boolean; + confirm?: AddRoleConfirm; +}; export type AddRoleItemResult = { - roleId: number - roleName: string - nodeId: number - status: AddRoleStatus - skipReason?: AddRoleSkipReason - message?: string -} + roleId: number; + roleName: string; + nodeId: number; + status: AddRoleStatus; + skipReason?: AddRoleSkipReason; + message?: string; +}; export type AddRoleResult = { - success: boolean - parentNodeId: number - parentNodeName: string - items: AddRoleItemResult[] - created: number - skipped: number - failed: number -} + success: boolean; + parentNodeId: number; + parentNodeName: string; + items: AddRoleItemResult[]; + created: number; + skipped: number; + failed: number; +}; export type FormattedAddRoleTable = { - headers: string[] - rows: string[][] - parentNodeName: string - summary: string -} + headers: string[]; + rows: string[][]; + parentNodeName: string; + summary: string; +}; + +export async function addRoles( + auth: Auth, + input: AddRoleInput, +): Promise { + const requestedNames = normalizeIdentifiers(input.roles); + if (requestedNames.length === 0) { + throw new KeeperSdkError("No roles to add.", ResultCodes.NO_ROLES_TO_ADD); + } + requestedNames.forEach(validateRoleName); + + const force = input.force === true; + const newUserInherit = resolveToggle(input.newUser) ?? false; + const visibleBelow = resolveToggle(input.visibleBelow) ?? false; + const enforcements = parseEnforcements(input.enforcements ?? []); -export async function addRoles(auth: Auth, input: AddRoleInput): Promise { - const requestedNames = normalizeIdentifiers(input.roles) - if (requestedNames.length === 0) { - throw new KeeperSdkError('No roles to add.', ResultCodes.NO_ROLES_TO_ADD) + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ADD_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const nodes = response.nodes || []; + applyDecryptedNodeNames(nodes, displayNames.nodes); + applyEnterpriseNameToRoot(nodes, response.enterprise_name); + if (parentNeedsNameLookup(input.parent ?? null)) + await enterpriseData.decryptNodeNames(nodes); + + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + const rolesByLowerName = buildRolesByLowerName(roles); + + const parentNode = resolveParentNode(nodes, input.parent ?? null); + const parentNodeId = parentNode.node_id; + const parentNodeName = nodePathOrFallback(nodes, parentNode); + + const treeKey = await enterpriseData.getTreeKey(); + if (!treeKey) { + throw new KeeperSdkError( + "Enterprise tree key is unavailable. The current user may not have permission to administer roles.", + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + + const items: AddRoleItemResult[] = []; + const seenLowerNames = new Set(); + for (const name of requestedNames) { + const lower = name.toLowerCase(); + if (seenLowerNames.has(lower)) continue; + seenLowerNames.add(lower); + + const conflicts = rolesByLowerName.get(lower) || []; + const sameParent = conflicts.find((role) => role.node_id === parentNodeId); + if (sameParent) { + items.push( + skippedItem( + sameParent.role_id, + name, + sameParent.node_id, + AddRoleSkipReason.AlreadyExistsInParent, + `Role "${name}" already exists in parent node ${parentNodeId}.`, + ), + ); + continue; } - requestedNames.forEach(validateRoleName) - - const force = input.force === true - const newUserInherit = resolveToggle(input.newUser) ?? false - const visibleBelow = resolveToggle(input.visibleBelow) ?? false - const enforcements = parseEnforcements(input.enforcements ?? []) - - const enterpriseData = new EnterpriseDataManager(auth) - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(ADD_ROLE_INCLUDES), - enterpriseData.getDisplayNames(), - ]) - - const nodes = response.nodes || [] - applyDecryptedNodeNames(nodes, displayNames.nodes) - applyEnterpriseNameToRoot(nodes, response.enterprise_name) - if (parentNeedsNameLookup(input.parent ?? null)) await enterpriseData.decryptNodeNames(nodes) - - const roles = response.roles || [] - applyDecryptedRoleNames(roles, displayNames.roles) - const rolesByLowerName = buildRolesByLowerName(roles) - - const parentNode = resolveParentNode(nodes, input.parent ?? null) - const parentNodeId = parentNode.node_id - const parentNodeName = nodePathOrFallback(nodes, parentNode) - - const treeKey = await enterpriseData.getTreeKey() - if (!treeKey) { - throw new KeeperSdkError( - 'Enterprise tree key is unavailable. The current user may not have permission to administer roles.', - ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE - ) + + if (conflicts.length > 0 && !force) { + const confirmed = await confirmCrossNode(input.confirm, conflicts[0], { + roleName: name, + parentNodeId, + parentNodeName, + }); + if (!confirmed) { + items.push( + skippedItem( + conflicts[0].role_id, + name, + conflicts[0].node_id, + AddRoleSkipReason.ExistsElsewhereDeclined, + `Role "${name}" exists in node ${conflicts[0].node_id}; creation declined.`, + ), + ); + continue; + } } - const items: AddRoleItemResult[] = [] - const seenLowerNames = new Set() - for (const name of requestedNames) { - const lower = name.toLowerCase() - if (seenLowerNames.has(lower)) continue - seenLowerNames.add(lower) - - const conflicts = rolesByLowerName.get(lower) || [] - const sameParent = conflicts.find((role) => role.node_id === parentNodeId) - if (sameParent) { - items.push(skippedItem(sameParent.role_id, name, sameParent.node_id, AddRoleSkipReason.AlreadyExistsInParent, - `Role "${name}" already exists in parent node ${parentNodeId}.`)) - continue - } - - if (conflicts.length > 0 && !force) { - const confirmed = await confirmCrossNode(input.confirm, conflicts[0], { roleName: name, parentNodeId, parentNodeName }) - if (!confirmed) { - items.push(skippedItem(conflicts[0].role_id, name, conflicts[0].node_id, AddRoleSkipReason.ExistsElsewhereDeclined, - `Role "${name}" exists in node ${conflicts[0].node_id}; creation declined.`)) - continue - } - } - - try { - const roleId = await allocateRoleId(auth) - const encryptedData = await encryptObjectForStorage({ displayname: name }, treeKey) - await sendRoleAdd(auth, { - role_id: roleId, - node_id: parentNodeId, - encrypted_data: encryptedData, - visible_below: visibleBelow, - new_user_inherit: newUserInherit, - }) - await applyRoleEnforcements(auth, roleId, enforcements) - items.push({ roleId, roleName: name, nodeId: parentNodeId, status: AddRoleStatus.Created }) - } catch (err) { - items.push({ roleId: 0, roleName: name, nodeId: parentNodeId, status: AddRoleStatus.Failed, message: extractErrorMessage(err) }) - } + try { + const roleId = await allocateRoleId(auth); + const encryptedData = await encryptObjectForStorage( + { displayname: name }, + treeKey, + ); + await sendRoleAdd(auth, { + role_id: roleId, + node_id: parentNodeId, + encrypted_data: encryptedData, + visible_below: visibleBelow, + new_user_inherit: newUserInherit, + }); + await applyRoleEnforcements(auth, roleId, enforcements); + items.push({ + roleId, + roleName: name, + nodeId: parentNodeId, + status: AddRoleStatus.Created, + }); + } catch (err) { + items.push({ + roleId: 0, + roleName: name, + nodeId: parentNodeId, + status: AddRoleStatus.Failed, + message: extractErrorMessage(err), + }); } + } - return finalizeResult(items, parentNodeId, parentNodeName) + return finalizeResult(items, parentNodeId, parentNodeName); } -export function formatAddRoleResult(result: AddRoleResult): FormattedAddRoleTable { - return { - headers: [...ROLE_TABLE_HEADERS], - rows: buildRoleResultRows(result.items, (item) => item.message || (item.skipReason ?? '')), - parentNodeName: result.parentNodeName, - summary: `Created: ${result.created} Skipped: ${result.skipped} Failed: ${result.failed}`, - } +export function formatAddRoleResult( + result: AddRoleResult, +): FormattedAddRoleTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows( + result.items, + (item) => item.message || (item.skipReason ?? ""), + ), + parentNodeName: result.parentNodeName, + summary: `Created: ${result.created} Skipped: ${result.skipped} Failed: ${result.failed}`, + }; } export function renderAddRoleAsciiTable(table: FormattedAddRoleTable): string { - return renderRoleResultTable(table.headers, table.rows, table.summary, [`Parent: ${table.parentNodeName}`]) + return renderRoleResultTable(table.headers, table.rows, table.summary, [ + `Parent: ${table.parentNodeName}`, + ]); } async function allocateRoleId(auth: Auth): Promise { - const response = await auth.executeRestCommand(enterpriseAllocateIdsCommand({ number_requested: 1 })) - if (!response.base_id) { - throw new KeeperSdkError('Failed to allocate enterprise ID for new role.', ResultCodes.ROLE_ID_ALLOCATION_FAILED) - } - return response.base_id + const response = await auth.executeRestCommand( + enterpriseAllocateIdsCommand({ number_requested: 1 }), + ); + if (!response.base_id) { + throw new KeeperSdkError( + "Failed to allocate enterprise ID for new role.", + ResultCodes.ROLE_ID_ALLOCATION_FAILED, + ); + } + return response.base_id; } -async function sendRoleAdd(auth: Auth, payload: RoleEditRequest): Promise { - const response = await auth.executeRestCommand(roleAddCommand(payload)) - assertCommandSucceeded(response, `role_add failed for role_id=${payload.role_id}`, ResultCodes.ROLE_ADD_FAILED) +async function sendRoleAdd( + auth: Auth, + payload: RoleEditRequest, +): Promise { + const response = await auth.executeRestCommand(roleAddCommand(payload)); + assertCommandSucceeded( + response, + `role_add failed for role_id=${payload.role_id}`, + ResultCodes.ROLE_ADD_FAILED, + ); } async function confirmCrossNode( - confirm: AddRoleConfirm | undefined, - existing: EnterpriseRole, - context: { roleName: string; parentNodeId: number; parentNodeName: string } + confirm: AddRoleConfirm | undefined, + existing: EnterpriseRole, + context: { roleName: string; parentNodeId: number; parentNodeName: string }, ): Promise { - if (!confirm) return false - return (await confirm({ - ...context, - existingRoleId: existing.role_id, - existingRoleName: (existing.displayName || '').trim() || String(existing.role_id), - existingNodeId: existing.node_id ?? 0, + if (!confirm) return false; + return ( + (await confirm({ + ...context, + existingRoleId: existing.role_id, + existingRoleName: + (existing.displayName || "").trim() || String(existing.role_id), + existingNodeId: existing.node_id ?? 0, })) === true + ); } function skippedItem( - roleId: number, - roleName: string, - nodeId: number, - skipReason: AddRoleSkipReason, - message: string + roleId: number, + roleName: string, + nodeId: number, + skipReason: AddRoleSkipReason, + message: string, ): AddRoleItemResult { - return { roleId, roleName, nodeId, status: AddRoleStatus.Skipped, skipReason, message } + return { + roleId, + roleName, + nodeId, + status: AddRoleStatus.Skipped, + skipReason, + message, + }; } -function finalizeResult(items: AddRoleItemResult[], parentNodeId: number, parentNodeName: string): AddRoleResult { - const created = items.filter((item) => item.status === AddRoleStatus.Created).length - const skipped = items.filter((item) => item.status === AddRoleStatus.Skipped).length - const failed = items.filter((item) => item.status === AddRoleStatus.Failed).length - return { success: failed === 0 && created > 0, parentNodeId, parentNodeName, items, created, skipped, failed } +function finalizeResult( + items: AddRoleItemResult[], + parentNodeId: number, + parentNodeName: string, +): AddRoleResult { + const created = items.filter( + (item) => item.status === AddRoleStatus.Created, + ).length; + const skipped = items.filter( + (item) => item.status === AddRoleStatus.Skipped, + ).length; + const failed = items.filter( + (item) => item.status === AddRoleStatus.Failed, + ).length; + return { + success: failed === 0 && created > 0, + parentNodeId, + parentNodeName, + items, + created, + skipped, + failed, + }; } diff --git a/KeeperSdk/src/roles/copyRole.ts b/KeeperSdk/src/roles/copyRole.ts new file mode 100644 index 00000000..f0e65bce --- /dev/null +++ b/KeeperSdk/src/roles/copyRole.ts @@ -0,0 +1,341 @@ +import { + createInMessage, + encryptObjectForStorage, + Enterprise, + enterpriseAllocateIdsCommand, + normal64Bytes, + roleAddCommand, + roleEnforcementAddCommand, + roleUserAddCommand, + type Auth, + type RoleEditRequest, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, +} from "../teams/enterpriseData"; +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from "../teams/teamUtils"; +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + resolveExistingRoles, + validateRoleName, +} from "./roleUtils"; + +const COPY_ROLE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleEnforcements, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.RoleTeams, +]; + +export type CopyRoleInput = { + source: string; + name?: string; + parent?: string | number | null; + clone?: boolean; +}; + +export type CopyRoleResult = { + success: boolean; + sourceRoleId: number; + sourceRoleName: string; + newRoleId: number; + newRoleName: string; + nodeId: number; + nodeName: string; + enforcementsCopied: number; + enforcementsFailed: number; + usersCopied: number; + usersFailed: number; + teamsCopied: number; + teamsFailed: number; + message?: string; +}; + +export type FormattedCopyRoleTable = { + rows: Array<{ label: string; value: string }>; +}; + +export async function copyRoles( + auth: Auth, + input: CopyRoleInput, +): Promise { + const sourceIdentifier = (input.source ?? "").trim(); + if (!sourceIdentifier) { + throw new KeeperSdkError( + "Source role is required.", + ResultCodes.ROLE_SOURCE_REQUIRED, + ); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(COPY_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const nodes = response.nodes || []; + applyDecryptedNodeNames(nodes, displayNames.nodes); + applyEnterpriseNameToRoot(nodes, response.enterprise_name); + if (parentNeedsNameLookup(input.parent ?? null)) + await enterpriseData.decryptNodeNames(nodes); + + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + const [sourceRole] = resolveExistingRoles(roles, [sourceIdentifier]); + const sourceRoleName = + (sourceRole.displayName || "").trim() || String(sourceRole.role_id); + + const targetNode = + input.parent !== undefined && input.parent !== null && input.parent !== "" + ? resolveParentNode(nodes, input.parent) + : resolveParentNode(nodes, sourceRole.node_id ?? null); + const nodeName = nodePathOrFallback(nodes, targetNode); + + const newName = (input.name || `${sourceRoleName} Copy`).trim(); + validateRoleName(newName); + + const treeKey = await enterpriseData.getTreeKey(); + if (!treeKey) { + throw new KeeperSdkError( + "Enterprise tree key is unavailable. The current user may not have permission to administer roles.", + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + + const newRoleId = await allocateRoleId(auth); + const encryptedData = await encryptObjectForStorage( + { displayname: newName }, + treeKey, + ); + + try { + await sendRoleAdd(auth, { + role_id: newRoleId, + node_id: targetNode.node_id, + encrypted_data: encryptedData, + visible_below: sourceRole.visible_below ?? false, + new_user_inherit: sourceRole.new_user_inherit ?? false, + }); + } catch (err) { + return { + success: false, + sourceRoleId: sourceRole.role_id, + sourceRoleName, + newRoleId, + newRoleName: newName, + nodeId: targetNode.node_id, + nodeName, + enforcementsCopied: 0, + enforcementsFailed: 0, + usersCopied: 0, + usersFailed: 0, + teamsCopied: 0, + teamsFailed: 0, + message: extractErrorMessage(err), + }; + } + + const { copied: enforcementsCopied, failed: enforcementsFailed } = + await copyEnforcements( + auth, + sourceRole.role_id, + newRoleId, + response.role_enforcements || [], + ); + + let usersCopied = 0; + let usersFailed = 0; + let teamsCopied = 0; + let teamsFailed = 0; + + if (input.clone === true) { + const userResult = await copyRoleUsers( + auth, + sourceRole.role_id, + newRoleId, + response.role_users || [], + ); + usersCopied = userResult.copied; + usersFailed = userResult.failed; + + const teamResult = await copyRoleTeams( + auth, + newRoleId, + (response.role_teams || []) + .filter((link) => link.role_id === sourceRole.role_id) + .map((link) => link.team_uid), + ); + teamsCopied = teamResult.copied; + teamsFailed = teamResult.failed; + } + + return { + success: true, + sourceRoleId: sourceRole.role_id, + sourceRoleName, + newRoleId, + newRoleName: newName, + nodeId: targetNode.node_id, + nodeName, + enforcementsCopied, + enforcementsFailed, + usersCopied, + usersFailed, + teamsCopied, + teamsFailed, + }; +} + +export function formatCopyRoleResult(result: CopyRoleResult): FormattedCopyRoleTable { + const rows: Array<{ label: string; value: string }> = [ + { label: "Source Role", value: `${result.sourceRoleName} (${result.sourceRoleId})` }, + { label: "New Role", value: `${result.newRoleName} (${result.newRoleId})` }, + { label: "Node", value: `${result.nodeName} (${result.nodeId})` }, + { label: "Status", value: result.success ? "success" : "failed" }, + { + label: "Enforcements Copied", + value: `${result.enforcementsCopied} (failed: ${result.enforcementsFailed})`, + }, + ]; + if (result.usersCopied > 0 || result.usersFailed > 0) { + rows.push({ + label: "Users Copied", + value: `${result.usersCopied} (failed: ${result.usersFailed})`, + }); + } + if (result.teamsCopied > 0 || result.teamsFailed > 0) { + rows.push({ + label: "Teams Copied", + value: `${result.teamsCopied} (failed: ${result.teamsFailed})`, + }); + } + if (result.message) rows.push({ label: "Detail", value: result.message }); + return { rows }; +} + +export function renderCopyRoleAsciiTable(table: FormattedCopyRoleTable): string { + const labelWidth = Math.max(...table.rows.map((r) => r.label.length)); + return table.rows + .map((row) => `${row.label.padEnd(labelWidth)} ${row.value}`) + .join("\n"); +} + +async function allocateRoleId(auth: Auth): Promise { + const response = await auth.executeRestCommand( + enterpriseAllocateIdsCommand({ number_requested: 1 }), + ); + if (!response.base_id) { + throw new KeeperSdkError( + "Failed to allocate enterprise ID for new role.", + ResultCodes.ROLE_ID_ALLOCATION_FAILED, + ); + } + return response.base_id; +} + +async function sendRoleAdd(auth: Auth, payload: RoleEditRequest): Promise { + const response = await auth.executeRestCommand(roleAddCommand(payload)); + assertCommandSucceeded( + response, + `role_add failed for role_id=${payload.role_id}`, + ResultCodes.ROLE_ADD_FAILED, + ); +} + +async function copyEnforcements( + auth: Auth, + sourceRoleId: number, + newRoleId: number, + links: ReadonlyArray<{ role_id: number; enforcement_type: string; value?: string }>, +): Promise<{ copied: number; failed: number }> { + let copied = 0; + let failed = 0; + for (const link of links) { + if (link.role_id !== sourceRoleId) continue; + try { + const response = await auth.executeRestCommand( + roleEnforcementAddCommand({ + role_id: newRoleId, + enforcement: link.enforcement_type, + value: link.value, + }), + ); + assertCommandSucceeded( + response, + `role_enforcement_add failed for ${link.enforcement_type}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED, + ); + copied++; + } catch { + failed++; + } + } + return { copied, failed }; +} + +async function copyRoleUsers( + auth: Auth, + sourceRoleId: number, + newRoleId: number, + links: ReadonlyArray<{ role_id: number; enterprise_user_id: number }>, +): Promise<{ copied: number; failed: number }> { + let copied = 0; + let failed = 0; + for (const link of links) { + if (link.role_id !== sourceRoleId) continue; + try { + const response = await auth.executeRestCommand( + roleUserAddCommand({ + role_id: newRoleId, + enterprise_user_id: link.enterprise_user_id, + }), + ); + assertCommandSucceeded( + response, + `role_user_add failed for enterprise_user_id=${link.enterprise_user_id}`, + ResultCodes.ROLE_USER_ADD_FAILED, + ); + copied++; + } catch { + failed++; + } + } + return { copied, failed }; +} + +async function copyRoleTeams( + auth: Auth, + newRoleId: number, + teamUids: string[], +): Promise<{ copied: number; failed: number }> { + if (teamUids.length === 0) return { copied: 0, failed: 0 }; + try { + const payload = Enterprise.RoleTeams.create({ + roleTeam: teamUids.map((teamUid) => + Enterprise.RoleTeam.create({ + roleId: newRoleId, + teamUid: normal64Bytes(teamUid), + }), + ), + }); + const message = createInMessage( + payload, + "enterprise/role_team_add", + Enterprise.RoleTeams, + ); + await auth.executeRestAction(message); + return { copied: teamUids.length, failed: 0 }; + } catch { + return { copied: 0, failed: teamUids.length }; + } +} diff --git a/KeeperSdk/src/roles/deleteRole.ts b/KeeperSdk/src/roles/deleteRole.ts index 9a893a73..b0b83f4c 100644 --- a/KeeperSdk/src/roles/deleteRole.ts +++ b/KeeperSdk/src/roles/deleteRole.ts @@ -1,98 +1,134 @@ -import { roleDeleteCommand, type Auth } from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' -import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { roleDeleteCommand, type Auth } from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; import { - applyDecryptedRoleNames, - assertCommandSucceeded, - buildRoleResultRows, - normalizeIdentifiers, - renderRoleResultTable, - resolveExistingRoles, - ROLE_TABLE_HEADERS, -} from './roleUtils' + EnterpriseDataInclude, + EnterpriseDataManager, +} from "../teams/enterpriseData"; +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + buildRoleResultRows, + normalizeIdentifiers, + renderRoleResultTable, + resolveExistingRoles, + ROLE_TABLE_HEADERS, +} from "./roleUtils"; -const DELETE_ROLE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Roles] +const DELETE_ROLE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Roles, +]; export enum DeleteRoleStatus { - Deleted = 'deleted', - Failed = 'failed', + Deleted = "deleted", + Failed = "failed", } export type DeleteRoleInput = { - roles: string[] -} + roles: string[]; +}; export type DeleteRoleItemResult = { - roleId: number - roleName: string - nodeId: number - status: DeleteRoleStatus - message?: string -} + roleId: number; + roleName: string; + nodeId: number; + status: DeleteRoleStatus; + message?: string; +}; export type DeleteRoleResult = { - success: boolean - items: DeleteRoleItemResult[] - deleted: number - failed: number -} + success: boolean; + items: DeleteRoleItemResult[]; + deleted: number; + failed: number; +}; export type FormattedDeleteRoleTable = { - headers: string[] - rows: string[][] - summary: string -} + headers: string[]; + rows: string[][]; + summary: string; +}; -export async function deleteRoles(auth: Auth, input: DeleteRoleInput): Promise { - const identifiers = normalizeIdentifiers(input.roles) - if (identifiers.length === 0) { - throw new KeeperSdkError('No roles to delete.', ResultCodes.NO_ROLES_TO_DELETE) - } +export async function deleteRoles( + auth: Auth, + input: DeleteRoleInput, +): Promise { + const identifiers = normalizeIdentifiers(input.roles); + if (identifiers.length === 0) { + throw new KeeperSdkError( + "No roles to delete.", + ResultCodes.NO_ROLES_TO_DELETE, + ); + } - const enterpriseData = new EnterpriseDataManager(auth) - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(DELETE_ROLE_INCLUDES), - enterpriseData.getDisplayNames(), - ]) + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(DELETE_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]); - const roles = response.roles || [] - applyDecryptedRoleNames(roles, displayNames.roles) - const resolvedRoles = resolveExistingRoles(roles, identifiers) + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + const resolvedRoles = resolveExistingRoles(roles, identifiers); - const items: DeleteRoleItemResult[] = [] - for (const role of resolvedRoles) { - const roleName = (role.displayName || '').trim() || String(role.role_id) - const nodeId = role.node_id ?? 0 - try { - await sendRoleDelete(auth, role.role_id) - items.push({ roleId: role.role_id, roleName, nodeId, status: DeleteRoleStatus.Deleted }) - } catch (err) { - items.push({ roleId: role.role_id, roleName, nodeId, status: DeleteRoleStatus.Failed, message: extractErrorMessage(err) }) - } + const items: DeleteRoleItemResult[] = []; + for (const role of resolvedRoles) { + const roleName = (role.displayName || "").trim() || String(role.role_id); + const nodeId = role.node_id ?? 0; + try { + await sendRoleDelete(auth, role.role_id); + items.push({ + roleId: role.role_id, + roleName, + nodeId, + status: DeleteRoleStatus.Deleted, + }); + } catch (err) { + items.push({ + roleId: role.role_id, + roleName, + nodeId, + status: DeleteRoleStatus.Failed, + message: extractErrorMessage(err), + }); } + } - return finalizeResult(items) + return finalizeResult(items); } -export function formatDeleteRoleResult(result: DeleteRoleResult): FormattedDeleteRoleTable { - return { - headers: [...ROLE_TABLE_HEADERS], - rows: buildRoleResultRows(result.items, (item) => item.message || ''), - summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, - } +export function formatDeleteRoleResult( + result: DeleteRoleResult, +): FormattedDeleteRoleTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows(result.items, (item) => item.message || ""), + summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, + }; } -export function renderDeleteRoleAsciiTable(table: FormattedDeleteRoleTable): string { - return renderRoleResultTable(table.headers, table.rows, table.summary) +export function renderDeleteRoleAsciiTable( + table: FormattedDeleteRoleTable, +): string { + return renderRoleResultTable(table.headers, table.rows, table.summary); } async function sendRoleDelete(auth: Auth, roleId: number): Promise { - const response = await auth.executeRestCommand(roleDeleteCommand({ role_id: roleId })) - assertCommandSucceeded(response, `role_delete failed for role_id=${roleId}`, ResultCodes.ROLE_DELETE_FAILED) + const response = await auth.executeRestCommand( + roleDeleteCommand({ role_id: roleId }), + ); + assertCommandSucceeded( + response, + `role_delete failed for role_id=${roleId}`, + ResultCodes.ROLE_DELETE_FAILED, + ); } function finalizeResult(items: DeleteRoleItemResult[]): DeleteRoleResult { - const deleted = items.filter((item) => item.status === DeleteRoleStatus.Deleted).length - const failed = items.filter((item) => item.status === DeleteRoleStatus.Failed).length - return { success: failed === 0 && deleted > 0, items, deleted, failed } + const deleted = items.filter( + (item) => item.status === DeleteRoleStatus.Deleted, + ).length; + const failed = items.filter( + (item) => item.status === DeleteRoleStatus.Failed, + ).length; + return { success: failed === 0 && deleted > 0, items, deleted, failed }; } diff --git a/KeeperSdk/src/roles/index.ts b/KeeperSdk/src/roles/index.ts index b0e3b993..1c0e258c 100644 --- a/KeeperSdk/src/roles/index.ts +++ b/KeeperSdk/src/roles/index.ts @@ -1,74 +1,145 @@ -export { listRoles, formatRolesTable, renderRolesAsciiTable } from './listRoles' +export { + listRoles, + formatRolesTable, + renderRolesAsciiTable, +} from "./listRoles"; + +export { + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + DEFAULT_ROLE_COLUMNS, + ALL_COLUMNS_WILDCARD, +} from "./roleTypes"; +export type { + ListRolesOptions, + ListRoleRow, + RoleColumnInput, + FormattedRolesTable, + FormatRolesTableOptions, +} from "./roleTypes"; + +export { viewRole, formatRoleView, roleViewTable } from "./viewRole"; +export type { + RoleView, + RoleTeamInfo, + RoleUserInfo, + RoleManagedNodeInfo, + RoleEnforcementInfo, + FormatRoleViewOptions, + FormattedRoleViewTable, + FormattedManagedNodePrivilegeTable, + RoleViewTableRow, +} from "./viewRole"; + +export { RoleManager } from "./RoleManager"; + +export { + addRoles, + formatAddRoleResult, + renderAddRoleAsciiTable, + AddRoleStatus, + AddRoleSkipReason, +} from "./addRole"; +export type { + AddRoleInput, + AddRoleResult, + AddRoleItemResult, + AddRoleConfirm, + AddRoleConflictPrompt, + FormattedAddRoleTable, +} from "./addRole"; + +export { + updateRoles, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + UpdateRoleStatus, +} from "./updateRole"; +export type { + UpdateRoleInput, + UpdateRoleResult, + UpdateRoleItemResult, + FormattedUpdateRoleTable, +} from "./updateRole"; export { - RoleColumn, - SUPPORTED_ROLE_COLUMNS, - DEFAULT_ROLE_COLUMNS, - ALL_COLUMNS_WILDCARD, -} from './roleTypes' + deleteRoles, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + DeleteRoleStatus, +} from "./deleteRole"; export type { - ListRolesOptions, - ListRoleRow, - RoleColumnInput, - FormattedRolesTable, - FormatRolesTableOptions, -} from './roleTypes' + DeleteRoleInput, + DeleteRoleResult, + DeleteRoleItemResult, + FormattedDeleteRoleTable, +} from "./deleteRole"; -export { viewRole, formatRoleView, roleViewTable } from './viewRole' +export { + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + RoleEnforcementStatus, +} from "./roleEnforcement"; export type { - RoleView, - RoleTeamInfo, - RoleUserInfo, - RoleManagedNodeInfo, - RoleEnforcementInfo, - FormatRoleViewOptions, - FormattedRoleViewTable, - FormattedManagedNodePrivilegeTable, - RoleViewTableRow, -} from './viewRole' + SetRoleEnforcementsInput, + RoleEnforcementItemResult, + SetRoleEnforcementsResult, + FormattedRoleEnforcementTable, +} from "./roleEnforcement"; -export { RoleManager } from './RoleManager' +export { + copyRoles, + formatCopyRoleResult, + renderCopyRoleAsciiTable, +} from "./copyRole"; +export type { + CopyRoleInput, + CopyRoleResult, + FormattedCopyRoleTable, +} from "./copyRole"; export { - addRoles, - formatAddRoleResult, - renderAddRoleAsciiTable, - AddRoleStatus, - AddRoleSkipReason, -} from './addRole' + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + RoleUserStatus, + RoleUserSkipReason, +} from "./roleUser"; export type { - AddRoleInput, - AddRoleResult, - AddRoleItemResult, - AddRoleConfirm, - AddRoleConflictPrompt, - FormattedAddRoleTable, -} from './addRole' + AddUsersToRolesInput, + RemoveUsersFromRolesInput, + RoleUserItemResult, + RoleUserResult, + FormattedRoleUserTable, +} from "./roleUser"; export { - updateRoles, - formatUpdateRoleResult, - renderUpdateRoleAsciiTable, - UpdateRoleStatus, -} from './updateRole' + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + RoleManagedNodeStatus, +} from "./manageNode"; export type { - UpdateRoleInput, - UpdateRoleResult, - UpdateRoleItemResult, - FormattedUpdateRoleTable, -} from './updateRole' + ManageRoleNodesAction, + ManageRoleNodesInput, + RoleManagedNodeItemResult, + ManageRoleNodesResult, + FormattedManageRoleNodesTable, +} from "./manageNode"; export { - deleteRoles, - formatDeleteRoleResult, - renderDeleteRoleAsciiTable, - DeleteRoleStatus, -} from './deleteRole' + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + RolePrivilegeStatus, +} from "./rolePrivilege"; export type { - DeleteRoleInput, - DeleteRoleResult, - DeleteRoleItemResult, - FormattedDeleteRoleTable, -} from './deleteRole' + ChangeRolePrivilegesInput, + RolePrivilegeItemResult, + ChangeRolePrivilegesResult, + FormattedRolePrivilegeTable, +} from "./rolePrivilege"; -export type { RoleToggleInput, RoleToggle, EnforcementPair } from './roleUtils' +export type { RoleToggleInput, RoleToggle, EnforcementPair } from "./roleUtils"; diff --git a/KeeperSdk/src/roles/listRoles.ts b/KeeperSdk/src/roles/listRoles.ts index efa4c1d9..6a2a2cde 100644 --- a/KeeperSdk/src/roles/listRoles.ts +++ b/KeeperSdk/src/roles/listRoles.ts @@ -1,341 +1,388 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { isNumber, resolveSearchPattern, TOKEN_SEPARATOR_PATTERN } from '../utils' +import type { Auth } from "@keeper-security/keeperapi"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseDataManagerApi, - type EnterpriseDisplayNames, - type EnterpriseNode, - type EnterpriseRole, - type EnterpriseRolePrivilegeLink, - type EnterpriseRoleTeamLink, - type EnterpriseRoleUserLink, - type EnterpriseTeamRecord, - type EnterpriseUser, -} from '../teams/enterpriseData' -import { applyDecryptedNodeNames } from '../teams/teamUtils' + isNumber, + resolveSearchPattern, + TOKEN_SEPARATOR_PATTERN, +} from "../utils"; import { - ALL_COLUMNS_WILDCARD, - DEFAULT_ROLE_COLUMNS, - RoleColumn, - SUPPORTED_ROLE_COLUMNS, - type FormatRolesTableOptions, - type FormattedRolesTable, - type ListRoleRow, - type ListRolesOptions, -} from './roleTypes' - -const NODE_PATH_SEPARATOR = '\\' -const MIN_ASCII_COL_WIDTH = 2 + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseDataManagerApi, + type EnterpriseDisplayNames, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRolePrivilegeLink, + type EnterpriseRoleTeamLink, + type EnterpriseRoleUserLink, + type EnterpriseTeamRecord, + type EnterpriseUser, +} from "../teams/enterpriseData"; +import { applyDecryptedNodeNames } from "../teams/teamUtils"; +import { + ALL_COLUMNS_WILDCARD, + DEFAULT_ROLE_COLUMNS, + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + type FormatRolesTableOptions, + type FormattedRolesTable, + type ListRoleRow, + type ListRolesOptions, +} from "./roleTypes"; + +const NODE_PATH_SEPARATOR = "\\"; +const MIN_ASCII_COL_WIDTH = 2; const HEADER_BY_COLUMN: Record = { - [RoleColumn.VisibleBelow]: 'Visible Below', - [RoleColumn.DefaultRole]: 'Default Role', - [RoleColumn.Admin]: 'Admin', - [RoleColumn.Node]: 'Node', - [RoleColumn.UserCount]: 'User Count', - [RoleColumn.Users]: 'Users', - [RoleColumn.TeamCount]: 'Team Count', - [RoleColumn.Teams]: 'Teams', -} + [RoleColumn.VisibleBelow]: "Visible Below", + [RoleColumn.DefaultRole]: "Default Role", + [RoleColumn.Admin]: "Admin", + [RoleColumn.Node]: "Node", + [RoleColumn.UserCount]: "User Count", + [RoleColumn.Users]: "Users", + [RoleColumn.TeamCount]: "Team Count", + [RoleColumn.Teams]: "Teams", +}; type DecorateContext = { - nodePaths: Map - adminRoleIds: Set - roleUsers: Map> - roleTeams: Map> - usernameById: Map - teamNameById: Map -} - -export async function listRoles(auth: Auth, options: ListRolesOptions = {}): Promise { - const columns = resolveColumns(options.columns) - const includes = includesForColumns(columns) - const wantsDisplayNames = - columns.includes(RoleColumn.Node) || - columns.includes(RoleColumn.Teams) || - columns.includes(RoleColumn.TeamCount) - - const enterpriseData: EnterpriseDataManagerApi = - options.enterpriseData ?? new EnterpriseDataManager(auth) - const emptyDisplayNames: EnterpriseDisplayNames = { nodes: new Map(), roles: new Map() } - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(includes), - wantsDisplayNames ? enterpriseData.getDisplayNames() : Promise.resolve(emptyDisplayNames), - ]) - - const roles = response.roles || [] - const nodes = response.nodes || [] - applyDecryptedNodeNames(nodes, displayNames.nodes) - - for (const role of roles) { - const display = displayNames.roles.get(role.role_id) - if (display) role.displayName = display - } - - const context: DecorateContext = { - nodePaths: buildNodePathLookup(nodes), - adminRoleIds: buildAdminRoleIds(response.role_privileges || []), - roleUsers: buildRoleUserMap(response.role_users || []), - roleTeams: buildRoleTeamMap(response.role_teams || []), - usernameById: buildUsernameMap(response.users || []), - teamNameById: buildTeamNameMap(response.teams || []), - } - - const pattern = resolveSearchPattern(options.pattern) - const rows: ListRoleRow[] = [] - for (const role of roles) { - const row = decorateRow(role, columns, context) - if (pattern && !rowMatchesPattern(row, pattern)) continue - rows.push(row) - } - - rows.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) - return rows + nodePaths: Map; + adminRoleIds: Set; + roleUsers: Map>; + roleTeams: Map>; + usernameById: Map; + teamNameById: Map; +}; + +export async function listRoles( + auth: Auth, + options: ListRolesOptions = {}, +): Promise { + const columns = resolveColumns(options.columns); + const includes = includesForColumns(columns); + const wantsDisplayNames = + columns.includes(RoleColumn.Node) || + columns.includes(RoleColumn.Teams) || + columns.includes(RoleColumn.TeamCount); + + const enterpriseData: EnterpriseDataManagerApi = + options.enterpriseData ?? new EnterpriseDataManager(auth); + const emptyDisplayNames: EnterpriseDisplayNames = { + nodes: new Map(), + roles: new Map(), + }; + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(includes), + wantsDisplayNames + ? enterpriseData.getDisplayNames() + : Promise.resolve(emptyDisplayNames), + ]); + + const roles = response.roles || []; + const nodes = response.nodes || []; + applyDecryptedNodeNames(nodes, displayNames.nodes); + + for (const role of roles) { + const display = displayNames.roles.get(role.role_id); + if (display) role.displayName = display; + } + + const context: DecorateContext = { + nodePaths: buildNodePathLookup(nodes), + adminRoleIds: buildAdminRoleIds(response.role_privileges || []), + roleUsers: buildRoleUserMap(response.role_users || []), + roleTeams: buildRoleTeamMap(response.role_teams || []), + usernameById: buildUsernameMap(response.users || []), + teamNameById: buildTeamNameMap(response.teams || []), + }; + + const pattern = resolveSearchPattern(options.pattern); + const rows: ListRoleRow[] = []; + for (const role of roles) { + const row = decorateRow(role, columns, context); + if (pattern && !rowMatchesPattern(row, pattern)) continue; + rows.push(row); + } + + rows.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + return rows; } export function formatRolesTable( - rows: ListRoleRow[], - options: FormatRolesTableOptions = {} + rows: ListRoleRow[], + options: FormatRolesTableOptions = {}, ): FormattedRolesTable { - const columns = resolveColumns(options.columns) - const headers: string[] = ['#', 'Role ID', 'Name', ...columns.map((col) => HEADER_BY_COLUMN[col])] - - const outRows: string[][] = rows.map((row, rowIndex) => { - const cells: string[] = [String(rowIndex + 1), String(row.role_id), row.name] - for (const col of columns) cells.push(formatCell(row, col)) - return cells - }) - - return { headers, rows: outRows } + const columns = resolveColumns(options.columns); + const headers: string[] = [ + "#", + "Role ID", + "Name", + ...columns.map((col) => HEADER_BY_COLUMN[col]), + ]; + + const outRows: string[][] = rows.map((row, rowIndex) => { + const cells: string[] = [ + String(rowIndex + 1), + String(row.role_id), + row.name, + ]; + for (const col of columns) cells.push(formatCell(row, col)); + return cells; + }); + + return { headers, rows: outRows }; } export function renderRolesAsciiTable( - table: FormattedRolesTable, - options: { minColWidth?: number } = {} + table: FormattedRolesTable, + options: { minColWidth?: number } = {}, ): string { - const { minColWidth = MIN_ASCII_COL_WIDTH } = options - const { headers, rows } = table - const columnCount = headers.length - - const expandedRows: string[][][] = rows.map((row) => - row.map((cell) => (cell.includes('\n') ? cell.split('\n') : [cell])) - ) - - const columnWidths = new Array(columnCount).fill(0) + const { minColWidth = MIN_ASCII_COL_WIDTH } = options; + const { headers, rows } = table; + const columnCount = headers.length; + + const expandedRows: string[][][] = rows.map((row) => + row.map((cell) => (cell.includes("\n") ? cell.split("\n") : [cell])), + ); + + const columnWidths = new Array(columnCount).fill(0); + for (let ci = 0; ci < columnCount; ci += 1) { + columnWidths[ci] = Math.max(headers[ci].length, minColWidth); + } + for (const row of expandedRows) { for (let ci = 0; ci < columnCount; ci += 1) { - columnWidths[ci] = Math.max(headers[ci].length, minColWidth) - } - for (const row of expandedRows) { - for (let ci = 0; ci < columnCount; ci += 1) { - for (const line of row[ci]) { - columnWidths[ci] = Math.max(columnWidths[ci], line.length, minColWidth) - } - } + for (const line of row[ci]) { + columnWidths[ci] = Math.max(columnWidths[ci], line.length, minColWidth); + } } + } - const padCell = (cell: string, ci: number): string => - cell + ' '.repeat(columnWidths[ci] - cell.length) - const formatPhysicalRow = (cells: string[]): string => - cells.map((cell, ci) => padCell(cell, ci)).join(' ') + const padCell = (cell: string, ci: number): string => + cell + " ".repeat(columnWidths[ci] - cell.length); + const formatPhysicalRow = (cells: string[]): string => + cells.map((cell, ci) => padCell(cell, ci)).join(" "); - const ruleRow = formatPhysicalRow(columnWidths.map((w) => '-'.repeat(w))) - const lines: string[] = [formatPhysicalRow(headers), ruleRow] + const ruleRow = formatPhysicalRow(columnWidths.map((w) => "-".repeat(w))); + const lines: string[] = [formatPhysicalRow(headers), ruleRow]; - for (const row of expandedRows) { - const physicalLineCount = Math.max(...row.map((cell) => cell.length)) - for (let li = 0; li < physicalLineCount; li += 1) { - lines.push(formatPhysicalRow(row.map((cell) => cell[li] ?? ''))) - } + for (const row of expandedRows) { + const physicalLineCount = Math.max(...row.map((cell) => cell.length)); + for (let li = 0; li < physicalLineCount; li += 1) { + lines.push(formatPhysicalRow(row.map((cell) => cell[li] ?? ""))); } - return lines.join('\n') + } + return lines.join("\n"); } -function includesForColumns(columns: readonly RoleColumn[]): EnterpriseDataInclude[] { - const set = new Set([EnterpriseDataInclude.Roles]) - for (const col of columns) { - switch (col) { - case RoleColumn.Node: - set.add(EnterpriseDataInclude.Nodes) - break - case RoleColumn.Admin: - set.add(EnterpriseDataInclude.RolePrivileges) - break - case RoleColumn.UserCount: - case RoleColumn.Users: - set.add(EnterpriseDataInclude.RoleUsers) - if (col === RoleColumn.Users) set.add(EnterpriseDataInclude.Users) - break - case RoleColumn.TeamCount: - case RoleColumn.Teams: - set.add(EnterpriseDataInclude.RoleTeams) - if (col === RoleColumn.Teams) set.add(EnterpriseDataInclude.Teams) - break - } +function includesForColumns( + columns: readonly RoleColumn[], +): EnterpriseDataInclude[] { + const set = new Set([EnterpriseDataInclude.Roles]); + for (const col of columns) { + switch (col) { + case RoleColumn.Node: + set.add(EnterpriseDataInclude.Nodes); + break; + case RoleColumn.Admin: + set.add(EnterpriseDataInclude.RolePrivileges); + break; + case RoleColumn.UserCount: + case RoleColumn.Users: + set.add(EnterpriseDataInclude.RoleUsers); + if (col === RoleColumn.Users) set.add(EnterpriseDataInclude.Users); + break; + case RoleColumn.TeamCount: + case RoleColumn.Teams: + set.add(EnterpriseDataInclude.RoleTeams); + if (col === RoleColumn.Teams) set.add(EnterpriseDataInclude.Teams); + break; } - return Array.from(set) + } + return Array.from(set); } -function resolveColumns(input: ListRolesOptions['columns']): RoleColumn[] { - if (input == null) return [...DEFAULT_ROLE_COLUMNS] - if (input === ALL_COLUMNS_WILDCARD) return [...SUPPORTED_ROLE_COLUMNS] - - const requested = Array.isArray(input) ? input : input.split(',').map((p) => p.trim()) - const allowed = new Set(SUPPORTED_ROLE_COLUMNS) - const seen = new Set() - for (const col of requested) { - if (col && allowed.has(col)) seen.add(col as RoleColumn) - } - if (seen.size === 0) return [...DEFAULT_ROLE_COLUMNS] - return SUPPORTED_ROLE_COLUMNS.filter((col) => seen.has(col)) +function resolveColumns(input: ListRolesOptions["columns"]): RoleColumn[] { + if (input == null) return [...DEFAULT_ROLE_COLUMNS]; + if (input === ALL_COLUMNS_WILDCARD) return [...SUPPORTED_ROLE_COLUMNS]; + + const requested = Array.isArray(input) + ? input + : input.split(",").map((p) => p.trim()); + const allowed = new Set(SUPPORTED_ROLE_COLUMNS); + const seen = new Set(); + for (const col of requested) { + if (col && allowed.has(col)) seen.add(col as RoleColumn); + } + if (seen.size === 0) return [...DEFAULT_ROLE_COLUMNS]; + return SUPPORTED_ROLE_COLUMNS.filter((col) => seen.has(col)); } function roleDisplayName(role: EnterpriseRole): string { - return (role.displayName || '').trim() || String(role.role_id) + return (role.displayName || "").trim() || String(role.role_id); } function tokenize(text: string): string[] { - return text.split(TOKEN_SEPARATOR_PATTERN).filter((t) => t.length > 0) + return text.split(TOKEN_SEPARATOR_PATTERN).filter((t) => t.length > 0); } function rowMatchesPattern(row: ListRoleRow, pattern: string): boolean { - const lower = pattern.toLowerCase() - const tokens: string[] = [String(row.role_id), ...tokenize(row.name.toLowerCase())] - if (row.node) tokens.push(...tokenize(row.node.toLowerCase())) - if (row.user_count != null) tokens.push(String(row.user_count)) - if (row.team_count != null) tokens.push(String(row.team_count)) - for (const list of [row.users, row.teams]) { - if (!list) continue - for (const v of list) tokens.push(...tokenize(v.toLowerCase())) - } - return tokens.some((t) => t.includes(lower)) + const lower = pattern.toLowerCase(); + const tokens: string[] = [ + String(row.role_id), + ...tokenize(row.name.toLowerCase()), + ]; + if (row.node) tokens.push(...tokenize(row.node.toLowerCase())); + if (row.user_count != null) tokens.push(String(row.user_count)); + if (row.team_count != null) tokens.push(String(row.team_count)); + for (const list of [row.users, row.teams]) { + if (!list) continue; + for (const v of list) tokens.push(...tokenize(v.toLowerCase())); + } + return tokens.some((t) => t.includes(lower)); } function buildNodePathLookup(nodes: EnterpriseNode[]): Map { - return new Map( - nodes.map((node) => [ - node.node_id, - EnterpriseDataManager.getNodePath(nodes, node.node_id, { separator: NODE_PATH_SEPARATOR }), - ]) - ) + return new Map( + nodes.map((node) => [ + node.node_id, + EnterpriseDataManager.getNodePath(nodes, node.node_id, { + separator: NODE_PATH_SEPARATOR, + }), + ]), + ); } -function buildAdminRoleIds(privileges: EnterpriseRolePrivilegeLink[]): Set { - const set = new Set() - for (const p of privileges) set.add(p.role_id) - return set +function buildAdminRoleIds( + privileges: EnterpriseRolePrivilegeLink[], +): Set { + const set = new Set(); + for (const p of privileges) set.add(p.role_id); + return set; } -function buildRoleUserMap(links: EnterpriseRoleUserLink[]): Map> { - const map = new Map>() - for (const link of links) { - const set = map.get(link.role_id) - if (set) set.add(link.enterprise_user_id) - else map.set(link.role_id, new Set([link.enterprise_user_id])) - } - return map +function buildRoleUserMap( + links: EnterpriseRoleUserLink[], +): Map> { + const map = new Map>(); + for (const link of links) { + const set = map.get(link.role_id); + if (set) set.add(link.enterprise_user_id); + else map.set(link.role_id, new Set([link.enterprise_user_id])); + } + return map; } -function buildRoleTeamMap(links: EnterpriseRoleTeamLink[]): Map> { - const map = new Map>() - for (const link of links) { - const set = map.get(link.role_id) - if (set) set.add(link.team_uid) - else map.set(link.role_id, new Set([link.team_uid])) - } - return map +function buildRoleTeamMap( + links: EnterpriseRoleTeamLink[], +): Map> { + const map = new Map>(); + for (const link of links) { + const set = map.get(link.role_id); + if (set) set.add(link.team_uid); + else map.set(link.role_id, new Set([link.team_uid])); + } + return map; } function buildUsernameMap(users: EnterpriseUser[]): Map { - const map = new Map() - for (const user of users) { - if (isNumber(user.enterprise_user_id) && user.username) { - map.set(user.enterprise_user_id, user.username) - } + const map = new Map(); + for (const user of users) { + if (isNumber(user.enterprise_user_id) && user.username) { + map.set(user.enterprise_user_id, user.username); } - return map + } + return map; } function buildTeamNameMap(teams: EnterpriseTeamRecord[]): Map { - const map = new Map() - for (const team of teams) { - if (team.team_uid) map.set(team.team_uid, (team.name || team.team_uid).trim()) - } - return map + const map = new Map(); + for (const team of teams) { + if (team.team_uid) + map.set(team.team_uid, (team.name || team.team_uid).trim()); + } + return map; } -function resolveSortedNames(ids: Set, nameById: Map): string[] { - const result: string[] = [] - for (const id of ids) { - const name = nameById.get(id) - if (name) result.push(name) - } - return result.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) +function resolveSortedNames( + ids: Set, + nameById: Map, +): string[] { + const result: string[] = []; + for (const id of ids) { + const name = nameById.get(id); + if (name) result.push(name); + } + return result.sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); } function decorateRow( - role: EnterpriseRole, - columns: readonly RoleColumn[], - context: DecorateContext + role: EnterpriseRole, + columns: readonly RoleColumn[], + context: DecorateContext, ): ListRoleRow { - const row: ListRoleRow = { role_id: role.role_id, name: roleDisplayName(role) } - - for (const col of columns) { - switch (col) { - case RoleColumn.VisibleBelow: - row.visible_below = role.visible_below ?? false - break - case RoleColumn.DefaultRole: - row.default_role = role.new_user_inherit ?? false - break - case RoleColumn.Admin: - row.admin = context.adminRoleIds.has(role.role_id) - break - case RoleColumn.Node: - row.node = context.nodePaths.get(role.node_id ?? 0) ?? '' - break - case RoleColumn.UserCount: - row.user_count = context.roleUsers.get(role.role_id)?.size ?? 0 - break - case RoleColumn.Users: { - const ids = context.roleUsers.get(role.role_id) - row.users = ids ? resolveSortedNames(ids, context.usernameById) : [] - break - } - case RoleColumn.TeamCount: - row.team_count = context.roleTeams.get(role.role_id)?.size ?? 0 - break - case RoleColumn.Teams: { - const uids = context.roleTeams.get(role.role_id) - row.teams = uids ? resolveSortedNames(uids, context.teamNameById) : [] - break - } - } + const row: ListRoleRow = { + role_id: role.role_id, + name: roleDisplayName(role), + }; + + for (const col of columns) { + switch (col) { + case RoleColumn.VisibleBelow: + row.visible_below = role.visible_below ?? false; + break; + case RoleColumn.DefaultRole: + row.default_role = role.new_user_inherit ?? false; + break; + case RoleColumn.Admin: + row.admin = context.adminRoleIds.has(role.role_id); + break; + case RoleColumn.Node: + row.node = context.nodePaths.get(role.node_id ?? 0) ?? ""; + break; + case RoleColumn.UserCount: + row.user_count = context.roleUsers.get(role.role_id)?.size ?? 0; + break; + case RoleColumn.Users: { + const ids = context.roleUsers.get(role.role_id); + row.users = ids ? resolveSortedNames(ids, context.usernameById) : []; + break; + } + case RoleColumn.TeamCount: + row.team_count = context.roleTeams.get(role.role_id)?.size ?? 0; + break; + case RoleColumn.Teams: { + const uids = context.roleTeams.get(role.role_id); + row.teams = uids ? resolveSortedNames(uids, context.teamNameById) : []; + break; + } } - return row + } + return row; } function formatListCell(values: string[] | undefined): string { - return values?.length ? values.join('\n') : '' + return values?.length ? values.join("\n") : ""; } function formatCell(row: ListRoleRow, col: RoleColumn): string { - switch (col) { - case RoleColumn.VisibleBelow: - return row.visible_below == null ? '' : row.visible_below ? 'Yes' : 'No' - case RoleColumn.DefaultRole: - return row.default_role == null ? '' : row.default_role ? 'Yes' : 'No' - case RoleColumn.Admin: - return row.admin == null ? '' : row.admin ? 'Yes' : 'No' - case RoleColumn.Node: - return row.node ?? '' - case RoleColumn.UserCount: - return row.user_count == null ? '' : String(row.user_count) - case RoleColumn.Users: - return formatListCell(row.users) - case RoleColumn.TeamCount: - return row.team_count == null ? '' : String(row.team_count) - case RoleColumn.Teams: - return formatListCell(row.teams) - } + switch (col) { + case RoleColumn.VisibleBelow: + return row.visible_below == null ? "" : row.visible_below ? "Yes" : "No"; + case RoleColumn.DefaultRole: + return row.default_role == null ? "" : row.default_role ? "Yes" : "No"; + case RoleColumn.Admin: + return row.admin == null ? "" : row.admin ? "Yes" : "No"; + case RoleColumn.Node: + return row.node ?? ""; + case RoleColumn.UserCount: + return row.user_count == null ? "" : String(row.user_count); + case RoleColumn.Users: + return formatListCell(row.users); + case RoleColumn.TeamCount: + return row.team_count == null ? "" : String(row.team_count); + case RoleColumn.Teams: + return formatListCell(row.teams); + } } diff --git a/KeeperSdk/src/roles/manageNode.ts b/KeeperSdk/src/roles/manageNode.ts new file mode 100644 index 00000000..a797ea70 --- /dev/null +++ b/KeeperSdk/src/roles/manageNode.ts @@ -0,0 +1,447 @@ +import { + Enterprise, + getPublicKeysMessage, + platform, + roleManagedNodeAddCommand, + roleManagedNodeRemoveCommand, + roleManagedNodeUpdateCommand, + webSafe64FromBytes, + type Auth, + type Authentication, + type RoleManagedNodeTreeKey, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, logger, ResultCodes } from "../utils"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRoleManagedNodeLink, + type EnterpriseRoleUserLink, + type EnterpriseUser, +} from "../teams/enterpriseData"; +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + resolveParentNode, +} from "../teams/teamUtils"; +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + normalizeIdentifiers, + resolveExistingRoles, + resolveToggle, + type RoleToggleInput, +} from "./roleUtils"; + +const MANAGE_NODE_BASE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.ManagedNodes, +]; + +const MANAGE_NODE_TABLE_HEADERS = [ + "#", + "Status", + "Role Name", + "Role ID", + "Node Name", + "Node ID", + "Detail", +]; + +export type ManageRoleNodesAction = "add" | "update" | "remove"; + +export enum RoleManagedNodeStatus { + Added = "added", + Updated = "updated", + Removed = "removed", + Skipped = "skipped", + Failed = "failed", +} + +export type ManageRoleNodesInput = { + roles: string[]; + nodes: string[]; + action: ManageRoleNodesAction; + cascade?: RoleToggleInput; +}; + +export type RoleManagedNodeItemResult = { + roleId: number; + roleName: string; + nodeId: number; + nodeName: string; + status: RoleManagedNodeStatus; + message?: string; +}; + +export type ManageRoleNodesResult = { + success: boolean; + items: RoleManagedNodeItemResult[]; + succeeded: number; + skipped: number; + failed: number; +}; + +export type FormattedManageRoleNodesTable = { + headers: string[]; + rows: string[][]; + summary: string; +}; + +type UserPublicKeys = { + rsaPublicKey: Uint8Array | null; + eccPublicKey: Uint8Array | null; +}; + +function findLink( + links: EnterpriseRoleManagedNodeLink[], + roleId: number, + nodeId: number, +): EnterpriseRoleManagedNodeLink | undefined { + return links.find( + (link) => link.role_id === roleId && link.managed_node_id === nodeId, + ); +} + +function roleDisplayName(role: EnterpriseRole): string { + return (role.displayName || "").trim() || String(role.role_id); +} + +async function fetchUserPublicKeys( + auth: Auth, + emails: string[], +): Promise> { + const result = new Map(); + if (emails.length === 0) return result; + + let response: Authentication.IGetPublicKeysResponse; + try { + response = await auth.executeRest(getPublicKeysMessage({ usernames: emails })); + } catch (err) { + logger.warn(`Failed to fetch public keys: ${extractErrorMessage(err)}`); + return result; + } + + for (const entry of response.keyResponses || []) { + const username = (entry.username || "").toLowerCase(); + if (!username || entry.errorCode) continue; + result.set(username, { + rsaPublicKey: entry.publicKey && entry.publicKey.length > 0 ? entry.publicKey : null, + eccPublicKey: + entry.publicEccKey && entry.publicEccKey.length > 0 ? entry.publicEccKey : null, + }); + } + return result; +} + +async function encryptTreeKeyForUser( + treeKey: Uint8Array, + publicKeys: UserPublicKeys, + enterpriseUserId: number, +): Promise { + if (publicKeys.rsaPublicKey) { + const encrypted = platform.publicEncrypt( + treeKey, + platform.bytesToBase64(publicKeys.rsaPublicKey), + ); + return { + enterprise_user_id: enterpriseUserId, + tree_key: webSafe64FromBytes(encrypted), + tree_key_type: Enterprise.EncryptedKeyType.KT_ENCRYPTED_BY_PUBLIC_KEY, + }; + } + if (publicKeys.eccPublicKey) { + const encrypted = await platform.publicEncryptEC(treeKey, publicKeys.eccPublicKey); + return { + enterprise_user_id: enterpriseUserId, + tree_key: webSafe64FromBytes(encrypted), + tree_key_type: Enterprise.EncryptedKeyType.KT_ENCRYPTED_BY_PUBLIC_KEY_ECC, + }; + } + return null; +} + +async function buildTreeKeysForRole( + auth: Auth, + roleId: number, + roleUserLinks: EnterpriseRoleUserLink[], + usersById: Map, + treeKey: Uint8Array, +): Promise { + const memberIds = roleUserLinks + .filter((link) => link.role_id === roleId) + .map((link) => link.enterprise_user_id); + if (memberIds.length === 0) return []; + + const emails = memberIds + .map((id) => usersById.get(id)?.username) + .filter((email): email is string => !!email); + const publicKeyMap = await fetchUserPublicKeys(auth, emails); + + const treeKeys: RoleManagedNodeTreeKey[] = []; + for (const userId of memberIds) { + const user = usersById.get(userId); + if (!user?.username) continue; + const publicKeys = publicKeyMap.get(user.username.toLowerCase()); + if (!publicKeys) { + logger.warn( + `No public key available for "${user.username}"; role admin will not be able to decrypt the enterprise tree key for this node until they re-key.`, + ); + continue; + } + const entry = await encryptTreeKeyForUser(treeKey, publicKeys, userId); + if (entry) treeKeys.push(entry); + } + return treeKeys; +} + +export async function manageRoleNodes( + auth: Auth, + input: ManageRoleNodesInput, +): Promise { + const roleIdentifiers = normalizeIdentifiers(input.roles); + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError("No roles specified.", ResultCodes.ROLE_REQUIRED); + } + const nodeIdentifiers = normalizeIdentifiers(input.nodes); + if (nodeIdentifiers.length === 0) { + throw new KeeperSdkError( + "No nodes specified.", + ResultCodes.NO_NODES_FOR_ROLE_OP, + ); + } + + const action = input.action; + const cascade = resolveToggle(input.cascade); + + const includes = [...MANAGE_NODE_BASE_INCLUDES]; + if (action === "add") { + includes.push(EnterpriseDataInclude.Users, EnterpriseDataInclude.RoleUsers); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(includes), + enterpriseData.getDisplayNames(), + ]); + + const nodes = response.nodes || []; + applyDecryptedNodeNames(nodes, displayNames.nodes); + applyEnterpriseNameToRoot(nodes, response.enterprise_name); + await enterpriseData.decryptNodeNames(nodes); + + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + const resolvedRoles = resolveExistingRoles(roles, roleIdentifiers); + const resolvedNodes: EnterpriseNode[] = nodeIdentifiers.map((identifier) => + resolveParentNode(nodes, identifier), + ); + + const managedNodeLinks = response.managed_nodes || []; + const usersById = new Map(); + for (const user of response.users || []) usersById.set(user.enterprise_user_id, user); + + let treeKey: Uint8Array | null = null; + if (action === "add") { + treeKey = await enterpriseData.getTreeKey(); + if (!treeKey) { + throw new KeeperSdkError( + "Enterprise tree key is unavailable. The current user may not have permission to administer roles.", + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + } + + const items: RoleManagedNodeItemResult[] = []; + for (const role of resolvedRoles) { + const roleName = roleDisplayName(role); + + for (const node of resolvedNodes) { + const nodeName = nodePathOrFallback(nodes, node); + const existingLink = findLink(managedNodeLinks, role.role_id, node.node_id); + + try { + if (action === "add") { + if (existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" already manages node "${nodeName}".`, + }); + continue; + } + const treeKeys = await buildTreeKeysForRole( + auth, + role.role_id, + response.role_users || [], + usersById, + treeKey as Uint8Array, + ); + const addResponse = await auth.executeRestCommand( + roleManagedNodeAddCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + cascade_node_management: cascade ?? false, + tree_keys: treeKeys.length > 0 ? treeKeys : undefined, + }), + ); + assertCommandSucceeded( + addResponse, + `role_managed_node_add failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_ADD_FAILED, + ); + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Added, + }); + } else if (action === "update") { + if (!existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" does not manage node "${nodeName}".`, + }); + continue; + } + const updateResponse = await auth.executeRestCommand( + roleManagedNodeUpdateCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + cascade_node_management: + cascade ?? existingLink.cascade_node_management, + }), + ); + assertCommandSucceeded( + updateResponse, + `role_managed_node_update failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_UPDATE_FAILED, + ); + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Updated, + }); + } else { + if (!existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" does not manage node "${nodeName}".`, + }); + continue; + } + const removeResponse = await auth.executeRestCommand( + roleManagedNodeRemoveCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + }), + ); + assertCommandSucceeded( + removeResponse, + `role_managed_node_remove failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_REMOVE_FAILED, + ); + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Removed, + }); + } + } catch (err) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Failed, + message: extractErrorMessage(err), + }); + } + } + } + + return finalizeResult(items); +} + +function finalizeResult( + items: RoleManagedNodeItemResult[], +): ManageRoleNodesResult { + let succeeded = 0; + let skipped = 0; + let failed = 0; + for (const item of items) { + if ( + item.status === RoleManagedNodeStatus.Added || + item.status === RoleManagedNodeStatus.Updated || + item.status === RoleManagedNodeStatus.Removed + ) + succeeded++; + else if (item.status === RoleManagedNodeStatus.Skipped) skipped++; + else failed++; + } + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + }; +} + +export function formatManageRoleNodesResult( + result: ManageRoleNodesResult, +): FormattedManageRoleNodesTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.roleName, + String(item.roleId), + item.nodeName, + String(item.nodeId), + item.message || "", + ]); + return { + headers: [...MANAGE_NODE_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + }; +} + +export function renderManageRoleNodesAsciiTable( + table: FormattedManageRoleNodesTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + return [ + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join("\n"); +} diff --git a/KeeperSdk/src/roles/roleEnforcement.ts b/KeeperSdk/src/roles/roleEnforcement.ts new file mode 100644 index 00000000..6afc4099 --- /dev/null +++ b/KeeperSdk/src/roles/roleEnforcement.ts @@ -0,0 +1,138 @@ +import type { Auth } from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, +} from "../teams/enterpriseData"; +import { + applyDecryptedRoleNames, + applyRoleEnforcements, + buildRoleResultRows, + normalizeIdentifiers, + parseEnforcements, + renderRoleResultTable, + resolveExistingRoles, + ROLE_TABLE_HEADERS, +} from "./roleUtils"; + +const ROLE_ENFORCEMENT_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleEnforcements, +]; + +export enum RoleEnforcementStatus { + Applied = "applied", + Failed = "failed", +} + +export type SetRoleEnforcementsInput = { + roles: string[]; + enforcements: string[]; +}; + +export type RoleEnforcementItemResult = { + roleId: number; + roleName: string; + nodeId: number; + status: RoleEnforcementStatus; + message?: string; +}; + +export type SetRoleEnforcementsResult = { + success: boolean; + items: RoleEnforcementItemResult[]; + applied: number; + failed: number; +}; + +export type FormattedRoleEnforcementTable = { + headers: string[]; + rows: string[][]; + summary: string; +}; + +export async function setRoleEnforcements( + auth: Auth, + input: SetRoleEnforcementsInput, +): Promise { + const identifiers = normalizeIdentifiers(input.roles); + if (identifiers.length === 0) { + throw new KeeperSdkError("No roles specified.", ResultCodes.ROLE_REQUIRED); + } + + const enforcements = parseEnforcements(input.enforcements ?? []); + if (enforcements.length === 0) { + throw new KeeperSdkError( + "No enforcements to apply. Use KEY:VALUE pairs (VALUE=false removes an enforcement).", + ResultCodes.NO_ENFORCEMENTS_TO_APPLY, + ); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_ENFORCEMENT_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + const resolvedRoles = resolveExistingRoles(roles, identifiers); + + const items: RoleEnforcementItemResult[] = []; + for (const role of resolvedRoles) { + const roleName = (role.displayName || "").trim() || String(role.role_id); + const nodeId = role.node_id ?? 0; + try { + await applyRoleEnforcements( + auth, + role.role_id, + enforcements, + response.role_enforcements || [], + ); + items.push({ + roleId: role.role_id, + roleName, + nodeId, + status: RoleEnforcementStatus.Applied, + }); + } catch (err) { + items.push({ + roleId: role.role_id, + roleName, + nodeId, + status: RoleEnforcementStatus.Failed, + message: extractErrorMessage(err), + }); + } + } + + return finalizeResult(items); +} + +export function formatSetRoleEnforcementsResult( + result: SetRoleEnforcementsResult, +): FormattedRoleEnforcementTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows(result.items, (item) => item.message || ""), + summary: `Applied: ${result.applied} Failed: ${result.failed}`, + }; +} + +export function renderRoleEnforcementAsciiTable( + table: FormattedRoleEnforcementTable, +): string { + return renderRoleResultTable(table.headers, table.rows, table.summary); +} + +function finalizeResult( + items: RoleEnforcementItemResult[], +): SetRoleEnforcementsResult { + const applied = items.filter( + (item) => item.status === RoleEnforcementStatus.Applied, + ).length; + const failed = items.filter( + (item) => item.status === RoleEnforcementStatus.Failed, + ).length; + return { success: failed === 0 && applied > 0, items, applied, failed }; +} diff --git a/KeeperSdk/src/roles/rolePrivilege.ts b/KeeperSdk/src/roles/rolePrivilege.ts new file mode 100644 index 00000000..6da4c104 --- /dev/null +++ b/KeeperSdk/src/roles/rolePrivilege.ts @@ -0,0 +1,312 @@ +import { + encryptForStorage, + encryptKey, + generateEncryptionKey, + managedNodePrivilegeAddCommand, + managedNodePrivilegeRemoveCommand, + platform, + type Auth, + type ManagedNodePrivilegeAddRequest, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, +} from "../teams/enterpriseData"; +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + resolveParentNode, +} from "../teams/teamUtils"; +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + normalizeIdentifiers, + resolveExistingRoles, +} from "./roleUtils"; + +/** + * Privileges that grant the underlying managed node the ability to act on the enterprise's + * behalf (transfer another user's vault, or administer managed companies). Commander protects + * these operations with a per-role AES key (encrypted with the enterprise tree key) plus an + * RSA keypair for the role. The exact wire contract for `role_key_enc_with_tree_key` / + * `role_public_key` / `role_private_key` has not been verified against a live server in this + * SDK; treat this path as best-effort/experimental. Regular privileges do not need any of this. + */ +const SPECIAL_PRIVILEGES = new Set(["transfer_account", "manage_companies"]); + +const ROLE_PRIVILEGE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.ManagedNodes, +]; + +const ROLE_PRIVILEGE_TABLE_HEADERS = [ + "#", + "Status", + "Action", + "Privilege", + "Detail", +]; + +export enum RolePrivilegeStatus { + Added = "added", + Removed = "removed", + Skipped = "skipped", + Failed = "failed", +} + +export type ChangeRolePrivilegesInput = { + role: string; + node: string | number; + add?: string[]; + remove?: string[]; +}; + +export type RolePrivilegeItemResult = { + privilege: string; + action: "add" | "remove"; + status: RolePrivilegeStatus; + message?: string; +}; + +export type ChangeRolePrivilegesResult = { + success: boolean; + roleId: number; + roleName: string; + nodeId: number; + nodeName: string; + items: RolePrivilegeItemResult[]; + succeeded: number; + skipped: number; + failed: number; +}; + +export type FormattedRolePrivilegeTable = { + headers: string[]; + rows: string[][]; + parentLabel: string; + summary: string; +}; + +type RoleKeyMaterial = { + role_key_enc_with_tree_key: string; + role_public_key: string; + role_private_key: string; +}; + +async function buildRoleKeyMaterial(treeKey: Uint8Array): Promise { + const roleKey = generateEncryptionKey(); + const roleKeyEncWithTreeKey = await encryptKey(roleKey, treeKey); + const { privateKey, publicKey } = await platform.generateRSAKeyPair(); + return { + role_key_enc_with_tree_key: roleKeyEncWithTreeKey, + role_public_key: platform.bytesToBase64(publicKey), + role_private_key: await encryptForStorage(privateKey, roleKey), + }; +} + +export async function changeRolePrivileges( + auth: Auth, + input: ChangeRolePrivilegesInput, +): Promise { + const roleIdentifiers = normalizeIdentifiers([input.role]); + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError("A role is required.", ResultCodes.ROLE_REQUIRED); + } + if (input.node === undefined || input.node === null || input.node === "") { + throw new KeeperSdkError( + "A managed node is required.", + ResultCodes.NO_NODES_FOR_ROLE_OP, + ); + } + + const addPrivileges = normalizeIdentifiers(input.add ?? []).map((p) => + p.toLowerCase(), + ); + const removePrivileges = normalizeIdentifiers(input.remove ?? []).map((p) => + p.toLowerCase(), + ); + if (addPrivileges.length === 0 && removePrivileges.length === 0) { + throw new KeeperSdkError( + "No privileges specified. Use add and/or remove.", + ResultCodes.NO_PRIVILEGES_SPECIFIED, + ); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_PRIVILEGE_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const nodes = response.nodes || []; + applyDecryptedNodeNames(nodes, displayNames.nodes); + applyEnterpriseNameToRoot(nodes, response.enterprise_name); + await enterpriseData.decryptNodeNames(nodes); + + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + const resolvedRoles = resolveExistingRoles(roles, roleIdentifiers); + const role = resolvedRoles[0]; + const roleName = (role.displayName || "").trim() || String(role.role_id); + + const node = resolveParentNode(nodes, input.node); + const nodeName = nodePathOrFallback(nodes, node); + + const managedNodeLink = (response.managed_nodes || []).find( + (link) => link.role_id === role.role_id && link.managed_node_id === node.node_id, + ); + if (!managedNodeLink) { + throw new KeeperSdkError( + `Role "${roleName}" does not manage node "${nodeName}". Add the role to the node first.`, + ResultCodes.MANAGED_NODE_NOT_MANAGED_BY_ROLE, + ); + } + + const needsRoleKeyMaterial = addPrivileges.some((p) => SPECIAL_PRIVILEGES.has(p)); + const treeKey = needsRoleKeyMaterial ? await enterpriseData.getTreeKey() : null; + if (needsRoleKeyMaterial && !treeKey) { + throw new KeeperSdkError( + "Enterprise tree key is unavailable; cannot grant transfer_account/manage_companies.", + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + const roleKeyMaterial = treeKey ? await buildRoleKeyMaterial(treeKey) : null; + + const items: RolePrivilegeItemResult[] = []; + + for (const privilege of removePrivileges) { + try { + const response = await auth.executeRestCommand( + managedNodePrivilegeRemoveCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + privilege, + }), + ); + assertCommandSucceeded( + response, + `managed_node_privilege_remove failed for privilege=${privilege}`, + ResultCodes.ROLE_PRIVILEGE_REMOVE_FAILED, + ); + items.push({ + privilege, + action: "remove", + status: RolePrivilegeStatus.Removed, + }); + } catch (err) { + items.push({ + privilege, + action: "remove", + status: RolePrivilegeStatus.Failed, + message: extractErrorMessage(err), + }); + } + } + + for (const privilege of addPrivileges) { + try { + const payload: ManagedNodePrivilegeAddRequest = { + role_id: role.role_id, + managed_node_id: node.node_id, + privilege, + }; + if (SPECIAL_PRIVILEGES.has(privilege) && roleKeyMaterial) { + Object.assign(payload, roleKeyMaterial); + } + const response = await auth.executeRestCommand( + managedNodePrivilegeAddCommand(payload), + ); + assertCommandSucceeded( + response, + `managed_node_privilege_add failed for privilege=${privilege}`, + ResultCodes.ROLE_PRIVILEGE_ADD_FAILED, + ); + items.push({ + privilege, + action: "add", + status: RolePrivilegeStatus.Added, + }); + } catch (err) { + items.push({ + privilege, + action: "add", + status: RolePrivilegeStatus.Failed, + message: extractErrorMessage(err), + }); + } + } + + return finalizeResult(role.role_id, roleName, node.node_id, nodeName, items); +} + +function finalizeResult( + roleId: number, + roleName: string, + nodeId: number, + nodeName: string, + items: RolePrivilegeItemResult[], +): ChangeRolePrivilegesResult { + let succeeded = 0; + let failed = 0; + for (const item of items) { + if ( + item.status === RolePrivilegeStatus.Added || + item.status === RolePrivilegeStatus.Removed + ) + succeeded++; + else if (item.status === RolePrivilegeStatus.Failed) failed++; + } + return { + success: failed === 0 && succeeded > 0, + roleId, + roleName, + nodeId, + nodeName, + items, + succeeded, + skipped: 0, + failed, + }; +} + +export function formatChangeRolePrivilegesResult( + result: ChangeRolePrivilegesResult, +): FormattedRolePrivilegeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.action, + item.privilege, + item.message || "", + ]); + return { + headers: [...ROLE_PRIVILEGE_TABLE_HEADERS], + rows, + parentLabel: `Role: ${result.roleName} Node: ${result.nodeName}`, + summary: `Succeeded: ${result.succeeded} Failed: ${result.failed}`, + }; +} + +export function renderChangeRolePrivilegesAsciiTable( + table: FormattedRolePrivilegeTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + return [ + table.parentLabel, + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join("\n"); +} diff --git a/KeeperSdk/src/roles/roleTypes.ts b/KeeperSdk/src/roles/roleTypes.ts index 09f95e0c..a15e8990 100644 --- a/KeeperSdk/src/roles/roleTypes.ts +++ b/KeeperSdk/src/roles/roleTypes.ts @@ -1,53 +1,54 @@ -import type { EnterpriseDataManagerApi } from '../teams/enterpriseData' +import type { EnterpriseDataManagerApi } from "../teams/enterpriseData"; export enum RoleColumn { - VisibleBelow = 'visible_below', - DefaultRole = 'default_role', - Admin = 'admin', - Node = 'node', - UserCount = 'user_count', - Users = 'users', - TeamCount = 'team_count', - Teams = 'teams', + VisibleBelow = "visible_below", + DefaultRole = "default_role", + Admin = "admin", + Node = "node", + UserCount = "user_count", + Users = "users", + TeamCount = "team_count", + Teams = "teams", } -export type RoleColumnInput = RoleColumn | `${RoleColumn}` +export type RoleColumnInput = RoleColumn | `${RoleColumn}`; -export const SUPPORTED_ROLE_COLUMNS: readonly RoleColumn[] = Object.values(RoleColumn) +export const SUPPORTED_ROLE_COLUMNS: readonly RoleColumn[] = + Object.values(RoleColumn); export const DEFAULT_ROLE_COLUMNS: readonly RoleColumn[] = [ - RoleColumn.DefaultRole, - RoleColumn.Admin, - RoleColumn.Node, - RoleColumn.UserCount, -] + RoleColumn.DefaultRole, + RoleColumn.Admin, + RoleColumn.Node, + RoleColumn.UserCount, +]; -export const ALL_COLUMNS_WILDCARD = '*' as const +export const ALL_COLUMNS_WILDCARD = "*" as const; export type ListRolesOptions = { - pattern?: string | null - columns?: RoleColumnInput[] | typeof ALL_COLUMNS_WILDCARD | string | null - enterpriseData?: EnterpriseDataManagerApi -} + pattern?: string | null; + columns?: RoleColumnInput[] | typeof ALL_COLUMNS_WILDCARD | string | null; + enterpriseData?: EnterpriseDataManagerApi; +}; export type ListRoleRow = { - role_id: number - name: string - visible_below?: boolean - default_role?: boolean - admin?: boolean - node?: string - user_count?: number - users?: string[] - team_count?: number - teams?: string[] -} + role_id: number; + name: string; + visible_below?: boolean; + default_role?: boolean; + admin?: boolean; + node?: string; + user_count?: number; + users?: string[]; + team_count?: number; + teams?: string[]; +}; export type FormattedRolesTable = { - headers: string[] - rows: string[][] -} + headers: string[]; + rows: string[][]; +}; export type FormatRolesTableOptions = { - columns?: ListRolesOptions['columns'] -} + columns?: ListRolesOptions["columns"]; +}; diff --git a/KeeperSdk/src/roles/roleUser.ts b/KeeperSdk/src/roles/roleUser.ts new file mode 100644 index 00000000..c5da1392 --- /dev/null +++ b/KeeperSdk/src/roles/roleUser.ts @@ -0,0 +1,296 @@ +import { + roleUserAddCommand, + roleUserRemoveCommand, + type Auth, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRole, + type EnterpriseRoleUserLink, + type EnterpriseUser, +} from "../teams/enterpriseData"; +import { resolveExistingUsers } from "../users/userTypes"; +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + normalizeIdentifiers, + resolveExistingRoles, +} from "./roleUtils"; + +const ROLE_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.RoleUsers, +]; + +const ROLE_USER_TABLE_HEADERS = [ + "#", + "Status", + "User Email", + "User ID", + "Role Name", + "Role ID", + "Detail", +]; + +export enum RoleUserStatus { + Added = "added", + Removed = "removed", + Skipped = "skipped", + Failed = "failed", +} + +export enum RoleUserSkipReason { + AlreadyMember = "already_member", + NotMember = "not_member", +} + +export type AddUsersToRolesInput = { + roles: string[]; + users: string[]; +}; + +export type RemoveUsersFromRolesInput = { + roles: string[]; + users: string[]; +}; + +export type RoleUserItemResult = { + username: string; + enterpriseUserId: number; + roleId: number; + roleName: string; + status: RoleUserStatus; + skipReason?: RoleUserSkipReason; + message?: string; +}; + +export type RoleUserResult = { + success: boolean; + items: RoleUserItemResult[]; + succeeded: number; + skipped: number; + failed: number; +}; + +export type FormattedRoleUserTable = { + headers: string[]; + rows: string[][]; + summary: string; +}; + +type RoleUserContext = { + roles: EnterpriseRole[]; + users: EnterpriseUser[]; + membership: Set; +}; + +const membershipKey = (roleId: number, userId: number): string => + `${roleId}:${userId}`; + +async function loadRoleUserContext( + auth: Auth, + rawRoles: string[], + rawUsers: string[], +): Promise { + const roleIdentifiers = normalizeIdentifiers(rawRoles); + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError("No roles specified.", ResultCodes.ROLE_REQUIRED); + } + const userIdentifiers = normalizeIdentifiers(rawUsers); + if (userIdentifiers.length === 0) { + throw new KeeperSdkError( + "No users specified.", + ResultCodes.NO_USERS_FOR_ROLE_OP, + ); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_USER_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + + return { + roles: resolveExistingRoles(roles, roleIdentifiers), + users: resolveExistingUsers(response.users || [], userIdentifiers), + membership: buildMembershipSet(response.role_users), + }; +} + +function buildMembershipSet( + roleUsers: EnterpriseRoleUserLink[] | undefined, +): Set { + const membership = new Set(); + for (const link of roleUsers || []) { + membership.add(membershipKey(link.role_id, link.enterprise_user_id)); + } + return membership; +} + +function roleDisplayName(role: EnterpriseRole): string { + return (role.displayName || "").trim() || String(role.role_id); +} + +function buildItemBase( + user: EnterpriseUser, + role: EnterpriseRole, +): Omit { + return { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + roleId: role.role_id, + roleName: roleDisplayName(role), + }; +} + +export async function addUsersToRoles( + auth: Auth, + input: AddUsersToRolesInput, +): Promise { + const ctx = await loadRoleUserContext(auth, input.roles, input.users); + const items: RoleUserItemResult[] = []; + + for (const role of ctx.roles) { + for (const user of ctx.users) { + const base = buildItemBase(user, role); + if (ctx.membership.has(membershipKey(role.role_id, user.enterprise_user_id))) { + items.push({ + ...base, + status: RoleUserStatus.Skipped, + skipReason: RoleUserSkipReason.AlreadyMember, + }); + continue; + } + + try { + const response = await auth.executeRestCommand( + roleUserAddCommand({ + role_id: role.role_id, + enterprise_user_id: user.enterprise_user_id, + }), + ); + assertCommandSucceeded( + response, + `role_user_add failed for role_id=${role.role_id}, user=${user.username}`, + ResultCodes.ROLE_USER_ADD_FAILED, + ); + items.push({ ...base, status: RoleUserStatus.Added }); + } catch (err) { + items.push({ + ...base, + status: RoleUserStatus.Failed, + message: extractErrorMessage(err), + }); + } + } + } + + return finalizeResult(items); +} + +export async function removeUsersFromRoles( + auth: Auth, + input: RemoveUsersFromRolesInput, +): Promise { + const ctx = await loadRoleUserContext(auth, input.roles, input.users); + const items: RoleUserItemResult[] = []; + + for (const role of ctx.roles) { + for (const user of ctx.users) { + const base = buildItemBase(user, role); + if (!ctx.membership.has(membershipKey(role.role_id, user.enterprise_user_id))) { + items.push({ + ...base, + status: RoleUserStatus.Skipped, + skipReason: RoleUserSkipReason.NotMember, + }); + continue; + } + + try { + const response = await auth.executeRestCommand( + roleUserRemoveCommand({ + role_id: role.role_id, + enterprise_user_id: user.enterprise_user_id, + }), + ); + assertCommandSucceeded( + response, + `role_user_remove failed for role_id=${role.role_id}, user=${user.username}`, + ResultCodes.ROLE_USER_REMOVE_FAILED, + ); + items.push({ ...base, status: RoleUserStatus.Removed }); + } catch (err) { + items.push({ + ...base, + status: RoleUserStatus.Failed, + message: extractErrorMessage(err), + }); + } + } + } + + return finalizeResult(items); +} + +function finalizeResult(items: RoleUserItemResult[]): RoleUserResult { + let succeeded = 0; + let skipped = 0; + let failed = 0; + for (const item of items) { + if (item.status === RoleUserStatus.Added || item.status === RoleUserStatus.Removed) + succeeded++; + else if (item.status === RoleUserStatus.Skipped) skipped++; + else failed++; + } + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + }; +} + +export function formatRoleUserResult( + result: RoleUserResult, +): FormattedRoleUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.roleName, + String(item.roleId), + item.message || item.skipReason || "", + ]); + return { + headers: [...ROLE_USER_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + }; +} + +export function renderRoleUserAsciiTable(table: FormattedRoleUserTable): string { + const { headers, rows } = table; + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + return [ + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join("\n"); +} diff --git a/KeeperSdk/src/roles/roleUtils.ts b/KeeperSdk/src/roles/roleUtils.ts index 5e3c5037..2843fbd2 100644 --- a/KeeperSdk/src/roles/roleUtils.ts +++ b/KeeperSdk/src/roles/roleUtils.ts @@ -1,229 +1,268 @@ import { - roleEnforcementAddCommand, - roleEnforcementRemoveCommand, - roleEnforcementUpdateCommand, - type Auth, - type KeeperResponse, -} from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' + roleEnforcementAddCommand, + roleEnforcementRemoveCommand, + roleEnforcementUpdateCommand, + type Auth, + type KeeperResponse, +} from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; import { - EnterpriseDataManager, - type EnterpriseNode, - type EnterpriseRole, - type EnterpriseRoleEnforcementLink, -} from '../teams/enterpriseData' + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRoleEnforcementLink, +} from "../teams/enterpriseData"; -export const ROLE_TABLE_HEADERS = ['#', 'Status', 'Role Name', 'Role ID', 'Node ID', 'Detail'] as const -export const NODE_PATH_SEPARATOR = '\\' +export const ROLE_TABLE_HEADERS = [ + "#", + "Status", + "Role Name", + "Role ID", + "Node ID", + "Detail", +] as const; +export const NODE_PATH_SEPARATOR = "\\"; -export type RoleToggle = 'on' | 'off' -export type RoleToggleInput = RoleToggle | `${RoleToggle}` | null | undefined -export type EnforcementPair = { key: string; value: string } +export type RoleToggle = "on" | "off"; +export type RoleToggleInput = RoleToggle | `${RoleToggle}` | null | undefined; +export type EnforcementPair = { key: string; value: string }; -type RoleResultRow = { status: string; roleName: string; roleId: number; nodeId: number } +type RoleResultRow = { + status: string; + roleName: string; + roleId: number; + nodeId: number; +}; -export function normalizeIdentifiers(values: ReadonlyArray | null | undefined): string[] { - return (values || []) - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter((value) => value.length > 0) +export function normalizeIdentifiers( + values: ReadonlyArray | null | undefined, +): string[] { + return (values || []) + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter((value) => value.length > 0); } export function validateRoleName(name: string): void { - if (!name.trim()) { - throw new KeeperSdkError('Role name cannot be empty.', ResultCodes.ROLE_NAME_EMPTY) - } + if (!name.trim()) { + throw new KeeperSdkError( + "Role name cannot be empty.", + ResultCodes.ROLE_NAME_EMPTY, + ); + } } -export function buildRolesByLowerName(roles: EnterpriseRole[]): Map { - const map = new Map() - for (const role of roles) { - const key = (role.displayName || '').trim().toLowerCase() - if (!key) continue - const bucket = map.get(key) - if (bucket) bucket.push(role) - else map.set(key, [role]) - } - return map +export function buildRolesByLowerName( + roles: EnterpriseRole[], +): Map { + const map = new Map(); + for (const role of roles) { + const key = (role.displayName || "").trim().toLowerCase(); + if (!key) continue; + const bucket = map.get(key); + if (bucket) bucket.push(role); + else map.set(key, [role]); + } + return map; } -export function resolveExistingRoles(roles: EnterpriseRole[], identifiers: string[]): EnterpriseRole[] { - const byId = new Map(roles.map((role) => [role.role_id, role])) - const byLowerName = buildRolesByLowerName(roles) - - const found = new Map() - const missing: string[] = [] - for (const identifier of identifiers) { - const trimmed = identifier.trim() - const numeric = Number(trimmed) - if (Number.isInteger(numeric) && byId.has(numeric)) { - const match = byId.get(numeric)! - found.set(match.role_id, match) - continue - } - const nameMatches = byLowerName.get(trimmed.toLowerCase()) - if (!nameMatches || nameMatches.length === 0) { - missing.push(trimmed) - continue - } - if (nameMatches.length > 1) { - throw new KeeperSdkError( - `Role name "${trimmed}" is not unique. Use Role ID instead.`, - ResultCodes.MULTIPLE_ROLE_MATCHES - ) - } - found.set(nameMatches[0].role_id, nameMatches[0]) - } +export function resolveExistingRoles( + roles: EnterpriseRole[], + identifiers: string[], +): EnterpriseRole[] { + const byId = new Map( + roles.map((role) => [role.role_id, role]), + ); + const byLowerName = buildRolesByLowerName(roles); - if (missing.length > 0) { - throw new KeeperSdkError( - `Role(s) "${missing.join(', ')}" could not be resolved.`, - ResultCodes.ROLE_NOT_FOUND - ) + const found = new Map(); + const missing: string[] = []; + for (const identifier of identifiers) { + const trimmed = identifier.trim(); + const numeric = Number(trimmed); + if (Number.isInteger(numeric) && byId.has(numeric)) { + const match = byId.get(numeric)!; + found.set(match.role_id, match); + continue; + } + const nameMatches = byLowerName.get(trimmed.toLowerCase()); + if (!nameMatches || nameMatches.length === 0) { + missing.push(trimmed); + continue; } - return Array.from(found.values()) + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Role name "${trimmed}" is not unique. Use Role ID instead.`, + ResultCodes.MULTIPLE_ROLE_MATCHES, + ); + } + found.set(nameMatches[0].role_id, nameMatches[0]); + } + + if (missing.length > 0) { + throw new KeeperSdkError( + `Role(s) "${missing.join(", ")}" could not be resolved.`, + ResultCodes.ROLE_NOT_FOUND, + ); + } + return Array.from(found.values()); } export function parseEnforcements(rawList: string[]): EnforcementPair[] { - return rawList - .map((raw) => { - const sep = raw.indexOf(':') - if (sep < 1) return null - const key = raw.slice(0, sep).trim() - const value = raw.slice(sep + 1).trim() - return key && value ? ({ key, value } as EnforcementPair) : null - }) - .filter((entry): entry is EnforcementPair => entry !== null) + return rawList + .map((raw) => { + const sep = raw.indexOf(":"); + if (sep < 1) return null; + const key = raw.slice(0, sep).trim(); + const value = raw.slice(sep + 1).trim(); + return key && value ? ({ key, value } as EnforcementPair) : null; + }) + .filter((entry): entry is EnforcementPair => entry !== null); } export function resolveToggle(input: RoleToggleInput): boolean | undefined { - if (input === 'on') return true - if (input === 'off') return false - return undefined + if (input === "on") return true; + if (input === "off") return false; + return undefined; } -export function applyDecryptedRoleNames(roles: EnterpriseRole[], decrypted: Map): void { - if (decrypted.size === 0) return - for (const role of roles) { - const display = decrypted.get(role.role_id) - if (display) role.displayName = display - } +export function applyDecryptedRoleNames( + roles: EnterpriseRole[], + decrypted: Map, +): void { + if (decrypted.size === 0) return; + for (const role of roles) { + const display = decrypted.get(role.role_id); + if (display) role.displayName = display; + } } -export function nodePathOrFallback(nodes: EnterpriseNode[], node: EnterpriseNode): string { - const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { - omitRoot: false, - separator: NODE_PATH_SEPARATOR, - }) - return path || (node.displayName || '').trim() || String(node.node_id) +export function nodePathOrFallback( + nodes: EnterpriseNode[], + node: EnterpriseNode, +): string { + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }); + return path || (node.displayName || "").trim() || String(node.node_id); } -export function assertCommandSucceeded(response: KeeperResponse, fallbackMessage: string, fallbackCode: string): void { - if ((response.result || '').toLowerCase() === 'fail') { - throw new KeeperSdkError( - response.message || response.result_code || fallbackMessage, - response.result_code || fallbackCode - ) - } +export function assertCommandSucceeded( + response: KeeperResponse, + fallbackMessage: string, + fallbackCode: string, +): void { + if ((response.result || "").toLowerCase() === "fail") { + throw new KeeperSdkError( + response.message || response.result_code || fallbackMessage, + response.result_code || fallbackCode, + ); + } } -const ENFORCEMENT_FALSE = new Set(['false', 'f', '0', 'off', 'no', 'n']) -const ENFORCEMENT_TRUE = new Set(['true', 't', '1', 'on', 'yes', 'y']) +const ENFORCEMENT_FALSE = new Set(["false", "f", "0", "off", "no", "n"]); +const ENFORCEMENT_TRUE = new Set(["true", "t", "1", "on", "yes", "y"]); function normalizeEnforcementKey(key: string): string { - return key.trim().toLowerCase().replace(/-/g, '_') + return key.trim().toLowerCase().replace(/-/g, "_"); } function roleHasEnforcement( - roleId: number, - enforcement: string, - links: readonly EnterpriseRoleEnforcementLink[] + roleId: number, + enforcement: string, + links: readonly EnterpriseRoleEnforcementLink[], ): boolean { - return links.some( - (link) => - link.role_id === roleId && normalizeEnforcementKey(link.enforcement_type) === enforcement - ) + return links.some( + (link) => + link.role_id === roleId && + normalizeEnforcementKey(link.enforcement_type) === enforcement, + ); } export async function applyRoleEnforcements( - auth: Auth, - roleId: number, - pairs: EnforcementPair[], - existingLinks: readonly EnterpriseRoleEnforcementLink[] = [] + auth: Auth, + roleId: number, + pairs: EnforcementPair[], + existingLinks: readonly EnterpriseRoleEnforcementLink[] = [], ): Promise { - for (const { key, value } of pairs) { - const enforcement = normalizeEnforcementKey(key) - const lower = value.trim().toLowerCase() - - if (ENFORCEMENT_FALSE.has(lower)) { - if (!roleHasEnforcement(roleId, enforcement, existingLinks)) continue - const response = await auth.executeRestCommand( - roleEnforcementRemoveCommand({ role_id: roleId, enforcement }) - ) - assertCommandSucceeded( - response, - `role_enforcement_remove failed: ${enforcement}`, - ResultCodes.ROLE_ENFORCEMENT_FAILED - ) - continue - } - - if (ENFORCEMENT_TRUE.has(lower)) { - const exists = roleHasEnforcement(roleId, enforcement, existingLinks) - const response = await auth.executeRestCommand( - exists - ? roleEnforcementUpdateCommand({ role_id: roleId, enforcement }) - : roleEnforcementAddCommand({ role_id: roleId, enforcement }) - ) - assertCommandSucceeded( - response, - `role_enforcement_${exists ? 'update' : 'add'} failed: ${enforcement}`, - ResultCodes.ROLE_ENFORCEMENT_FAILED - ) - continue - } - - const exists = roleHasEnforcement(roleId, enforcement, existingLinks) - const response = await auth.executeRestCommand( - exists - ? roleEnforcementUpdateCommand({ role_id: roleId, enforcement, value }) - : roleEnforcementAddCommand({ role_id: roleId, enforcement, value }) - ) - assertCommandSucceeded( - response, - `role_enforcement_${exists ? 'update' : 'add'} failed: ${enforcement}`, - ResultCodes.ROLE_ENFORCEMENT_FAILED - ) + for (const { key, value } of pairs) { + const enforcement = normalizeEnforcementKey(key); + const lower = value.trim().toLowerCase(); + + if (ENFORCEMENT_FALSE.has(lower)) { + if (!roleHasEnforcement(roleId, enforcement, existingLinks)) continue; + const response = await auth.executeRestCommand( + roleEnforcementRemoveCommand({ role_id: roleId, enforcement }), + ); + assertCommandSucceeded( + response, + `role_enforcement_remove failed: ${enforcement}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED, + ); + continue; } + + if (ENFORCEMENT_TRUE.has(lower)) { + const exists = roleHasEnforcement(roleId, enforcement, existingLinks); + const response = await auth.executeRestCommand( + exists + ? roleEnforcementUpdateCommand({ role_id: roleId, enforcement }) + : roleEnforcementAddCommand({ role_id: roleId, enforcement }), + ); + assertCommandSucceeded( + response, + `role_enforcement_${exists ? "update" : "add"} failed: ${enforcement}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED, + ); + continue; + } + + const exists = roleHasEnforcement(roleId, enforcement, existingLinks); + const response = await auth.executeRestCommand( + exists + ? roleEnforcementUpdateCommand({ role_id: roleId, enforcement, value }) + : roleEnforcementAddCommand({ role_id: roleId, enforcement, value }), + ); + assertCommandSucceeded( + response, + `role_enforcement_${exists ? "update" : "add"} failed: ${enforcement}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED, + ); + } } export function buildRoleResultRows( - items: ReadonlyArray, - detailFor: (item: T) => string + items: ReadonlyArray, + detailFor: (item: T) => string, ): string[][] { - return items.map((item, index) => [ - String(index + 1), - item.status, - item.roleName, - String(item.roleId), - String(item.nodeId), - detailFor(item), - ]) + return items.map((item, index) => [ + String(index + 1), + item.status, + item.roleName, + String(item.roleId), + String(item.nodeId), + detailFor(item), + ]); } export function renderRoleResultTable( - headers: ReadonlyArray, - rows: string[][], - summary: string, - prefixLines: ReadonlyArray = [] + headers: ReadonlyArray, + rows: string[][], + summary: string, + prefixLines: ReadonlyArray = [], ): string { - const widths = headers.map((header, i) => Math.max(header.length, ...rows.map((row) => (row[i] || '').length))) - const pad = (cell: string, i: number) => cell + ' '.repeat(Math.max(0, widths[i] - cell.length)) - const formatRow = (cells: ReadonlyArray) => cells.map((cell, i) => pad(cell, i)).join(' ') - return [ - ...prefixLines, - formatRow(headers), - formatRow(widths.map((width) => '-'.repeat(width))), - ...rows.map(formatRow), - summary, - ].join('\n') + const widths = headers.map((header, i) => + Math.max(header.length, ...rows.map((row) => (row[i] || "").length)), + ); + const pad = (cell: string, i: number) => + cell + " ".repeat(Math.max(0, widths[i] - cell.length)); + const formatRow = (cells: ReadonlyArray) => + cells.map((cell, i) => pad(cell, i)).join(" "); + return [ + ...prefixLines, + formatRow(headers), + formatRow(widths.map((width) => "-".repeat(width))), + ...rows.map(formatRow), + summary, + ].join("\n"); } diff --git a/KeeperSdk/src/roles/updateRole.ts b/KeeperSdk/src/roles/updateRole.ts index d0fc5679..6b3918ef 100644 --- a/KeeperSdk/src/roles/updateRole.ts +++ b/KeeperSdk/src/roles/updateRole.ts @@ -1,173 +1,229 @@ import { - encryptObjectForStorage, - roleUpdateCommand, - type Auth, - type RoleEditRequest, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' -import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseRole } from '../teams/enterpriseData' + encryptObjectForStorage, + roleUpdateCommand, + type Auth, + type RoleEditRequest, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; import { - applyDecryptedNodeNames, - applyEnterpriseNameToRoot, - parentNeedsNameLookup, - resolveParentNode, -} from '../teams/teamUtils' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRole, +} from "../teams/enterpriseData"; import { - applyDecryptedRoleNames, - applyRoleEnforcements, - assertCommandSucceeded, - buildRoleResultRows, - normalizeIdentifiers, - parseEnforcements, - renderRoleResultTable, - resolveExistingRoles, - resolveToggle, - ROLE_TABLE_HEADERS, - type RoleToggleInput, -} from './roleUtils' + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from "../teams/teamUtils"; +import { + applyDecryptedRoleNames, + applyRoleEnforcements, + assertCommandSucceeded, + buildRoleResultRows, + normalizeIdentifiers, + parseEnforcements, + renderRoleResultTable, + resolveExistingRoles, + resolveToggle, + ROLE_TABLE_HEADERS, + type RoleToggleInput, +} from "./roleUtils"; const UPDATE_ROLE_BASE_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Nodes, - EnterpriseDataInclude.Roles, -] + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, +]; export enum UpdateRoleStatus { - Updated = 'updated', - Failed = 'failed', + Updated = "updated", + Failed = "failed", } export type UpdateRoleInput = { - roles: string[] - name?: string - parent?: string | number | null - newUser?: RoleToggleInput - visibleBelow?: RoleToggleInput - enforcements?: string[] -} + roles: string[]; + name?: string; + parent?: string | number | null; + newUser?: RoleToggleInput; + visibleBelow?: RoleToggleInput; + enforcements?: string[]; +}; export type UpdateRoleItemResult = { - roleId: number - roleName: string - nodeId: number - status: UpdateRoleStatus - message?: string -} + roleId: number; + roleName: string; + nodeId: number; + status: UpdateRoleStatus; + message?: string; +}; export type UpdateRoleResult = { - success: boolean - items: UpdateRoleItemResult[] - updated: number - failed: number -} + success: boolean; + items: UpdateRoleItemResult[]; + updated: number; + failed: number; +}; export type FormattedUpdateRoleTable = { - headers: string[] - rows: string[][] - summary: string -} - -export async function updateRoles(auth: Auth, input: UpdateRoleInput): Promise { - const identifiers = normalizeIdentifiers(input.roles) - if (identifiers.length === 0) { - throw new KeeperSdkError('No roles to update.', ResultCodes.NO_ROLES_TO_UPDATE) - } - - const newName = input.name?.trim() || undefined - if (newName && identifiers.length > 1) { - throw new KeeperSdkError( - 'Cannot rename more than one role in a single update.', - ResultCodes.ROLE_RENAME_MULTI_NOT_ALLOWED - ) - } - - const newUserInherit = resolveToggle(input.newUser) - const visibleBelow = resolveToggle(input.visibleBelow) - const enforcements = parseEnforcements(input.enforcements ?? []) - const includes = [...UPDATE_ROLE_BASE_INCLUDES] - if (enforcements.length > 0) includes.push(EnterpriseDataInclude.RoleEnforcements) - - const enterpriseData = new EnterpriseDataManager(auth) - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(includes), - enterpriseData.getDisplayNames(), - ]) - - const nodes = response.nodes || [] - applyDecryptedNodeNames(nodes, displayNames.nodes) - applyEnterpriseNameToRoot(nodes, response.enterprise_name) - if (parentNeedsNameLookup(input.parent ?? null)) await enterpriseData.decryptNodeNames(nodes) - - const roles = response.roles || [] - applyDecryptedRoleNames(roles, displayNames.roles) - const resolvedRoles = resolveExistingRoles(roles, identifiers) - - let overrideNodeId: number | null = null - if (input.parent !== undefined && input.parent !== null && input.parent !== '') { - overrideNodeId = resolveParentNode(nodes, input.parent).node_id + headers: string[]; + rows: string[][]; + summary: string; +}; + +export async function updateRoles( + auth: Auth, + input: UpdateRoleInput, +): Promise { + const identifiers = normalizeIdentifiers(input.roles); + if (identifiers.length === 0) { + throw new KeeperSdkError( + "No roles to update.", + ResultCodes.NO_ROLES_TO_UPDATE, + ); + } + + const newName = input.name?.trim() || undefined; + if (newName && identifiers.length > 1) { + throw new KeeperSdkError( + "Cannot rename more than one role in a single update.", + ResultCodes.ROLE_RENAME_MULTI_NOT_ALLOWED, + ); + } + + const newUserInherit = resolveToggle(input.newUser); + const visibleBelow = resolveToggle(input.visibleBelow); + const enforcements = parseEnforcements(input.enforcements ?? []); + const includes = [...UPDATE_ROLE_BASE_INCLUDES]; + if (enforcements.length > 0) + includes.push(EnterpriseDataInclude.RoleEnforcements); + + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(includes), + enterpriseData.getDisplayNames(), + ]); + + const nodes = response.nodes || []; + applyDecryptedNodeNames(nodes, displayNames.nodes); + applyEnterpriseNameToRoot(nodes, response.enterprise_name); + if (parentNeedsNameLookup(input.parent ?? null)) + await enterpriseData.decryptNodeNames(nodes); + + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + const resolvedRoles = resolveExistingRoles(roles, identifiers); + + let overrideNodeId: number | null = null; + if ( + input.parent !== undefined && + input.parent !== null && + input.parent !== "" + ) { + overrideNodeId = resolveParentNode(nodes, input.parent).node_id; + } + + const needsTreeKey = + Boolean(newName) || resolvedRoles.some((role) => !role.encrypted_data); + const treeKey = needsTreeKey ? await enterpriseData.getTreeKey() : null; + + const items: UpdateRoleItemResult[] = []; + for (const role of resolvedRoles) { + const targetNodeId = overrideNodeId ?? role.node_id ?? 0; + const currentName = (role.displayName || "").trim() || String(role.role_id); + + try { + const payload: RoleEditRequest = { + role_id: role.role_id, + node_id: targetNodeId, + encrypted_data: await resolveEncryptedData( + role, + newName, + currentName, + treeKey, + ), + visible_below: visibleBelow ?? role.visible_below ?? false, + new_user_inherit: newUserInherit ?? role.new_user_inherit ?? false, + }; + await sendRoleUpdate(auth, payload); + await applyRoleEnforcements( + auth, + role.role_id, + enforcements, + response.role_enforcements || [], + ); + items.push({ + roleId: role.role_id, + roleName: newName || currentName, + nodeId: targetNodeId, + status: UpdateRoleStatus.Updated, + }); + } catch (err) { + items.push({ + roleId: role.role_id, + roleName: currentName, + nodeId: role.node_id ?? 0, + status: UpdateRoleStatus.Failed, + message: extractErrorMessage(err), + }); } + } - const needsTreeKey = Boolean(newName) || resolvedRoles.some((role) => !role.encrypted_data) - const treeKey = needsTreeKey ? await enterpriseData.getTreeKey() : null - - const items: UpdateRoleItemResult[] = [] - for (const role of resolvedRoles) { - const targetNodeId = overrideNodeId ?? role.node_id ?? 0 - const currentName = (role.displayName || '').trim() || String(role.role_id) - - try { - const payload: RoleEditRequest = { - role_id: role.role_id, - node_id: targetNodeId, - encrypted_data: await resolveEncryptedData(role, newName, currentName, treeKey), - visible_below: visibleBelow ?? role.visible_below ?? false, - new_user_inherit: newUserInherit ?? role.new_user_inherit ?? false, - } - await sendRoleUpdate(auth, payload) - await applyRoleEnforcements(auth, role.role_id, enforcements, response.role_enforcements || []) - items.push({ roleId: role.role_id, roleName: newName || currentName, nodeId: targetNodeId, status: UpdateRoleStatus.Updated }) - } catch (err) { - items.push({ roleId: role.role_id, roleName: currentName, nodeId: role.node_id ?? 0, status: UpdateRoleStatus.Failed, message: extractErrorMessage(err) }) - } - } - - return finalizeResult(items) + return finalizeResult(items); } -export function formatUpdateRoleResult(result: UpdateRoleResult): FormattedUpdateRoleTable { - return { - headers: [...ROLE_TABLE_HEADERS], - rows: buildRoleResultRows(result.items, (item) => item.message || ''), - summary: `Updated: ${result.updated} Failed: ${result.failed}`, - } +export function formatUpdateRoleResult( + result: UpdateRoleResult, +): FormattedUpdateRoleTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows(result.items, (item) => item.message || ""), + summary: `Updated: ${result.updated} Failed: ${result.failed}`, + }; } -export function renderUpdateRoleAsciiTable(table: FormattedUpdateRoleTable): string { - return renderRoleResultTable(table.headers, table.rows, table.summary) +export function renderUpdateRoleAsciiTable( + table: FormattedUpdateRoleTable, +): string { + return renderRoleResultTable(table.headers, table.rows, table.summary); } async function resolveEncryptedData( - role: EnterpriseRole, - newName: string | undefined, - currentName: string, - treeKey: Uint8Array | null + role: EnterpriseRole, + newName: string | undefined, + currentName: string, + treeKey: Uint8Array | null, ): Promise { - if (!newName && role.encrypted_data) return role.encrypted_data - if (!treeKey) { - throw new KeeperSdkError( - `Enterprise tree key is unavailable; cannot ${newName ? 'rename role' : 'preserve role name'}.`, - ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE - ) - } - return encryptObjectForStorage({ displayname: newName || currentName }, treeKey) + if (!newName && role.encrypted_data) return role.encrypted_data; + if (!treeKey) { + throw new KeeperSdkError( + `Enterprise tree key is unavailable; cannot ${newName ? "rename role" : "preserve role name"}.`, + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + return encryptObjectForStorage( + { displayname: newName || currentName }, + treeKey, + ); } -async function sendRoleUpdate(auth: Auth, payload: RoleEditRequest): Promise { - const response = await auth.executeRestCommand(roleUpdateCommand(payload)) - assertCommandSucceeded(response, `role_update failed for role_id=${payload.role_id}`, ResultCodes.ROLE_UPDATE_FAILED) +async function sendRoleUpdate( + auth: Auth, + payload: RoleEditRequest, +): Promise { + const response = await auth.executeRestCommand(roleUpdateCommand(payload)); + assertCommandSucceeded( + response, + `role_update failed for role_id=${payload.role_id}`, + ResultCodes.ROLE_UPDATE_FAILED, + ); } function finalizeResult(items: UpdateRoleItemResult[]): UpdateRoleResult { - const updated = items.filter((item) => item.status === UpdateRoleStatus.Updated).length - const failed = items.filter((item) => item.status === UpdateRoleStatus.Failed).length - return { success: failed === 0 && updated > 0, items, updated, failed } + const updated = items.filter( + (item) => item.status === UpdateRoleStatus.Updated, + ).length; + const failed = items.filter( + (item) => item.status === UpdateRoleStatus.Failed, + ).length; + return { success: failed === 0 && updated > 0, items, updated, failed }; } diff --git a/KeeperSdk/src/roles/viewRole.ts b/KeeperSdk/src/roles/viewRole.ts index e0aa336d..8edef129 100644 --- a/KeeperSdk/src/roles/viewRole.ts +++ b/KeeperSdk/src/roles/viewRole.ts @@ -1,448 +1,520 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' +import type { Auth } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseDisplayNames, - type EnterpriseNode, - type EnterpriseRole, - type EnterpriseRoleEnforcementLink, - type EnterpriseRoleManagedNodeLink, - type EnterpriseRolePrivilegeLink, - type EnterpriseRoleTeamLink, - type EnterpriseRoleUserLink, - type EnterpriseTeamRecord, - type EnterpriseUser, -} from '../teams/enterpriseData' - -const NODE_PATH_SEPARATOR = '\\' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseDisplayNames, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRoleEnforcementLink, + type EnterpriseRoleManagedNodeLink, + type EnterpriseRolePrivilegeLink, + type EnterpriseRoleTeamLink, + type EnterpriseRoleUserLink, + type EnterpriseTeamRecord, + type EnterpriseUser, +} from "../teams/enterpriseData"; + +const NODE_PATH_SEPARATOR = "\\"; const VIEW_ROLE_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Roles, - EnterpriseDataInclude.Nodes, - EnterpriseDataInclude.Teams, - EnterpriseDataInclude.Users, - EnterpriseDataInclude.RoleUsers, - EnterpriseDataInclude.RoleTeams, - EnterpriseDataInclude.RolePrivileges, - EnterpriseDataInclude.ManagedNodes, - EnterpriseDataInclude.RoleEnforcements, -] + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.RoleTeams, + EnterpriseDataInclude.RolePrivileges, + EnterpriseDataInclude.ManagedNodes, + EnterpriseDataInclude.RoleEnforcements, +]; export type RoleTeamInfo = { - team_uid: string - team_name: string -} + team_uid: string; + team_name: string; +}; export type RoleUserInfo = { - enterprise_user_id: number - username: string -} + enterprise_user_id: number; + username: string; +}; export type RoleManagedNodeInfo = { - node_id: number - node_name: string - cascade: boolean - privileges: string[] -} + node_id: number; + node_name: string; + cascade: boolean; + privileges: string[]; +}; export type RoleEnforcementInfo = { - name: string - value: string -} + name: string; + value: string; +}; export type RoleView = { - role_id: number - role_name: string - node_id: number - node_name: string - default_role: boolean - visible_below: boolean - role_teams?: RoleTeamInfo[] - role_users?: RoleUserInfo[] - managed_nodes?: RoleManagedNodeInfo[] - role_enforcements: RoleEnforcementInfo[] -} + role_id: number; + role_name: string; + node_id: number; + node_name: string; + default_role: boolean; + visible_below: boolean; + role_teams?: RoleTeamInfo[]; + role_users?: RoleUserInfo[]; + managed_nodes?: RoleManagedNodeInfo[]; + role_enforcements: RoleEnforcementInfo[]; +}; export type FormatRoleViewOptions = { - verbose?: boolean -} + verbose?: boolean; +}; export type RoleViewTableRow = { - label: string - value: string | string[] - id?: number | number[] -} + label: string; + value: string | string[]; + id?: number | number[]; +}; export type FormattedRoleViewTable = { - mainRows: RoleViewTableRow[] - enforcementRows: RoleEnforcementInfo[] - managedNodeTable: FormattedManagedNodePrivilegeTable | null - hasIdColumn: boolean -} + mainRows: RoleViewTableRow[]; + enforcementRows: RoleEnforcementInfo[]; + managedNodeTable: FormattedManagedNodePrivilegeTable | null; + hasIdColumn: boolean; +}; export type FormattedManagedNodePrivilegeTable = { - nodeHeaders: string[] - privilegeRows: Array<{ privilege: string; granted: boolean[] }> - cascadeRow: boolean[] -} - -export async function viewRole(auth: Auth, identifier: string): Promise { - const enterpriseData = new EnterpriseDataManager(auth) - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(VIEW_ROLE_INCLUDES), - enterpriseData.getDisplayNames(), - ]) - - const role = resolveRole(response.roles || [], displayNames.roles, identifier) - const nodePath = resolveNodePath(response.nodes || [], displayNames, role.node_id ?? 0) - - const roleTeams = buildRoleTeams(response.role_teams || [], response.teams || [], role.role_id) - const roleUsers = buildRoleUsers(response.role_users || [], response.users || [], role.role_id) - const managedNodes = buildManagedNodes( - response.managed_nodes || [], - response.role_privileges || [], - response.nodes || [], - displayNames, - role.role_id, - response.enterprise_name - ) - const enforcements = buildEnforcements(response.role_enforcements || [], role.role_id) - - const view: RoleView = { - role_id: role.role_id, - role_name: (role.displayName || '').trim() || String(role.role_id), - node_id: role.node_id ?? 0, - node_name: nodePath, - default_role: role.new_user_inherit ?? false, - visible_below: role.visible_below ?? false, - role_enforcements: enforcements, - } - if (roleTeams.length > 0) view.role_teams = roleTeams - if (roleUsers.length > 0) view.role_users = roleUsers - if (managedNodes.length > 0) view.managed_nodes = managedNodes - - return view + nodeHeaders: string[]; + privilegeRows: Array<{ privilege: string; granted: boolean[] }>; + cascadeRow: boolean[]; +}; + +export async function viewRole( + auth: Auth, + identifier: string, +): Promise { + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(VIEW_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const role = resolveRole( + response.roles || [], + displayNames.roles, + identifier, + ); + const nodePath = resolveNodePath( + response.nodes || [], + displayNames, + role.node_id ?? 0, + ); + + const roleTeams = buildRoleTeams( + response.role_teams || [], + response.teams || [], + role.role_id, + ); + const roleUsers = buildRoleUsers( + response.role_users || [], + response.users || [], + role.role_id, + ); + const managedNodes = buildManagedNodes( + response.managed_nodes || [], + response.role_privileges || [], + response.nodes || [], + displayNames, + role.role_id, + response.enterprise_name, + ); + const enforcements = buildEnforcements( + response.role_enforcements || [], + role.role_id, + ); + + const view: RoleView = { + role_id: role.role_id, + role_name: (role.displayName || "").trim() || String(role.role_id), + node_id: role.node_id ?? 0, + node_name: nodePath, + default_role: role.new_user_inherit ?? false, + visible_below: role.visible_below ?? false, + role_enforcements: enforcements, + }; + if (roleTeams.length > 0) view.role_teams = roleTeams; + if (roleUsers.length > 0) view.role_users = roleUsers; + if (managedNodes.length > 0) view.managed_nodes = managedNodes; + + return view; } export function formatRoleView( - view: RoleView, - options: FormatRoleViewOptions = {} + view: RoleView, + options: FormatRoleViewOptions = {}, ): FormattedRoleViewTable { - const verbose = options.verbose === true - - const mainRows: RoleViewTableRow[] = [ - { label: 'Role ID', value: String(view.role_id) }, - { label: 'Role Name', value: view.role_name }, - { - label: 'Node Name', - value: view.node_name, - id: verbose ? view.node_id : undefined, - }, - { label: 'Default Role', value: boolText(view.default_role) }, - { label: 'Visible Below', value: boolText(view.visible_below) }, - ] - - if (view.role_users && view.role_users.length > 0) { - mainRows.push({ - label: 'User(s)', - value: view.role_users.map((u) => u.username), - id: verbose ? view.role_users.map((u) => u.enterprise_user_id) : undefined, - }) - } - - if (view.role_teams && view.role_teams.length > 0) { - mainRows.push({ - label: 'Team(s)', - value: view.role_teams.map((t) => t.team_name), - }) - } - - const managedNodeTable = buildFormattedManagedNodeTable(view.managed_nodes ?? []) - - return { - mainRows, - enforcementRows: view.role_enforcements, - managedNodeTable, - hasIdColumn: verbose, - } + const verbose = options.verbose === true; + + const mainRows: RoleViewTableRow[] = [ + { label: "Role ID", value: String(view.role_id) }, + { label: "Role Name", value: view.role_name }, + { + label: "Node Name", + value: view.node_name, + id: verbose ? view.node_id : undefined, + }, + { label: "Default Role", value: boolText(view.default_role) }, + { label: "Visible Below", value: boolText(view.visible_below) }, + ]; + + if (view.role_users && view.role_users.length > 0) { + mainRows.push({ + label: "User(s)", + value: view.role_users.map((u) => u.username), + id: verbose + ? view.role_users.map((u) => u.enterprise_user_id) + : undefined, + }); + } + + if (view.role_teams && view.role_teams.length > 0) { + mainRows.push({ + label: "Team(s)", + value: view.role_teams.map((t) => t.team_name), + }); + } + + const managedNodeTable = buildFormattedManagedNodeTable( + view.managed_nodes ?? [], + ); + + return { + mainRows, + enforcementRows: view.role_enforcements, + managedNodeTable, + hasIdColumn: verbose, + }; } export function roleViewTable(table: FormattedRoleViewTable): string { - const sections: string[] = [] + const sections: string[] = []; - sections.push(renderMainSection(table)) + sections.push(renderMainSection(table)); - sections.push(renderEnforcementsSection(table.enforcementRows)) + sections.push(renderEnforcementsSection(table.enforcementRows)); - if (table.managedNodeTable) { - sections.push(renderManagedNodesSection(table.managedNodeTable)) - } + if (table.managedNodeTable) { + sections.push(renderManagedNodesSection(table.managedNodeTable)); + } - return sections.join('\n\n') + return sections.join("\n\n"); } function resolveRole( - roles: EnterpriseRole[], - decryptedNames: Map, - identifier: string + roles: EnterpriseRole[], + decryptedNames: Map, + identifier: string, ): EnterpriseRole { - const trimmed = (identifier ?? '').trim() - if (!trimmed) { - throw new KeeperSdkError('Role name or ID is required.', ResultCodes.ROLE_REQUIRED) - } - - for (const role of roles) { - const display = decryptedNames.get(role.role_id) - if (display) role.displayName = display - } - - const numeric = Number(trimmed) - if (Number.isFinite(numeric) && Number.isInteger(numeric)) { - const byId = roles.find((r) => r.role_id === numeric) - if (byId) return byId - } - - const lowered = trimmed.toLowerCase() - const nameMatches = roles.filter( - (r) => (r.displayName || '').trim().toLowerCase() === lowered - ) - if (nameMatches.length === 1) return nameMatches[0] - if (nameMatches.length > 1) { - throw new KeeperSdkError( - `Multiple roles match name "${trimmed}". Specify the role ID instead.`, - ResultCodes.MULTIPLE_ROLE_MATCHES - ) - } - throw new KeeperSdkError(`Role "${trimmed}" does not exist.`, ResultCodes.ROLE_NOT_FOUND) + const trimmed = (identifier ?? "").trim(); + if (!trimmed) { + throw new KeeperSdkError( + "Role name or ID is required.", + ResultCodes.ROLE_REQUIRED, + ); + } + + for (const role of roles) { + const display = decryptedNames.get(role.role_id); + if (display) role.displayName = display; + } + + const numeric = Number(trimmed); + if (Number.isFinite(numeric) && Number.isInteger(numeric)) { + const byId = roles.find((r) => r.role_id === numeric); + if (byId) return byId; + } + + const lowered = trimmed.toLowerCase(); + const nameMatches = roles.filter( + (r) => (r.displayName || "").trim().toLowerCase() === lowered, + ); + if (nameMatches.length === 1) return nameMatches[0]; + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Multiple roles match name "${trimmed}". Specify the role ID instead.`, + ResultCodes.MULTIPLE_ROLE_MATCHES, + ); + } + throw new KeeperSdkError( + `Role "${trimmed}" does not exist.`, + ResultCodes.ROLE_NOT_FOUND, + ); } function resolveNodePath( - nodes: EnterpriseNode[], - displayNames: EnterpriseDisplayNames, - nodeId: number + nodes: EnterpriseNode[], + displayNames: EnterpriseDisplayNames, + nodeId: number, ): string { - for (const node of nodes) { - const display = displayNames.nodes.get(node.node_id) - if (display) node.displayName = display - } - return EnterpriseDataManager.getNodePath(nodes, nodeId, { - omitRoot: false, - separator: NODE_PATH_SEPARATOR, - }) + for (const node of nodes) { + const display = displayNames.nodes.get(node.node_id); + if (display) node.displayName = display; + } + return EnterpriseDataManager.getNodePath(nodes, nodeId, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }); } function buildRoleTeams( - roleTeamLinks: EnterpriseRoleTeamLink[], - teams: EnterpriseTeamRecord[], - roleId: number + roleTeamLinks: EnterpriseRoleTeamLink[], + teams: EnterpriseTeamRecord[], + roleId: number, ): RoleTeamInfo[] { - const teamByUid = new Map() - for (const team of teams) teamByUid.set(team.team_uid, team) - - const result: RoleTeamInfo[] = [] - for (const link of roleTeamLinks) { - if (link.role_id !== roleId) continue - const team = teamByUid.get(link.team_uid) - if (team) result.push({ team_uid: team.team_uid, team_name: (team.name || team.team_uid).trim() }) - } - result.sort((a, b) => a.team_name.localeCompare(b.team_name, undefined, { sensitivity: 'base' })) - return result + const teamByUid = new Map(); + for (const team of teams) teamByUid.set(team.team_uid, team); + + const result: RoleTeamInfo[] = []; + for (const link of roleTeamLinks) { + if (link.role_id !== roleId) continue; + const team = teamByUid.get(link.team_uid); + if (team) + result.push({ + team_uid: team.team_uid, + team_name: (team.name || team.team_uid).trim(), + }); + } + result.sort((a, b) => + a.team_name.localeCompare(b.team_name, undefined, { sensitivity: "base" }), + ); + return result; } function buildRoleUsers( - roleUserLinks: EnterpriseRoleUserLink[], - users: EnterpriseUser[], - roleId: number + roleUserLinks: EnterpriseRoleUserLink[], + users: EnterpriseUser[], + roleId: number, ): RoleUserInfo[] { - const userById = new Map() - for (const user of users) userById.set(user.enterprise_user_id, user) - - const result: RoleUserInfo[] = [] - for (const link of roleUserLinks) { - if (link.role_id !== roleId) continue - const user = userById.get(link.enterprise_user_id) - if (user?.username) { - result.push({ enterprise_user_id: link.enterprise_user_id, username: user.username }) - } + const userById = new Map(); + for (const user of users) userById.set(user.enterprise_user_id, user); + + const result: RoleUserInfo[] = []; + for (const link of roleUserLinks) { + if (link.role_id !== roleId) continue; + const user = userById.get(link.enterprise_user_id); + if (user?.username) { + result.push({ + enterprise_user_id: link.enterprise_user_id, + username: user.username, + }); } - result.sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' })) - return result + } + result.sort((a, b) => + a.username.localeCompare(b.username, undefined, { sensitivity: "base" }), + ); + return result; } function buildManagedNodes( - managedNodeLinks: EnterpriseRoleManagedNodeLink[], - privileges: EnterpriseRolePrivilegeLink[], - nodes: EnterpriseNode[], - displayNames: EnterpriseDisplayNames, - roleId: number, - enterpriseName?: string + managedNodeLinks: EnterpriseRoleManagedNodeLink[], + privileges: EnterpriseRolePrivilegeLink[], + nodes: EnterpriseNode[], + displayNames: EnterpriseDisplayNames, + roleId: number, + enterpriseName?: string, ): RoleManagedNodeInfo[] { - const nodeById = new Map() - for (const node of nodes) { - const display = displayNames.nodes.get(node.node_id) - if (display) node.displayName = display - nodeById.set(node.node_id, node) - } - - const privilegesByNode = new Map() - for (const p of privileges) { - if (p.role_id !== roleId || !p.privilege_type) continue - const list = privilegesByNode.get(p.managed_node_id) - if (list) list.push(p.privilege_type) - else privilegesByNode.set(p.managed_node_id, [p.privilege_type]) - } - - const result: RoleManagedNodeInfo[] = [] - for (const link of managedNodeLinks) { - if (link.role_id !== roleId) continue - const node = nodeById.get(link.managed_node_id) - let nodeName = (node?.displayName || '').trim() - if (!nodeName && node && !node.parent_id && enterpriseName) nodeName = enterpriseName - if (!nodeName) nodeName = String(link.managed_node_id) - const nodePrivileges = (privilegesByNode.get(link.managed_node_id) ?? []).sort() - result.push({ - node_id: link.managed_node_id, - node_name: nodeName, - cascade: link.cascade_node_management, - privileges: nodePrivileges, - }) - } - result.sort((a, b) => a.node_name.localeCompare(b.node_name, undefined, { sensitivity: 'base' })) - return result + const nodeById = new Map(); + for (const node of nodes) { + const display = displayNames.nodes.get(node.node_id); + if (display) node.displayName = display; + nodeById.set(node.node_id, node); + } + + const privilegesByNode = new Map(); + for (const p of privileges) { + if (p.role_id !== roleId || !p.privilege_type) continue; + const list = privilegesByNode.get(p.managed_node_id); + if (list) list.push(p.privilege_type); + else privilegesByNode.set(p.managed_node_id, [p.privilege_type]); + } + + const result: RoleManagedNodeInfo[] = []; + for (const link of managedNodeLinks) { + if (link.role_id !== roleId) continue; + const node = nodeById.get(link.managed_node_id); + let nodeName = (node?.displayName || "").trim(); + if (!nodeName && node && !node.parent_id && enterpriseName) + nodeName = enterpriseName; + if (!nodeName) nodeName = String(link.managed_node_id); + const nodePrivileges = ( + privilegesByNode.get(link.managed_node_id) ?? [] + ).sort(); + result.push({ + node_id: link.managed_node_id, + node_name: nodeName, + cascade: link.cascade_node_management, + privileges: nodePrivileges, + }); + } + result.sort((a, b) => + a.node_name.localeCompare(b.node_name, undefined, { sensitivity: "base" }), + ); + return result; } function buildEnforcements( - links: EnterpriseRoleEnforcementLink[], - roleId: number + links: EnterpriseRoleEnforcementLink[], + roleId: number, ): RoleEnforcementInfo[] { - const result: RoleEnforcementInfo[] = [] - for (const link of links) { - if (link.role_id !== roleId || !link.value) continue - result.push({ name: link.enforcement_type, value: link.value }) - } - result.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) - return result + const result: RoleEnforcementInfo[] = []; + for (const link of links) { + if (link.role_id !== roleId || !link.value) continue; + result.push({ name: link.enforcement_type, value: link.value }); + } + result.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + return result; } function buildFormattedManagedNodeTable( - managedNodes: RoleManagedNodeInfo[] + managedNodes: RoleManagedNodeInfo[], ): FormattedManagedNodePrivilegeTable | null { - if (managedNodes.length === 0) return null - - const allPrivileges = new Set() - for (const mn of managedNodes) { - for (const p of mn.privileges) allPrivileges.add(p) - } - - const privileges = [...allPrivileges].sort() - const privilegeRows = privileges.map((privilege) => ({ - privilege, - granted: managedNodes.map((mn) => mn.privileges.includes(privilege)), - })) - - return { - nodeHeaders: managedNodes.map((mn) => mn.node_name), - privilegeRows, - cascadeRow: managedNodes.map((mn) => mn.cascade), - } + if (managedNodes.length === 0) return null; + + const allPrivileges = new Set(); + for (const mn of managedNodes) { + for (const p of mn.privileges) allPrivileges.add(p); + } + + const privileges = [...allPrivileges].sort(); + const privilegeRows = privileges.map((privilege) => ({ + privilege, + granted: managedNodes.map((mn) => mn.privileges.includes(privilege)), + })); + + return { + nodeHeaders: managedNodes.map((mn) => mn.node_name), + privilegeRows, + cascadeRow: managedNodes.map((mn) => mn.cascade), + }; } function renderMainSection(table: FormattedRoleViewTable): string { - const { mainRows, hasIdColumn } = table - - const labelWidth = mainRows.reduce((max, row) => Math.max(max, row.label.length), 0) - const expandedRows = mainRows.map((row) => ({ - label: row.label, - valueLines: asLines(row.value), - idLines: hasIdColumn ? asIdLines(row.id) : [], - })) - - const valueWidth = expandedRows.reduce( - (max, row) => Math.max(max, ...row.valueLines.map((l) => l.length)), - 0 - ) - const idWidth = hasIdColumn - ? expandedRows.reduce((max, row) => Math.max(max, ...row.idLines.map((l) => l.length)), 0) - : 0 - - const columnWidths = hasIdColumn ? [labelWidth, valueWidth, idWidth] : [labelWidth, valueWidth] - const lines: string[] = [] - for (const row of expandedRows) { - const lineCount = Math.max(row.valueLines.length, hasIdColumn ? row.idLines.length : 0, 1) - for (let li = 0; li < lineCount; li++) { - const cells = [ - li === 0 ? row.label : '', - row.valueLines[li] ?? '', - ...(hasIdColumn ? [row.idLines[li] ?? ''] : []), - ] - lines.push(formatAsciiRow(cells, columnWidths)) - } + const { mainRows, hasIdColumn } = table; + + const labelWidth = mainRows.reduce( + (max, row) => Math.max(max, row.label.length), + 0, + ); + const expandedRows = mainRows.map((row) => ({ + label: row.label, + valueLines: asLines(row.value), + idLines: hasIdColumn ? asIdLines(row.id) : [], + })); + + const valueWidth = expandedRows.reduce( + (max, row) => Math.max(max, ...row.valueLines.map((l) => l.length)), + 0, + ); + const idWidth = hasIdColumn + ? expandedRows.reduce( + (max, row) => Math.max(max, ...row.idLines.map((l) => l.length)), + 0, + ) + : 0; + + const columnWidths = hasIdColumn + ? [labelWidth, valueWidth, idWidth] + : [labelWidth, valueWidth]; + const lines: string[] = []; + for (const row of expandedRows) { + const lineCount = Math.max( + row.valueLines.length, + hasIdColumn ? row.idLines.length : 0, + 1, + ); + for (let li = 0; li < lineCount; li++) { + const cells = [ + li === 0 ? row.label : "", + row.valueLines[li] ?? "", + ...(hasIdColumn ? [row.idLines[li] ?? ""] : []), + ]; + lines.push(formatAsciiRow(cells, columnWidths)); } - return lines.join('\n') + } + return lines.join("\n"); } function renderEnforcementsSection(rows: RoleEnforcementInfo[]): string { - const title = 'Role Enforcements' - if (rows.length === 0) return `${title}\n (none)` - - const nameWidth = Math.max('Name'.length, ...rows.map((r) => r.name.length)) - const valWidth = Math.max('Value'.length, ...rows.map((r) => r.value.length)) - const columnWidths = [nameWidth, valWidth] - - const lines: string[] = [ - title, - formatAsciiRow(['Name', 'Value'], columnWidths), - formatAsciiRow(['-'.repeat(nameWidth), '-'.repeat(valWidth)], columnWidths), - ...rows.map((r) => formatAsciiRow([r.name, r.value], columnWidths)), - ] - return lines.join('\n') + const title = "Role Enforcements"; + if (rows.length === 0) return `${title}\n (none)`; + + const nameWidth = Math.max("Name".length, ...rows.map((r) => r.name.length)); + const valWidth = Math.max("Value".length, ...rows.map((r) => r.value.length)); + const columnWidths = [nameWidth, valWidth]; + + const lines: string[] = [ + title, + formatAsciiRow(["Name", "Value"], columnWidths), + formatAsciiRow(["-".repeat(nameWidth), "-".repeat(valWidth)], columnWidths), + ...rows.map((r) => formatAsciiRow([r.name, r.value], columnWidths)), + ]; + return lines.join("\n"); } -function renderManagedNodesSection(table: FormattedManagedNodePrivilegeTable): string { - const title = 'Managed Node Privileges' - const colHeaders = ['Privilege', ...table.nodeHeaders] - - const colWidths = colHeaders.map((h) => h.length) - for (const row of table.privilegeRows) { - colWidths[0] = Math.max(colWidths[0], row.privilege.length) - row.granted.forEach((_, i) => { - colWidths[i + 1] = Math.max(colWidths[i + 1], 1) - }) - } - colWidths[0] = Math.max(colWidths[0], 'Cascade Node Permissions'.length) - - const formatRow = (cells: string[]) => formatAsciiRow(cells, colWidths) - - const rule = formatRow(colWidths.map((w) => '-'.repeat(w))) - const lines: string[] = [ - title, - formatRow(colHeaders), - rule, - ...table.privilegeRows.map((row) => - formatRow([row.privilege, ...row.granted.map((g) => (g ? 'X' : ''))]) - ), - rule, - formatRow(['Cascade Node Permissions', ...table.cascadeRow.map((c) => (c ? 'X' : ''))]), - ] - return lines.join('\n') +function renderManagedNodesSection( + table: FormattedManagedNodePrivilegeTable, +): string { + const title = "Managed Node Privileges"; + const colHeaders = ["Privilege", ...table.nodeHeaders]; + + const colWidths = colHeaders.map((h) => h.length); + for (const row of table.privilegeRows) { + colWidths[0] = Math.max(colWidths[0], row.privilege.length); + row.granted.forEach((_, i) => { + colWidths[i + 1] = Math.max(colWidths[i + 1], 1); + }); + } + colWidths[0] = Math.max(colWidths[0], "Cascade Node Permissions".length); + + const formatRow = (cells: string[]) => formatAsciiRow(cells, colWidths); + + const rule = formatRow(colWidths.map((w) => "-".repeat(w))); + const lines: string[] = [ + title, + formatRow(colHeaders), + rule, + ...table.privilegeRows.map((row) => + formatRow([row.privilege, ...row.granted.map((g) => (g ? "X" : ""))]), + ), + rule, + formatRow([ + "Cascade Node Permissions", + ...table.cascadeRow.map((c) => (c ? "X" : "")), + ]), + ]; + return lines.join("\n"); } function boolText(value: boolean): string { - return value ? 'True' : 'False' + return value ? "True" : "False"; } function asLines(value: string | string[]): string[] { - if (Array.isArray(value)) return value.length > 0 ? value : [''] - return [value] + if (Array.isArray(value)) return value.length > 0 ? value : [""]; + return [value]; } function asIdLines(id: number | number[] | undefined): string[] { - if (id == null) return [] - if (Array.isArray(id)) return id.length > 0 ? id.map(String) : [''] - return [String(id)] + if (id == null) return []; + if (Array.isArray(id)) return id.length > 0 ? id.map(String) : [""]; + return [String(id)]; } function formatAsciiRow(cells: string[], widths: number[]): string { - return cells.map((cell, index) => cell + ' '.repeat(Math.max(0, widths[index] - cell.length))).join(' ') -} \ No newline at end of file + return cells + .map( + (cell, index) => + cell + " ".repeat(Math.max(0, widths[index] - cell.length)), + ) + .join(" "); +} diff --git a/KeeperSdk/src/sharedFolders/SharedFolderManager.ts b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts index bac940f3..b4df386f 100644 --- a/KeeperSdk/src/sharedFolders/SharedFolderManager.ts +++ b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts @@ -1,57 +1,88 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes } from '../utils' +import type { Auth } from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes } from "../utils"; import { - formatSharedFoldersTable, - listSharedFolders, - renderSharedFoldersAsciiTable, -} from './listSharedFolders' + formatSharedFoldersTable, + listSharedFolders, + renderSharedFoldersAsciiTable, +} from "./listSharedFolders"; import type { - FormattedSharedFoldersTable, - ListSharedFolderRow, - ListSharedFoldersOptions, -} from './listSharedFolders' -import { shareFolder } from './shareFolder' -import type { ShareFolderInput, ShareFolderResult } from './shareFolder' + FormattedSharedFoldersTable, + ListSharedFolderRow, + ListSharedFoldersOptions, +} from "./listSharedFolders"; +import { shareFolder } from "./shareFolder"; +import type { ShareFolderInput, ShareFolderResult } from "./shareFolder"; +import { downloadMembership } from "./downloadMembership"; +import type { + DownloadMembershipOptions, + MembershipData, +} from "./downloadMembership"; +import { applyMembership } from "./applyMembership"; +import type { + ApplyMembershipInput, + ApplyMembershipOptions, + ApplyMembershipResult, +} from "./applyMembership"; -export type AuthProvider = () => Auth +export type AuthProvider = () => Auth; export class SharedFolderManager { - private readonly storage: InMemoryStorage - private readonly authProvider: AuthProvider + private readonly storage: InMemoryStorage; + private readonly authProvider: AuthProvider; - constructor(storage: InMemoryStorage, authProvider: AuthProvider) { - this.storage = storage - this.authProvider = authProvider - } + constructor(storage: InMemoryStorage, authProvider: AuthProvider) { + this.storage = storage; + this.authProvider = authProvider; + } - private requireAuth(): Auth { - const auth = this.authProvider() - if (!auth) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } - return auth + private requireAuth(): Auth { + const auth = this.authProvider(); + if (!auth) { + throw new KeeperSdkError( + "Not logged in. Call login() first.", + ResultCodes.NOT_LOGGED_IN, + ); } + return auth; + } - public listSharedFolders(options: ListSharedFoldersOptions = {}): ListSharedFolderRow[] { - return listSharedFolders(this.storage, options) - } + public listSharedFolders( + options: ListSharedFoldersOptions = {}, + ): ListSharedFolderRow[] { + return listSharedFolders(this.storage, options); + } - public formatSharedFoldersTable( - rows: ListSharedFolderRow[], - options: { verbose?: boolean; columnWidth?: number } = {} - ): FormattedSharedFoldersTable { - return formatSharedFoldersTable(rows, options) - } + public formatSharedFoldersTable( + rows: ListSharedFolderRow[], + options: { verbose?: boolean; columnWidth?: number } = {}, + ): FormattedSharedFoldersTable { + return formatSharedFoldersTable(rows, options); + } - public renderSharedFoldersAsciiTable( - table: FormattedSharedFoldersTable, - options: { minColWidth?: number } = {} - ): string { - return renderSharedFoldersAsciiTable(table, options) - } + public renderSharedFoldersAsciiTable( + table: FormattedSharedFoldersTable, + options: { minColWidth?: number } = {}, + ): string { + return renderSharedFoldersAsciiTable(table, options); + } - public async shareFolder(input: ShareFolderInput): Promise { - return shareFolder(this.requireAuth(), this.storage, input) - } + public async shareFolder( + input: ShareFolderInput, + ): Promise { + return shareFolder(this.requireAuth(), this.storage, input); + } + + public async downloadMembership( + options: DownloadMembershipOptions = {}, + ): Promise { + return downloadMembership(this.requireAuth(), this.storage, options); + } + + public async applyMembership( + data: ApplyMembershipInput, + options: ApplyMembershipOptions = {}, + ): Promise { + return applyMembership(this.requireAuth(), this.storage, data, options); + } } diff --git a/KeeperSdk/src/sharedFolders/applyMembership.ts b/KeeperSdk/src/sharedFolders/applyMembership.ts new file mode 100644 index 00000000..a67fc7dc --- /dev/null +++ b/KeeperSdk/src/sharedFolders/applyMembership.ts @@ -0,0 +1,693 @@ +import type { + Auth, + DSharedFolder, + DSharedFolderTeam, + DSharedFolderUser, + DTeam, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { + FolderKind, + VaultObjectKind, + sharedFolderName, +} from "../folders/folderHelpers"; +import { updateSharedFolderPermissions } from "../folders/updateFolder"; +import { + updateSharedFolderMembership, + type ShareFolderUserStatus, +} from "./shareFolder"; +import { + buildAccountUidEmailMap, + resolveUserEmail, +} from "./downloadMembership"; +import type { + MembershipData, + MembershipSharedFolder, + MembershipTeam, +} from "./downloadMembership"; +import { addUsersToTeams, removeUsersFromTeams } from "../users/teamUser"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, +} from "../teams/enterpriseData"; +import { resolveExistingTeams } from "../teams/teamUtils"; +import { extractErrorMessage, isValidEmail, KeeperSdkError } from "../utils"; + +export type ApplyMembershipInput = MembershipData | MembershipSharedFolder[]; + +export type ApplyMembershipOptions = { + /** + * When `true`, also updates permission flags that differ from the JSON + * and removes users/teams (and team members) that are not present in the + * JSON. When `false` (default, matches Commander), only missing grants + * are added; existing membership is left untouched even if permissions differ. + */ + fullSync?: boolean; +}; + +export type ApplyMembershipCounts = { + usersAdded: number; + usersUpdated: number; + usersRemoved: number; + teamsAdded: number; + teamsUpdated: number; + teamsRemoved: number; + teamMembersAdded: number; + teamMembersRemoved: number; +}; + +export type ApplyMembershipFolderResult = { + sharedFolderUid?: string; + path: string; + success: boolean; + message?: string; + counts: ApplyMembershipCounts; + results: ShareFolderUserStatus[]; +}; + +export type ApplyMembershipTeamMembershipResult = { + teamUid?: string; + name: string; + success: boolean; + message?: string; + added: number; + removed: number; +}; + +export type ApplyMembershipResult = { + success: boolean; + folders: ApplyMembershipFolderResult[]; + teamMembership: ApplyMembershipTeamMembershipResult[]; + totals: ApplyMembershipCounts; +}; + +function emptyCounts(): ApplyMembershipCounts { + return { + usersAdded: 0, + usersUpdated: 0, + usersRemoved: 0, + teamsAdded: 0, + teamsUpdated: 0, + teamsRemoved: 0, + teamMembersAdded: 0, + teamMembersRemoved: 0, + }; +} + +function addCounts( + totals: ApplyMembershipCounts, + counts: ApplyMembershipCounts, +): ApplyMembershipCounts { + return { + usersAdded: totals.usersAdded + counts.usersAdded, + usersUpdated: totals.usersUpdated + counts.usersUpdated, + usersRemoved: totals.usersRemoved + counts.usersRemoved, + teamsAdded: totals.teamsAdded + counts.teamsAdded, + teamsUpdated: totals.teamsUpdated + counts.teamsUpdated, + teamsRemoved: totals.teamsRemoved + counts.teamsRemoved, + teamMembersAdded: totals.teamMembersAdded + counts.teamMembersAdded, + teamMembersRemoved: totals.teamMembersRemoved + counts.teamMembersRemoved, + }; +} + +function normalizeMembershipData(data: ApplyMembershipInput): MembershipData { + if (Array.isArray(data)) return { shared_folders: data }; + return { shared_folders: data.shared_folders || [], teams: data.teams }; +} + +function resolveSharedFolder( + storage: InMemoryStorage, + entry: MembershipSharedFolder, +): DSharedFolder | undefined { + const uid = (entry.uid || "").trim(); + if (uid) { + const byUid = storage.getByUid(FolderKind.SharedFolder, uid); + if (byUid) return byUid; + } + + const path = (entry.path || "").trim(); + if (!path) return undefined; + const lowerPath = path.toLowerCase(); + const matches = storage + .getAll(FolderKind.SharedFolder) + .filter( + (sharedFolder) => + sharedFolderName(sharedFolder).toLowerCase() === lowerPath, + ); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + throw new KeeperSdkError( + `Shared folder name "${path}" is not unique. Use the shared folder UID instead.`, + "shared_folder_ambiguous", + ); + } + return undefined; +} + +function getExistingUsers( + storage: InMemoryStorage, + sharedFolderUid: string, + emailMap: Map, +): Map { + const existing = new Map(); + for (const sharedUser of storage.getAll( + VaultObjectKind.SharedFolderUser, + )) { + if (sharedUser.sharedFolderUid !== sharedFolderUid) continue; + const email = resolveUserEmail( + sharedUser.accountUid, + sharedUser.accountUsername, + emailMap, + ); + if (!email) continue; + existing.set(email.toLowerCase(), sharedUser); + } + return existing; +} + +function getExistingTeams( + storage: InMemoryStorage, + sharedFolderUid: string, +): Map { + const existing = new Map(); + for (const sharedTeam of storage.getAll( + VaultObjectKind.SharedFolderTeam, + )) { + if (sharedTeam.sharedFolderUid !== sharedFolderUid) continue; + existing.set(sharedTeam.teamUid, sharedTeam); + } + return existing; +} + +/** Resolves team names/uids to team uids, preserving a 1:1 identifier -> uid mapping. */ +async function resolveTeamIdentifierMap( + auth: Auth, + storage: InMemoryStorage, + identifiers: string[], +): Promise> { + const resolved = new Map(); + if (identifiers.length === 0) return resolved; + + const vaultTeams = storage.getAll(VaultObjectKind.Team); + const byUid = new Set(vaultTeams.map((team) => team.uid)); + const byLowerName = new Map(); + for (const team of vaultTeams) { + const name = (team.name || "").trim().toLowerCase(); + if (!name) continue; + const uids = byLowerName.get(name) || []; + uids.push(team.uid); + byLowerName.set(name, uids); + } + + const unresolved: string[] = []; + for (const rawIdentifier of identifiers) { + const identifier = rawIdentifier.trim(); + if (byUid.has(identifier)) { + resolved.set(rawIdentifier, identifier); + continue; + } + const nameMatches = byLowerName.get(identifier.toLowerCase()); + if (nameMatches && nameMatches.length === 1) { + resolved.set(rawIdentifier, nameMatches[0]); + continue; + } + if (nameMatches && nameMatches.length > 1) { + throw new KeeperSdkError( + `Team name "${rawIdentifier}" is not unique. Use the team UID instead.`, + "multiple_team_matches", + ); + } + unresolved.push(rawIdentifier); + } + + if (unresolved.length > 0) { + const enterpriseData = new EnterpriseDataManager(auth); + const data = await enterpriseData.getData([ + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + ]); + for (const rawIdentifier of unresolved) { + try { + const teams = resolveExistingTeams( + data.teams || [], + [rawIdentifier.trim()], + data.queued_teams || [], + ); + if (teams.length > 0) resolved.set(rawIdentifier, teams[0].team_uid); + } catch { + // Leave unresolved; caller reports the missing target per-permission. + } + } + } + + return resolved; +} + +function tallyCounts( + addUsers: { email: string }[], + updateUsers: { email: string }[], + removeUsers: string[], + addTeams: { teamUid: string }[], + updateTeams: { teamUid: string }[], + removeTeams: string[], + results: ShareFolderUserStatus[], +): ApplyMembershipCounts { + const addUserSet = new Set( + addUsers.map((grant) => grant.email.toLowerCase()), + ); + const updateUserSet = new Set( + updateUsers.map((grant) => grant.email.toLowerCase()), + ); + const removeUserSet = new Set( + removeUsers.map((email) => email.toLowerCase()), + ); + const addTeamSet = new Set(addTeams.map((grant) => grant.teamUid)); + const updateTeamSet = new Set(updateTeams.map((grant) => grant.teamUid)); + const removeTeamSet = new Set(removeTeams); + + const counts = emptyCounts(); + for (const result of results) { + if (!result.success) continue; + const key = (result.email || "").toLowerCase(); + if (addUserSet.has(key)) counts.usersAdded += 1; + else if (updateUserSet.has(key)) counts.usersUpdated += 1; + else if (removeUserSet.has(key)) counts.usersRemoved += 1; + else if (addTeamSet.has(result.email)) counts.teamsAdded += 1; + else if (updateTeamSet.has(result.email)) counts.teamsUpdated += 1; + else if (removeTeamSet.has(result.email)) counts.teamsRemoved += 1; + } + return counts; +} + +async function applyFolderMembership( + auth: Auth, + storage: InMemoryStorage, + entry: MembershipSharedFolder, + fullSync: boolean, +): Promise { + const path = entry.path || entry.uid || "(unknown)"; + const counts = emptyCounts(); + + let sharedFolder: DSharedFolder | undefined; + try { + sharedFolder = resolveSharedFolder(storage, entry); + } catch (err) { + return { + path, + success: false, + message: extractErrorMessage(err), + counts, + results: [], + }; + } + if (!sharedFolder) { + return { + path, + success: false, + message: `Shared folder "${entry.uid || entry.path}" was not found in the vault.`, + counts, + results: [], + }; + } + + const emailMap = buildAccountUidEmailMap(storage); + const existingUsers = getExistingUsers(storage, sharedFolder.uid, emailMap); + const existingTeams = getExistingTeams(storage, sharedFolder.uid); + + const permissions = entry.permissions || []; + const userPermissions = permissions.filter((permission) => + isValidEmail(permission.name), + ); + const teamPermissions = permissions.filter( + (permission) => !isValidEmail(permission.name), + ); + + let teamIdentifierMap: Map; + try { + teamIdentifierMap = await resolveTeamIdentifierMap( + auth, + storage, + teamPermissions.map((permission) => permission.name), + ); + } catch (err) { + return { + sharedFolderUid: sharedFolder.uid, + path, + success: false, + message: extractErrorMessage(err), + counts, + results: [], + }; + } + + const addUsers: { + email: string; + manageUsers?: boolean; + manageRecords?: boolean; + }[] = []; + const updateUsers: { + email: string; + manageUsers?: boolean; + manageRecords?: boolean; + }[] = []; + const seenEmails = new Set(); + + for (const permission of userPermissions) { + const email = permission.name.trim(); + const key = email.toLowerCase(); + if (!key || seenEmails.has(key)) continue; + seenEmails.add(key); + const wantManageUsers = permission.manage_users === true; + const wantManageRecords = permission.manage_records === true; + const existing = existingUsers.get(key); + if (!existing) { + addUsers.push({ + email, + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + }); + } else if ( + fullSync && + (existing.manageUsers !== wantManageUsers || + existing.manageRecords !== wantManageRecords) + ) { + updateUsers.push({ + email, + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + }); + } + } + + const removeUsers: string[] = []; + if (fullSync) { + const ownerEmail = (sharedFolder.ownerUsername || "").trim().toLowerCase(); + for (const [email] of existingUsers) { + if (seenEmails.has(email)) continue; + if (ownerEmail && ownerEmail === email) continue; + removeUsers.push(email); + } + } + + const addTeams: { + teamUid: string; + manageUsers?: boolean; + manageRecords?: boolean; + }[] = []; + const updateTeams: { + teamUid: string; + manageUsers?: boolean; + manageRecords?: boolean; + }[] = []; + const seenTeamUids = new Set(); + const missingTargets: string[] = []; + + for (const permission of teamPermissions) { + const teamUid = teamIdentifierMap.get(permission.name); + if (!teamUid) { + missingTargets.push(permission.name); + continue; + } + if (seenTeamUids.has(teamUid)) continue; + seenTeamUids.add(teamUid); + const wantManageUsers = permission.manage_users === true; + const wantManageRecords = permission.manage_records === true; + const existing = existingTeams.get(teamUid); + if (!existing) { + addTeams.push({ + teamUid, + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + }); + } else if ( + fullSync && + (existing.manageUsers !== wantManageUsers || + existing.manageRecords !== wantManageRecords) + ) { + updateTeams.push({ + teamUid, + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + }); + } + } + + const removeTeams: string[] = []; + if (fullSync) { + for (const [teamUid] of existingTeams) { + if (!seenTeamUids.has(teamUid)) removeTeams.push(teamUid); + } + } + + let membershipResult; + try { + membershipResult = await updateSharedFolderMembership(auth, storage, { + sharedFolderUid: sharedFolder.uid, + addUsers, + updateUsers, + removeUsers, + addTeams, + updateTeams, + removeTeams, + }); + } catch (err) { + return { + sharedFolderUid: sharedFolder.uid, + path, + success: false, + message: extractErrorMessage(err), + counts, + results: [], + }; + } + + let permissionsMessage: string | undefined; + if (fullSync) { + const wantManageUsers = entry.manage_users === true; + const wantManageRecords = entry.manage_records === true; + const wantCanEdit = entry.can_edit === true; + const wantCanShare = entry.can_share === true; + const defaultsChanged = + sharedFolder.defaultManageUsers !== wantManageUsers || + sharedFolder.defaultManageRecords !== wantManageRecords || + sharedFolder.defaultCanEdit !== wantCanEdit || + sharedFolder.defaultCanShare !== wantCanShare; + if (defaultsChanged) { + try { + const permissionResult = await updateSharedFolderPermissions( + auth, + storage, + sharedFolder.uid, + { + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + canEdit: wantCanEdit, + canShare: wantCanShare, + }, + ); + if (!permissionResult.success) + permissionsMessage = permissionResult.message; + } catch (err) { + permissionsMessage = extractErrorMessage(err); + } + } + } + + const missingMessage = + missingTargets.length > 0 + ? `Could not resolve team(s): ${missingTargets.join(", ")}` + : undefined; + const message = + [membershipResult.message, permissionsMessage, missingMessage] + .filter((part): part is string => !!part) + .join("; ") || undefined; + + return { + sharedFolderUid: sharedFolder.uid, + path, + success: + membershipResult.success && + !permissionsMessage && + missingTargets.length === 0, + message, + counts: tallyCounts( + addUsers, + updateUsers, + removeUsers, + addTeams, + updateTeams, + removeTeams, + membershipResult.results, + ), + results: membershipResult.results, + }; +} + +async function applyTeamMembership( + auth: Auth, + team: MembershipTeam, + fullSync: boolean, +): Promise { + const name = team.name || team.uid || "(unknown)"; + const desired = new Set( + (team.members || []) + .map((member) => member.trim().toLowerCase()) + .filter(Boolean), + ); + if (desired.size === 0 && !fullSync) { + return { teamUid: team.uid, name, success: true, added: 0, removed: 0 }; + } + + const enterpriseData = new EnterpriseDataManager(auth); + let data; + try { + data = await enterpriseData.getData([ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.QueuedTeamUsers, + ]); + } catch (err) { + return { + teamUid: team.uid, + name, + success: false, + message: extractErrorMessage(err), + added: 0, + removed: 0, + }; + } + + const identifier = (team.uid || team.name || "").trim(); + let resolvedTeamUid = ""; + try { + const resolved = resolveExistingTeams( + data.teams || [], + identifier ? [identifier] : [], + data.queued_teams || [], + ); + resolvedTeamUid = resolved[0]?.team_uid || ""; + } catch (err) { + return { + teamUid: team.uid, + name, + success: false, + message: extractErrorMessage(err), + added: 0, + removed: 0, + }; + } + if (!resolvedTeamUid) { + return { + teamUid: team.uid, + name, + success: false, + message: `Team "${name}" was not found.`, + added: 0, + removed: 0, + }; + } + + const usernameById = new Map(); + for (const user of data.users || []) + usernameById.set(user.enterprise_user_id, user.username); + + const currentMembers = new Set(); + for (const link of [ + ...(data.team_users || []), + ...(data.queued_team_users || []), + ]) { + if (link.team_uid !== resolvedTeamUid) continue; + const username = usernameById.get(link.enterprise_user_id); + if (username) currentMembers.add(username.toLowerCase()); + } + + const toAdd = [...desired].filter((email) => !currentMembers.has(email)); + const toRemove = fullSync + ? [...currentMembers].filter((email) => !desired.has(email)) + : []; + + let added = 0; + let removed = 0; + const messages: string[] = []; + + if (toAdd.length > 0) { + try { + const result = await addUsersToTeams(auth, { + users: toAdd, + teams: [resolvedTeamUid], + }); + added = result.succeeded; + if (result.failed > 0) + messages.push(`${result.failed} member(s) failed to add`); + } catch (err) { + messages.push(`add failed: ${extractErrorMessage(err)}`); + } + } + + if (toRemove.length > 0) { + try { + const result = await removeUsersFromTeams(auth, { + users: toRemove, + teams: [resolvedTeamUid], + }); + removed = result.succeeded; + if (result.failed > 0) + messages.push(`${result.failed} member(s) failed to remove`); + } catch (err) { + messages.push(`remove failed: ${extractErrorMessage(err)}`); + } + } + + return { + teamUid: resolvedTeamUid, + name, + success: messages.length === 0, + message: messages.length > 0 ? messages.join("; ") : undefined, + added, + removed, + }; +} + +/** + * Applies a previously downloaded (Commander-compatible) shared folder + * membership JSON to the vault: missing users/teams are granted access + * (and enterprise team membership from `teams[].members` is filled in). + * + * With `options.fullSync`, permission flags that differ are also updated and + * users/teams (and team members) absent from the JSON are removed. Without + * it (the default, matching Commander), only missing grants are added. + */ +export async function applyMembership( + auth: Auth, + storage: InMemoryStorage, + data: ApplyMembershipInput, + options: ApplyMembershipOptions = {}, +): Promise { + const fullSync = options.fullSync === true; + const normalized = normalizeMembershipData(data); + + const folders: ApplyMembershipFolderResult[] = []; + for (const entry of normalized.shared_folders || []) { + folders.push(await applyFolderMembership(auth, storage, entry, fullSync)); + } + + const teamMembership: ApplyMembershipTeamMembershipResult[] = []; + for (const team of normalized.teams || []) { + teamMembership.push(await applyTeamMembership(auth, team, fullSync)); + } + + let totals = emptyCounts(); + for (const folder of folders) totals = addCounts(totals, folder.counts); + for (const team of teamMembership) { + totals.teamMembersAdded += team.added; + totals.teamMembersRemoved += team.removed; + } + + const success = + folders.every((folder) => folder.success) && + teamMembership.every((team) => team.success); + + return { success, folders, teamMembership, totals }; +} diff --git a/KeeperSdk/src/sharedFolders/downloadMembership.ts b/KeeperSdk/src/sharedFolders/downloadMembership.ts new file mode 100644 index 00000000..8970240f --- /dev/null +++ b/KeeperSdk/src/sharedFolders/downloadMembership.ts @@ -0,0 +1,316 @@ +import type { + Auth, + DSharedFolder, + DSharedFolderTeam, + DSharedFolderUser, + DUser, +} from "@keeper-security/keeperapi"; +import { webSafe64FromBytes } from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { + FolderKind, + VaultObjectKind, + sharedFolderName, +} from "../folders/folderHelpers"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, +} from "../teams/enterpriseData"; +import { extractErrorMessage, logger } from "../utils"; + +/** A single user or team permission entry on a shared folder (Commander JSON shape). */ +export type MembershipPermission = { + name: string; + manage_users: boolean; + manage_records: boolean; +}; + +/** A shared folder's membership, matching Commander's `download-membership` JSON shape. */ +export type MembershipSharedFolder = { + uid: string; + path: string; + manage_users: boolean; + manage_records: boolean; + can_edit: boolean; + can_share: boolean; + permissions: MembershipPermission[]; +}; + +/** Enterprise team + its members, included alongside shared folder membership. */ +export type MembershipTeam = { + uid: string; + name: string; + members: string[]; +}; + +export type MembershipData = { + shared_folders: MembershipSharedFolder[]; + teams?: MembershipTeam[]; +}; + +export type DownloadMembershipOptions = { + /** Skip enterprise team lookup and only export shared folder membership. */ + foldersOnly?: boolean; + /** Case-insensitive substring/exact-uid filter applied to shared folder name or uid. */ + folderFilter?: string; + /** Force every exported user/team permission's manage_users flag to `true`. */ + forceManageUsers?: boolean; + /** Force every exported user/team permission's manage_records flag to `true`. */ + forceManageRecords?: boolean; + /** Force every exported user/team permission's manage_users flag to `false`. */ + forceRestrictUsers?: boolean; + /** Force every exported user/team permission's manage_records flag to `false`. */ + forceRestrictRecords?: boolean; +}; + +function resolveForcedFlag( + value: boolean, + force: boolean | undefined, + restrict: boolean | undefined, +): boolean { + if (force === true) return true; + if (restrict === true) return false; + return value; +} + +/** Maps `DUser.accountUid` (webSafe64) to the account's username/email. */ +export function buildAccountUidEmailMap( + storage: InMemoryStorage, +): Map { + const accountUidToEmail = new Map(); + for (const user of storage.getAll(VaultObjectKind.User)) { + const uid = user.accountUid ? webSafe64FromBytes(user.accountUid) : ""; + const email = (user.username || "").trim(); + if (uid && email) accountUidToEmail.set(uid, email); + } + return accountUidToEmail; +} + +/** Resolves the best-known email/username for a shared-folder user/owner entry. */ +export function resolveUserEmail( + accountUid: string | undefined, + accountUsername: string | undefined, + emailMap: Map, +): string { + const explicit = (accountUsername || "").trim(); + if (explicit) return explicit; + if (accountUid) return emailMap.get(accountUid) || accountUid; + return ""; +} + +function collectFolderPermissions( + storage: InMemoryStorage, + sharedFolder: DSharedFolder, + emailMap: Map, + options: DownloadMembershipOptions, +): MembershipPermission[] { + const permissions: MembershipPermission[] = []; + const seenUserUids = new Set(); + const seenEmails = new Set(); + + for (const sharedUser of storage.getAll( + VaultObjectKind.SharedFolderUser, + )) { + if (sharedUser.sharedFolderUid !== sharedFolder.uid) continue; + if (sharedUser.accountUid) seenUserUids.add(sharedUser.accountUid); + const email = resolveUserEmail( + sharedUser.accountUid, + sharedUser.accountUsername, + emailMap, + ); + if (!email) continue; + const key = email.toLowerCase(); + if (seenEmails.has(key)) continue; + seenEmails.add(key); + permissions.push({ + name: email, + manage_users: resolveForcedFlag( + sharedUser.manageUsers === true, + options.forceManageUsers, + options.forceRestrictUsers, + ), + manage_records: resolveForcedFlag( + sharedUser.manageRecords === true, + options.forceManageRecords, + options.forceRestrictRecords, + ), + }); + } + + if ( + sharedFolder.ownerAccountUid && + !seenUserUids.has(sharedFolder.ownerAccountUid) + ) { + const ownerEmail = resolveUserEmail( + sharedFolder.ownerAccountUid, + sharedFolder.ownerUsername, + emailMap, + ); + const key = ownerEmail.toLowerCase(); + if (ownerEmail && !seenEmails.has(key)) { + seenEmails.add(key); + permissions.push({ + name: ownerEmail, + manage_users: resolveForcedFlag( + true, + options.forceManageUsers, + options.forceRestrictUsers, + ), + manage_records: resolveForcedFlag( + true, + options.forceManageRecords, + options.forceRestrictRecords, + ), + }); + } + } + + for (const sharedTeam of storage.getAll( + VaultObjectKind.SharedFolderTeam, + )) { + if (sharedTeam.sharedFolderUid !== sharedFolder.uid) continue; + const name = (sharedTeam.name || sharedTeam.teamUid || "").trim(); + if (!name) continue; + permissions.push({ + name, + manage_users: resolveForcedFlag( + sharedTeam.manageUsers === true, + options.forceManageUsers, + options.forceRestrictUsers, + ), + manage_records: resolveForcedFlag( + sharedTeam.manageRecords === true, + options.forceManageRecords, + options.forceRestrictRecords, + ), + }); + } + + permissions.sort((permissionA, permissionB) => + permissionA.name + .toLowerCase() + .localeCompare(permissionB.name.toLowerCase()), + ); + return permissions; +} + +function matchesFolderFilter( + sharedFolder: DSharedFolder, + filter: string | undefined, +): boolean { + const pattern = (filter || "").trim().toLowerCase(); + if (!pattern) return true; + if (sharedFolder.uid.toLowerCase() === pattern) return true; + return sharedFolderName(sharedFolder).toLowerCase().includes(pattern); +} + +async function collectEnterpriseTeams(auth: Auth): Promise { + const enterpriseData = new EnterpriseDataManager(auth); + const data = await enterpriseData.getData([ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.QueuedTeamUsers, + ]); + + const usernameById = new Map(); + for (const user of data.users || []) { + usernameById.set(user.enterprise_user_id, user.username); + } + + const membersByTeam = new Map>(); + for (const link of [ + ...(data.team_users || []), + ...(data.queued_team_users || []), + ]) { + const username = usernameById.get(link.enterprise_user_id); + if (!username) continue; + let members = membersByTeam.get(link.team_uid); + if (!members) { + members = new Set(); + membersByTeam.set(link.team_uid, members); + } + members.add(username); + } + + const teams: MembershipTeam[] = []; + const seenTeamUids = new Set(); + for (const team of [...(data.teams || []), ...(data.queued_teams || [])]) { + if (!team.team_uid || seenTeamUids.has(team.team_uid)) continue; + seenTeamUids.add(team.team_uid); + const members = Array.from(membersByTeam.get(team.team_uid) || []).sort( + (memberA, memberB) => memberA.localeCompare(memberB), + ); + teams.push({ + uid: team.team_uid, + name: team.name || team.team_uid, + members, + }); + } + teams.sort((teamA, teamB) => + teamA.name.toLowerCase().localeCompare(teamB.name.toLowerCase()), + ); + return teams; +} + +/** + * Exports shared folder (and optionally enterprise team) membership to a + * Commander-compatible JSON structure, suitable for later re-applying with + * `applyMembership`. + * + * `auth` is only required (and only used) to fetch enterprise team data; pass + * `null` (or set `options.foldersOnly`) to export shared folder membership only. + */ +export async function downloadMembership( + auth: Auth | null, + storage: InMemoryStorage, + options: DownloadMembershipOptions = {}, +): Promise { + const emailMap = buildAccountUidEmailMap(storage); + + const shared_folders: MembershipSharedFolder[] = storage + .getAll(FolderKind.SharedFolder) + .filter((sharedFolder) => + matchesFolderFilter(sharedFolder, options.folderFilter), + ) + .map((sharedFolder) => ({ + uid: sharedFolder.uid, + path: sharedFolderName(sharedFolder), + manage_users: resolveForcedFlag( + sharedFolder.defaultManageUsers === true, + options.forceManageUsers, + options.forceRestrictUsers, + ), + manage_records: resolveForcedFlag( + sharedFolder.defaultManageRecords === true, + options.forceManageRecords, + options.forceRestrictRecords, + ), + can_edit: sharedFolder.defaultCanEdit === true, + can_share: sharedFolder.defaultCanShare === true, + permissions: collectFolderPermissions( + storage, + sharedFolder, + emailMap, + options, + ), + })) + .sort((folderA, folderB) => + folderA.path.toLowerCase().localeCompare(folderB.path.toLowerCase()), + ); + + const result: MembershipData = { shared_folders }; + + if (!options.foldersOnly && auth) { + try { + result.teams = await collectEnterpriseTeams(auth); + } catch (err) { + logger.debug( + `download-membership: could not include enterprise team membership: ${extractErrorMessage(err)}`, + ); + } + } + + return result; +} diff --git a/KeeperSdk/src/sharedFolders/listSharedFolders.ts b/KeeperSdk/src/sharedFolders/listSharedFolders.ts index ac484bf3..b674a116 100644 --- a/KeeperSdk/src/sharedFolders/listSharedFolders.ts +++ b/KeeperSdk/src/sharedFolders/listSharedFolders.ts @@ -1,173 +1,256 @@ import type { - DSharedFolder, - DSharedFolderRecord, - DSharedFolderTeam, - DSharedFolderUser, -} from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { TOKEN_SEPARATOR_PATTERN } from '../utils' -import { FolderKind, VaultObjectKind } from '../folders/folderHelpers' + DRecord, + DRecordRotation, + DSharedFolder, + DSharedFolderRecord, + DSharedFolderTeam, + DSharedFolderUser, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { TOKEN_SEPARATOR_PATTERN } from "../utils"; +import { FolderKind, VaultObjectKind } from "../folders/folderHelpers"; +import { getRecordType } from "../records/RecordUtils"; export type ListSharedFoldersOptions = { - pattern?: string | null - verbose?: boolean - includeDetails?: boolean -} + pattern?: string | null; + verbose?: boolean; + includeDetails?: boolean; + roeEligible?: boolean; +}; export type ListSharedFolderRow = { - shared_folder_uid: string - name: string - team_count?: number - user_count?: number - record_count?: number - default_manage_records?: boolean - default_manage_users?: boolean - default_can_edit?: boolean - default_can_share?: boolean -} + shared_folder_uid: string; + name: string; + team_count?: number; + user_count?: number; + record_count?: number; + default_manage_records?: boolean; + default_manage_users?: boolean; + default_can_edit?: boolean; + default_can_share?: boolean; +}; -const DEFAULT_COLUMN_WIDTH = 40 -const MIN_TRUNCATE_PREFIX = 3 +const DEFAULT_COLUMN_WIDTH = 40; +const MIN_TRUNCATE_PREFIX = 3; function sharedFolderDisplayName(folder: DSharedFolder): string { - return (folder.name || folder.uid).trim() || folder.uid + return (folder.name || folder.uid).trim() || folder.uid; } -function findSharedFolders(storage: InMemoryStorage, pattern: string): DSharedFolder[] { - const searchWords = tokenize(pattern.toLowerCase()) - const matches: DSharedFolder[] = [] - for (const sharedFolder of storage.getAll(FolderKind.SharedFolder)) { - const uid = sharedFolder.uid - const name = sharedFolderDisplayName(sharedFolder).toLowerCase() - const entityWords = tokenize(`${uid} ${name}`) - if (matchEntity(entityWords, searchWords)) { - matches.push(sharedFolder) - } +function findSharedFolders( + storage: InMemoryStorage, + pattern: string, +): DSharedFolder[] { + const searchWords = tokenize(pattern.toLowerCase()); + const matches: DSharedFolder[] = []; + for (const sharedFolder of storage.getAll( + FolderKind.SharedFolder, + )) { + const uid = sharedFolder.uid; + const name = sharedFolderDisplayName(sharedFolder).toLowerCase(); + const entityWords = tokenize(`${uid} ${name}`); + if (matchEntity(entityWords, searchWords)) { + matches.push(sharedFolder); } - return matches + } + return matches; } function matchEntity(entityWords: string[], searchWords: string[]): boolean { - if (!searchWords || searchWords.length === 0) return true - if (!entityWords || entityWords.length === 0) return false - for (const entityWord of entityWords) { - for (const searchWord of searchWords) { - if (searchWord.length <= entityWord.length && entityWord.includes(searchWord)) { - return true - } - } + if (!searchWords || searchWords.length === 0) return true; + if (!entityWords || entityWords.length === 0) return false; + for (const entityWord of entityWords) { + for (const searchWord of searchWords) { + if ( + searchWord.length <= entityWord.length && + entityWord.includes(searchWord) + ) { + return true; + } } - return false + } + return false; } function tokenize(text: string): string[] { - return text.split(TOKEN_SEPARATOR_PATTERN).filter((token) => token.length > 0) + return text + .split(TOKEN_SEPARATOR_PATTERN) + .filter((token) => token.length > 0); } -function countBySharedFolderUid(items: T[], sharedFolderUid: string): number { - let count = 0 - for (const item of items) { - if (item.sharedFolderUid === sharedFolderUid) count += 1 - } - return count +function countBySharedFolderUid( + items: T[], + sharedFolderUid: string, +): number { + let count = 0; + for (const item of items) { + if (item.sharedFolderUid === sharedFolderUid) count += 1; + } + return count; +} + +function countTeamsForFolder( + storage: InMemoryStorage, + sharedFolderUid: string, +): number { + return countBySharedFolderUid( + storage.getAll(VaultObjectKind.SharedFolderTeam), + sharedFolderUid, + ); +} + +function countUsersForFolder( + storage: InMemoryStorage, + sharedFolderUid: string, +): number { + return countBySharedFolderUid( + storage.getAll(VaultObjectKind.SharedFolderUser), + sharedFolderUid, + ); } -function countTeamsForFolder(storage: InMemoryStorage, sharedFolderUid: string): number { - return countBySharedFolderUid(storage.getAll(VaultObjectKind.SharedFolderTeam), sharedFolderUid) +function countRecordsForFolder( + storage: InMemoryStorage, + sharedFolderUid: string, +): number { + return countBySharedFolderUid( + storage.getAll(VaultObjectKind.SharedFolderRecord), + sharedFolderUid, + ); } -function countUsersForFolder(storage: InMemoryStorage, sharedFolderUid: string): number { - return countBySharedFolderUid(storage.getAll(VaultObjectKind.SharedFolderUser), sharedFolderUid) +function recordHasRotationConfigured( + storage: InMemoryStorage, + recordUid: string, +): boolean { + const rotation = storage.getByUid( + "record_rotation", + recordUid, + ); + return rotation != null && rotation.disabled !== true; } -function countRecordsForFolder(storage: InMemoryStorage, sharedFolderUid: string): number { - return countBySharedFolderUid( - storage.getAll(VaultObjectKind.SharedFolderRecord), - sharedFolderUid - ) +function sharedFolderHasPamUserWithRotation( + storage: InMemoryStorage, + sharedFolderUid: string, +): boolean { + for (const link of storage.getAll( + VaultObjectKind.SharedFolderRecord, + )) { + if (link.sharedFolderUid !== sharedFolderUid) continue; + const record = storage.getByUid( + VaultObjectKind.Record, + link.recordUid, + ); + if (!record) continue; + if (getRecordType(record).toLowerCase() !== "pamuser") continue; + if (recordHasRotationConfigured(storage, link.recordUid)) return true; + } + return false; } export function listSharedFolders( - storage: InMemoryStorage, - options: ListSharedFoldersOptions = {} + storage: InMemoryStorage, + options: ListSharedFoldersOptions = {}, ): ListSharedFolderRow[] { - const { pattern, includeDetails = false } = options - const sharedFolders: DSharedFolder[] = pattern - ? findSharedFolders(storage, pattern) - : storage.getAll(FolderKind.SharedFolder) - - return sharedFolders.map((sharedFolder) => { - const shared_folder_uid = sharedFolder.uid - const name = sharedFolderDisplayName(sharedFolder) - const row: ListSharedFolderRow = { shared_folder_uid, name } - if (includeDetails) { - row.record_count = countRecordsForFolder(storage, shared_folder_uid) - row.user_count = countUsersForFolder(storage, shared_folder_uid) - row.team_count = countTeamsForFolder(storage, shared_folder_uid) - row.default_manage_records = sharedFolder.defaultManageRecords - row.default_manage_users = sharedFolder.defaultManageUsers - row.default_can_edit = sharedFolder.defaultCanEdit - row.default_can_share = sharedFolder.defaultCanShare - } - return row + const { pattern, includeDetails = false, roeEligible = false } = options; + let sharedFolders: DSharedFolder[] = pattern + ? findSharedFolders(storage, pattern) + : storage.getAll(FolderKind.SharedFolder); + + if (roeEligible) { + sharedFolders = sharedFolders.filter((folder) => + sharedFolderHasPamUserWithRotation(storage, folder.uid), + ); + } + + return sharedFolders + .map((sharedFolder) => { + const shared_folder_uid = sharedFolder.uid; + const name = sharedFolderDisplayName(sharedFolder); + const row: ListSharedFolderRow = { shared_folder_uid, name }; + if (includeDetails) { + row.record_count = countRecordsForFolder(storage, shared_folder_uid); + row.user_count = countUsersForFolder(storage, shared_folder_uid); + row.team_count = countTeamsForFolder(storage, shared_folder_uid); + row.default_manage_records = sharedFolder.defaultManageRecords; + row.default_manage_users = sharedFolder.defaultManageUsers; + row.default_can_edit = sharedFolder.defaultCanEdit; + row.default_can_share = sharedFolder.defaultCanShare; + } + return row; }) + .sort((rowA, rowB) => + rowA.name.localeCompare(rowB.name, undefined, { sensitivity: "base" }), + ); } export type FormattedSharedFoldersTable = { - headers: string[] - rows: string[][] -} + headers: string[]; + rows: string[][]; +}; function truncateText(text: string, maxLength: number): string { - if (!text) return '' - if (text.length <= maxLength) return text - if (maxLength <= MIN_TRUNCATE_PREFIX) return text.slice(0, maxLength) - return `${text.slice(0, maxLength - MIN_TRUNCATE_PREFIX)}...` + if (!text) return ""; + if (text.length <= maxLength) return text; + if (maxLength <= MIN_TRUNCATE_PREFIX) return text.slice(0, maxLength); + return `${text.slice(0, maxLength - MIN_TRUNCATE_PREFIX)}...`; } export function formatSharedFoldersTable( - rows: ListSharedFolderRow[], - options: { verbose?: boolean; columnWidth?: number } = {} + rows: ListSharedFolderRow[], + options: { verbose?: boolean; columnWidth?: number } = {}, ): FormattedSharedFoldersTable { - const { verbose = false, columnWidth = DEFAULT_COLUMN_WIDTH } = options - const maxWidth = verbose ? null : columnWidth - const headers = ['#', 'Shared Folder UID', 'Name'] - const outRows: string[][] = rows.map((row, rowIndex) => { - const uid = maxWidth == null ? row.shared_folder_uid : truncateText(row.shared_folder_uid, maxWidth) - const name = maxWidth == null ? row.name : truncateText(row.name, maxWidth) - return [String(rowIndex + 1), uid, name] - }) - return { headers, rows: outRows } + const { verbose = false, columnWidth = DEFAULT_COLUMN_WIDTH } = options; + const maxWidth = verbose ? null : columnWidth; + const headers = ["#", "Shared Folder UID", "Name"]; + const outRows: string[][] = rows.map((row, rowIndex) => { + const uid = + maxWidth == null + ? row.shared_folder_uid + : truncateText(row.shared_folder_uid, maxWidth); + const name = maxWidth == null ? row.name : truncateText(row.name, maxWidth); + return [String(rowIndex + 1), uid, name]; + }); + return { headers, rows: outRows }; } export function renderSharedFoldersAsciiTable( - table: FormattedSharedFoldersTable, - options: { minColWidth?: number } = {} + table: FormattedSharedFoldersTable, + options: { minColWidth?: number } = {}, ): string { - const { minColWidth = 2 } = options - const { headers, rows } = table - const columnCount = headers.length - const columnWidths: number[] = new Array(columnCount).fill(0) + const { minColWidth = 2 } = options; + const { headers, rows } = table; + const columnCount = headers.length; + const columnWidths: number[] = new Array(columnCount).fill(0); + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + columnWidths[columnIndex] = Math.max( + headers[columnIndex].length, + minColWidth, + ); + } + for (const row of rows) { for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { - columnWidths[columnIndex] = Math.max(headers[columnIndex].length, minColWidth) - } - for (const row of rows) { - for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { - const cell = row[columnIndex] || '' - columnWidths[columnIndex] = Math.max(columnWidths[columnIndex], cell.length, minColWidth) - } - } - const padCell = (cell: string, columnIndex: number) => - cell + ' '.repeat(columnWidths[columnIndex] - cell.length) - const formatRow = (cells: string[]) => cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - const ruleRow = Array.from({ length: columnCount }, (_unused, columnIndex) => - '-'.repeat(columnWidths[columnIndex]) - ) - .map((dashes, columnIndex) => padCell(dashes, columnIndex)) - .join(' ') - const lines: string[] = [formatRow(headers), ruleRow] - for (const row of rows) { - lines.push(formatRow(row)) + const cell = row[columnIndex] || ""; + columnWidths[columnIndex] = Math.max( + columnWidths[columnIndex], + cell.length, + minColWidth, + ); } - return lines.join('\n') + } + const padCell = (cell: string, columnIndex: number) => + cell + " ".repeat(columnWidths[columnIndex] - cell.length); + const formatRow = (cells: string[]) => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + const ruleRow = Array.from({ length: columnCount }, (_unused, columnIndex) => + "-".repeat(columnWidths[columnIndex]), + ) + .map((dashes, columnIndex) => padCell(dashes, columnIndex)) + .join(" "); + const lines: string[] = [formatRow(headers), ruleRow]; + for (const row of rows) { + lines.push(formatRow(row)); + } + return lines.join("\n"); } diff --git a/KeeperSdk/src/sharedFolders/shareFolder.ts b/KeeperSdk/src/sharedFolders/shareFolder.ts index 98d622c9..5e3c64d9 100644 --- a/KeeperSdk/src/sharedFolders/shareFolder.ts +++ b/KeeperSdk/src/sharedFolders/shareFolder.ts @@ -1,457 +1,1288 @@ import type { - Auth, - Authentication, - DSharedFolder, - DSharedFolderFolder, - DSharedFolderUser, - DUserFolder, -} from '@keeper-security/keeperapi' + Auth, + Authentication, + DSharedFolder, + DSharedFolderFolder, + DSharedFolderTeam, + DSharedFolderUser, + DTeam, + DUserFolder, +} from "@keeper-security/keeperapi"; import { - Folder, - getPublicKeysMessage, - normal64Bytes, - platform, - sharedFolderUpdateV3Message, -} from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { extractErrorMessage, isBoolean, isObject, isValidEmail, KeeperSdkError } from '../utils' -import { FolderKind, FolderResultStatus, VaultObjectKind } from '../folders/folderHelpers' + Folder, + getPublicKeysMessage, + normal64Bytes, + platform, + sharedFolderUpdateV3Message, + webSafe64FromBytes, + type RestCommand, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, +} from "../teams/enterpriseData"; +import { resolveExistingTeams } from "../teams/teamUtils"; +import { + extractErrorMessage, + isBoolean, + isObject, + isValidEmail, + KeeperSdkError, +} from "../utils"; +import { + FolderKind, + FolderResultStatus, + VaultObjectKind, +} from "../folders/folderHelpers"; export enum ShareFolderAction { - Grant = 'grant', - Remove = 'remove', + Grant = "grant", + Remove = "remove", } -export type ShareFolderActionInput = ShareFolderAction | `${ShareFolderAction}` +export type ShareFolderActionInput = ShareFolderAction | `${ShareFolderAction}`; export enum ShareFolderUserResultStatus { - Success = 'success', - Invited = 'invited', - MissingPublicKey = 'missing_public_key', - Unknown = 'unknown', + Success = "success", + Invited = "invited", + MissingPublicKey = "missing_public_key", + Unknown = "unknown", } export type ShareFolderInput = { - folder: string - emails: string[] - action?: ShareFolderActionInput - manageRecords?: boolean - manageUsers?: boolean -} + folder: string; + emails: string[]; + action?: ShareFolderActionInput; + manageRecords?: boolean; + manageUsers?: boolean; +}; export type ShareFolderUserStatus = { - email: string - success: boolean - status: string - message?: string -} + email: string; + success: boolean; + status: string; + message?: string; +}; export type ShareFolderResult = { - success: boolean - message?: string - folderUid: string - folderKind: FolderKind.SharedFolder - sharedFolderUid: string - results: ShareFolderUserStatus[] -} + success: boolean; + message?: string; + folderUid: string; + folderKind: FolderKind.SharedFolder; + sharedFolderUid: string; + results: ShareFolderUserStatus[]; +}; type ResolvedFolder = - | { - kind: FolderKind.SharedFolder - folderUid: string - sharedFolderUid: string - displayName: string - } - | { - kind: FolderKind.SharedFolderFolder - folderUid: string - sharedFolderUid: string - displayName: string - } - | { kind: FolderKind.UserFolder; folderUid: string; displayName: string } + | { + kind: FolderKind.SharedFolder; + folderUid: string; + sharedFolderUid: string; + displayName: string; + } + | { + kind: FolderKind.SharedFolderFolder; + folderUid: string; + sharedFolderUid: string; + displayName: string; + } + | { kind: FolderKind.UserFolder; folderUid: string; displayName: string }; -type UserPublicKeys = { - rsaPublicKey: Uint8Array | null - eccPublicKey: Uint8Array | null - errorCode?: string - message?: string - username: string +type TeamPublicKeys = { + rsaPublicKey: Uint8Array | null; + eccPublicKey: Uint8Array | null; + aesKey: Uint8Array | null; +}; + +const TEAM_GET_KEYS_BATCH_SIZE = 90; + +type TeamGetKeysResponse = { + keys?: Array<{ + team_uid: string; + key: string; + type: number; + }>; +}; + +function teamGetKeysCommand( + teams: string[], +): RestCommand<{ teams: string[] }, TeamGetKeysResponse> { + return { + baseRequest: { command: "team_get_keys" }, + request: { teams }, + authorization: {}, + }; } function toSetBoolean(value: boolean | undefined): Folder.SetBooleanValue { - if (value === true) return Folder.SetBooleanValue.BOOLEAN_TRUE - if (value === false) return Folder.SetBooleanValue.BOOLEAN_FALSE - return Folder.SetBooleanValue.BOOLEAN_NO_CHANGE + if (value === true) return Folder.SetBooleanValue.BOOLEAN_TRUE; + if (value === false) return Folder.SetBooleanValue.BOOLEAN_FALSE; + return Folder.SetBooleanValue.BOOLEAN_NO_CHANGE; } function dataName(data: unknown): string { - if (isObject(data)) { - const { title, name } = data as { title?: string; name?: string } - return (title || name || '').trim() - } - return '' + if (isObject(data)) { + const { title, name } = data as { title?: string; name?: string }; + return (title || name || "").trim(); + } + return ""; } -function resolveFolder(storage: InMemoryStorage, nameOrUid: string): ResolvedFolder | undefined { - const trimmedNameOrUid = nameOrUid.trim() - if (!trimmedNameOrUid) return undefined - - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, trimmedNameOrUid) - if (sharedFolder) { - return { - kind: FolderKind.SharedFolder, - folderUid: sharedFolder.uid, - sharedFolderUid: sharedFolder.uid, - displayName: (sharedFolder.name || sharedFolder.uid).trim() || sharedFolder.uid, - } +function resolveFolder( + storage: InMemoryStorage, + nameOrUid: string, +): ResolvedFolder | undefined { + const trimmedNameOrUid = nameOrUid.trim(); + if (!trimmedNameOrUid) return undefined; + + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + trimmedNameOrUid, + ); + if (sharedFolder) { + return { + kind: FolderKind.SharedFolder, + folderUid: sharedFolder.uid, + sharedFolderUid: sharedFolder.uid, + displayName: + (sharedFolder.name || sharedFolder.uid).trim() || sharedFolder.uid, + }; + } + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + trimmedNameOrUid, + ); + if (sharedFolderFolder) { + return { + kind: FolderKind.SharedFolderFolder, + folderUid: sharedFolderFolder.uid, + sharedFolderUid: sharedFolderFolder.sharedFolderUid, + displayName: dataName(sharedFolderFolder.data) || sharedFolderFolder.uid, + }; + } + const userFolder = storage.getByUid( + FolderKind.UserFolder, + trimmedNameOrUid, + ); + if (userFolder) { + return { + kind: FolderKind.UserFolder, + folderUid: userFolder.uid, + displayName: dataName(userFolder.data) || userFolder.uid, + }; + } + + const lowerNameOrUid = trimmedNameOrUid.toLowerCase(); + for (const candidateSharedFolder of storage.getAll( + FolderKind.SharedFolder, + )) { + if ( + (candidateSharedFolder.name || "").trim().toLowerCase() === lowerNameOrUid + ) { + return { + kind: FolderKind.SharedFolder, + folderUid: candidateSharedFolder.uid, + sharedFolderUid: candidateSharedFolder.uid, + displayName: candidateSharedFolder.name || candidateSharedFolder.uid, + }; } - const sharedFolderFolder = storage.getByUid(FolderKind.SharedFolderFolder, trimmedNameOrUid) - if (sharedFolderFolder) { - return { - kind: FolderKind.SharedFolderFolder, - folderUid: sharedFolderFolder.uid, - sharedFolderUid: sharedFolderFolder.sharedFolderUid, - displayName: dataName(sharedFolderFolder.data) || sharedFolderFolder.uid, - } + } + for (const candidateSharedFolderFolder of storage.getAll( + FolderKind.SharedFolderFolder, + )) { + const candidateName = dataName(candidateSharedFolderFolder.data); + if (candidateName && candidateName.toLowerCase() === lowerNameOrUid) { + return { + kind: FolderKind.SharedFolderFolder, + folderUid: candidateSharedFolderFolder.uid, + sharedFolderUid: candidateSharedFolderFolder.sharedFolderUid, + displayName: candidateName, + }; } - const userFolder = storage.getByUid(FolderKind.UserFolder, trimmedNameOrUid) - if (userFolder) { - return { - kind: FolderKind.UserFolder, - folderUid: userFolder.uid, - displayName: dataName(userFolder.data) || userFolder.uid, - } + } + for (const candidateUserFolder of storage.getAll( + FolderKind.UserFolder, + )) { + const candidateName = dataName(candidateUserFolder.data); + if (candidateName && candidateName.toLowerCase() === lowerNameOrUid) { + return { + kind: FolderKind.UserFolder, + folderUid: candidateUserFolder.uid, + displayName: candidateName, + }; } + } - const lowerNameOrUid = trimmedNameOrUid.toLowerCase() - for (const candidateSharedFolder of storage.getAll(FolderKind.SharedFolder)) { - if ((candidateSharedFolder.name || '').trim().toLowerCase() === lowerNameOrUid) { - return { - kind: FolderKind.SharedFolder, - folderUid: candidateSharedFolder.uid, - sharedFolderUid: candidateSharedFolder.uid, - displayName: candidateSharedFolder.name || candidateSharedFolder.uid, - } - } - } - for (const candidateSharedFolderFolder of storage.getAll(FolderKind.SharedFolderFolder)) { - const candidateName = dataName(candidateSharedFolderFolder.data) - if (candidateName && candidateName.toLowerCase() === lowerNameOrUid) { - return { - kind: FolderKind.SharedFolderFolder, - folderUid: candidateSharedFolderFolder.uid, - sharedFolderUid: candidateSharedFolderFolder.sharedFolderUid, - displayName: candidateName, - } - } + return undefined; +} + +async function fetchUserPublicKeys( + auth: Auth, + emails: string[], +): Promise> { + const usernameToKeys = new Map(); + if (emails.length === 0) return usernameToKeys; + + const keysRequest = getPublicKeysMessage({ usernames: emails }); + let response: Authentication.IGetPublicKeysResponse; + try { + response = await auth.executeRest(keysRequest); + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch public keys: ${extractErrorMessage(err)}`, + ); + } + for (const entry of response.keyResponses || []) { + const username = (entry.username || "").toLowerCase(); + if (!username) continue; + usernameToKeys.set(username, { + username: entry.username || "", + rsaPublicKey: + entry.publicKey && entry.publicKey.length > 0 + ? (entry.publicKey as Uint8Array) + : null, + eccPublicKey: + entry.publicEccKey && entry.publicEccKey.length > 0 + ? (entry.publicEccKey as Uint8Array) + : null, + errorCode: entry.errorCode || undefined, + message: entry.message || undefined, + }); + } + return usernameToKeys; +} + +type UserPublicKeys = { + rsaPublicKey: Uint8Array | null; + eccPublicKey: Uint8Array | null; + errorCode?: string; + message?: string; + username: string; +}; + +function splitShareTargets(targets: string[]): { + userEmails: string[]; + teamIdentifiers: string[]; +} { + const userEmails: string[] = []; + const teamIdentifiers: string[] = []; + for (const target of targets) { + if (isValidEmail(target)) userEmails.push(target); + else teamIdentifiers.push(target); + } + return { userEmails, teamIdentifiers }; +} + +function dedupeTargets(targets: string[]): string[] { + const seen = new Set(); + const deduped: string[] = []; + for (const rawTarget of targets) { + const normalized = (rawTarget || "").trim(); + if (!normalized) continue; + const key = isValidEmail(normalized) + ? normalized.toLowerCase() + : normalized; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(isValidEmail(normalized) ? key : normalized); + } + return deduped; +} + +async function resolveTeamUids( + auth: Auth, + storage: InMemoryStorage, + identifiers: string[], +): Promise { + if (identifiers.length === 0) return []; + + const vaultTeams = storage.getAll(VaultObjectKind.Team); + const byUid = new Map(vaultTeams.map((team) => [team.uid, team.uid])); + const byLowerName = new Map(); + for (const team of vaultTeams) { + const name = (team.name || "").trim().toLowerCase(); + if (name && !byLowerName.has(name)) byLowerName.set(name, team.uid); + } + + const toResolve: string[] = []; + const resolved: string[] = []; + for (const raw of identifiers) { + const id = raw.trim(); + if (byUid.has(id)) { + resolved.push(byUid.get(id)!); + continue; } - for (const candidateUserFolder of storage.getAll(FolderKind.UserFolder)) { - const candidateName = dataName(candidateUserFolder.data) - if (candidateName && candidateName.toLowerCase() === lowerNameOrUid) { - return { - kind: FolderKind.UserFolder, - folderUid: candidateUserFolder.uid, - displayName: candidateName, - } - } + const nameMatch = byLowerName.get(id.toLowerCase()); + if (nameMatch) { + resolved.push(nameMatch); + continue; } + toResolve.push(id); + } + + if (toResolve.length > 0) { + const enterpriseData = new EnterpriseDataManager(auth); + const data = await enterpriseData.getData([ + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + ]); + const teams = resolveExistingTeams( + data.teams || [], + toResolve, + data.queued_teams || [], + ); + resolved.push(...teams.map((team) => team.team_uid)); + } - return undefined + return [...new Set(resolved)]; } -async function fetchUserPublicKeys(auth: Auth, emails: string[]): Promise> { - const usernameToKeys = new Map() - if (emails.length === 0) return usernameToKeys +async function loadTeamKeys( + auth: Auth, + teamUids: string[], +): Promise> { + const keysByTeam = new Map(); + if (teamUids.length === 0) return keysByTeam; - const keysRequest = getPublicKeysMessage({ usernames: emails }) - let response: Authentication.IGetPublicKeysResponse + const pending = [...new Set(teamUids)]; + while (pending.length > 0) { + const batch = pending.splice(0, TEAM_GET_KEYS_BATCH_SIZE); + let response; try { - response = await auth.executeRest(keysRequest) + response = await auth.executeRestCommand(teamGetKeysCommand(batch)); } catch (err) { - throw new KeeperSdkError(`Failed to fetch public keys: ${extractErrorMessage(err)}`) + throw new KeeperSdkError( + `Failed to fetch team keys: ${extractErrorMessage(err)}`, + ); } - for (const entry of response.keyResponses || []) { - const username = (entry.username || '').toLowerCase() - if (!username) continue - usernameToKeys.set(username, { - username: entry.username || '', - rsaPublicKey: entry.publicKey && entry.publicKey.length > 0 ? (entry.publicKey as Uint8Array) : null, - eccPublicKey: - entry.publicEccKey && entry.publicEccKey.length > 0 ? (entry.publicEccKey as Uint8Array) : null, - errorCode: entry.errorCode || undefined, - message: entry.message || undefined, - }) + + for (const entry of response.keys || []) { + const teamUid = (entry.team_uid || "").trim(); + if (!teamUid || !entry.key) continue; + try { + const encryptedKey = normal64Bytes(entry.key); + keysByTeam.set( + teamUid, + await decryptTeamKeyEntry(auth, encryptedKey, entry.type), + ); + } catch (err) { + throw new KeeperSdkError( + `Failed to decrypt team key for "${teamUid}": ${extractErrorMessage(err)}`, + ); + } } - return usernameToKeys + } + + return keysByTeam; } -function dedupeEmails(emails: string[]): string[] { - const seen = new Set() - const dedupedEmails: string[] = [] - for (const rawEmail of emails) { - const normalized = (rawEmail || '').trim().toLowerCase() - if (!normalized) continue - if (seen.has(normalized)) continue - seen.add(normalized) - dedupedEmails.push(normalized) - } - return dedupedEmails +async function decryptTeamKeyEntry( + auth: Auth, + encryptedKey: Uint8Array, + keyType: number, +): Promise { + const empty: TeamPublicKeys = { + rsaPublicKey: null, + eccPublicKey: null, + aesKey: null, + }; + switch (keyType) { + case 1: + return { + ...empty, + aesKey: await platform.aesCbcDecrypt(encryptedKey, auth.dataKey, true), + }; + case 2: + if (!auth.privateKey) return empty; + return { + ...empty, + aesKey: platform.privateDecrypt(encryptedKey, auth.privateKey), + }; + case 3: + return { + ...empty, + aesKey: await platform.aesGcmDecrypt(encryptedKey, auth.dataKey), + }; + case 4: + if (!auth.eccPrivateKey) return empty; + return { + ...empty, + aesKey: await platform.privateDecryptEC( + encryptedKey, + auth.eccPrivateKey, + ), + }; + case -1: + return { ...empty, eccPublicKey: encryptedKey }; + case -3: + return { ...empty, rsaPublicKey: encryptedKey }; + default: + return empty; + } +} + +async function encryptSharedFolderKeyForTeam( + sharedFolderKey: Uint8Array, + teamKeys: TeamPublicKeys, +): Promise { + if (teamKeys.aesKey) { + return { + encryptedKey: await platform.aesCbcEncrypt( + sharedFolderKey, + teamKeys.aesKey, + true, + ), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_data_key, + }; + } + if (teamKeys.eccPublicKey) { + return { + encryptedKey: await platform.publicEncryptEC( + sharedFolderKey, + teamKeys.eccPublicKey, + ), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key_ecc, + }; + } + if (teamKeys.rsaPublicKey) { + return { + encryptedKey: platform.publicEncrypt( + sharedFolderKey, + platform.bytesToBase64(teamKeys.rsaPublicKey), + ), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key, + }; + } + return undefined; +} + +function teamUidFromStatus(teamUid: Uint8Array | null | undefined): string { + return teamUid && teamUid.length > 0 ? webSafe64FromBytes(teamUid) : ""; } async function removeFromSharedFolder( - auth: Auth, - sharedFolder: DSharedFolder, - resolved: Extract, - emails: string[] + auth: Auth, + sharedFolder: DSharedFolder, + resolved: Extract< + ResolvedFolder, + { kind: FolderKind.SharedFolder | FolderKind.SharedFolderFolder } + >, + userEmails: string[], + teamUids: string[], ): Promise { - const updateRequest: Folder.ISharedFolderUpdateV3Request = { - sharedFolderUid: normal64Bytes(sharedFolder.uid), - revision: sharedFolder.revision, - forceUpdate: false, - sharedFolderRemoveUser: emails, - } + const updateRequest: Folder.ISharedFolderUpdateV3Request = { + sharedFolderUid: normal64Bytes(sharedFolder.uid), + revision: sharedFolder.revision, + forceUpdate: false, + }; + if (userEmails.length > 0) updateRequest.sharedFolderRemoveUser = userEmails; + if (teamUids.length > 0) { + updateRequest.sharedFolderRemoveTeam = teamUids.map((teamUid) => + normal64Bytes(teamUid), + ); + } - let response: Folder.ISharedFolderUpdateV3ResponseV2 - try { - response = await auth.executeRest(sharedFolderUpdateV3Message({ sharedFoldersUpdateV3: [updateRequest] })) - } catch (err) { - return { - success: false, - folderUid: resolved.folderUid, - sharedFolderUid: sharedFolder.uid, - folderKind: FolderKind.SharedFolder, - message: `shared_folder_update_v3 (remove) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): ${extractErrorMessage(err)}`, - results: [], - } - } + if ( + (updateRequest.sharedFolderRemoveUser || []).length === 0 && + (updateRequest.sharedFolderRemoveTeam || []).length === 0 + ) { + throw new KeeperSdkError( + "Provide at least one user or team to remove.", + "no_targets", + ); + } - const innerResponse = (response.sharedFoldersUpdateV3Response || [])[0] - const requestOk = !innerResponse?.status || innerResponse.status === FolderResultStatus.Success + let response: Folder.ISharedFolderUpdateV3ResponseV2; + try { + response = await auth.executeRest( + sharedFolderUpdateV3Message({ sharedFoldersUpdateV3: [updateRequest] }), + ); + } catch (err) { + return { + success: false, + folderUid: resolved.folderUid, + sharedFolderUid: sharedFolder.uid, + folderKind: FolderKind.SharedFolder, + message: `shared_folder_update_v3 (remove) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): ${extractErrorMessage(err)}`, + results: [], + }; + } - const userResults: ShareFolderUserStatus[] = [] - for (const userStatus of innerResponse?.sharedFolderRemoveUserStatus || []) { - const status = userStatus.status || ShareFolderUserResultStatus.Unknown - userResults.push({ - email: userStatus.username || '', - success: status === FolderResultStatus.Success, - status, - }) - } + const innerResponse = (response.sharedFoldersUpdateV3Response || [])[0]; + const requestOk = + !innerResponse?.status || + innerResponse.status === FolderResultStatus.Success; - const allUsersOk = userResults.length > 0 && userResults.every((userResult) => userResult.success) + const userResults: ShareFolderUserStatus[] = []; + for (const userStatus of innerResponse?.sharedFolderRemoveUserStatus || []) { + const status = userStatus.status || ShareFolderUserResultStatus.Unknown; + userResults.push({ + email: userStatus.username || "", + success: status === FolderResultStatus.Success, + status, + }); + } + for (const teamStatus of innerResponse?.sharedFolderRemoveTeamStatus || []) { + const status = teamStatus.status || ShareFolderUserResultStatus.Unknown; + userResults.push({ + email: teamUidFromStatus(teamStatus.teamUid as Uint8Array), + success: status === FolderResultStatus.Success, + status, + }); + } - const failureReason = !requestOk - ? innerResponse?.status || - `shared_folder_update_v3 (remove) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): server returned no status` - : undefined + const allUsersOk = + userResults.length === 0 + ? requestOk + : userResults.every((userResult) => userResult.success); - return { - success: requestOk && allUsersOk, - folderUid: resolved.folderUid, - sharedFolderUid: sharedFolder.uid, - folderKind: FolderKind.SharedFolder, - message: failureReason, - results: userResults, - } + const failureReason = !requestOk + ? innerResponse?.status || + `shared_folder_update_v3 (remove) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): server returned no status` + : undefined; + + return { + success: requestOk && allUsersOk, + folderUid: resolved.folderUid, + sharedFolderUid: sharedFolder.uid, + folderKind: FolderKind.SharedFolder, + message: failureReason, + results: userResults, + }; } async function shareWithSharedFolder( - auth: Auth, - storage: InMemoryStorage, - resolved: Extract, - input: ShareFolderInput + auth: Auth, + storage: InMemoryStorage, + resolved: Extract< + ResolvedFolder, + { kind: FolderKind.SharedFolder | FolderKind.SharedFolderFolder } + >, + input: ShareFolderInput, ): Promise { - const sharedFolder = storage.getByUid(FolderKind.SharedFolder, resolved.sharedFolderUid) - if (!sharedFolder) { - throw new KeeperSdkError(`Shared folder "${resolved.sharedFolderUid}" not found.`, 'shared_folder_not_found') - } - const sharedFolderKey = await storage.getKeyBytes(sharedFolder.uid) - if (!sharedFolderKey) { - throw new KeeperSdkError( - 'Shared folder encryption key not available. Sync the vault and try again.', - 'shared_folder_key_missing' - ) - } + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + resolved.sharedFolderUid, + ); + if (!sharedFolder) { + throw new KeeperSdkError( + `Shared folder "${resolved.sharedFolderUid}" not found.`, + "shared_folder_not_found", + ); + } + const sharedFolderKey = await storage.getKeyBytes(sharedFolder.uid); + if (!sharedFolderKey) { + throw new KeeperSdkError( + "Shared folder encryption key not available. Sync the vault and try again.", + "shared_folder_key_missing", + ); + } - const emails = dedupeEmails(input.emails) - if (emails.length === 0) { - throw new KeeperSdkError('Provide at least one user email.', 'no_emails') - } + const targets = dedupeTargets(input.emails); + if (targets.length === 0) { + throw new KeeperSdkError( + "Provide at least one user email or team.", + "no_targets", + ); + } - const invalidEmails = emails.filter((email) => !isValidEmail(email)) - if (invalidEmails.length > 0) { - throw new KeeperSdkError(`Invalid email(s): ${invalidEmails.join(', ')}`, 'invalid_email') - } + const { userEmails, teamIdentifiers } = splitShareTargets(targets); + const teamUids = await resolveTeamUids(auth, storage, teamIdentifiers); - if (input.action === ShareFolderAction.Remove) { - return removeFromSharedFolder(auth, sharedFolder, resolved, emails) - } + if (input.action === ShareFolderAction.Remove) { + return removeFromSharedFolder( + auth, + sharedFolder, + resolved, + userEmails, + teamUids, + ); + } - const existingMembers = new Set() - for (const sharedFolderUser of storage.getAll(VaultObjectKind.SharedFolderUser)) { - if (sharedFolderUser.sharedFolderUid === sharedFolder.uid && sharedFolderUser.accountUsername) { - existingMembers.add(sharedFolderUser.accountUsername.toLowerCase()) - } + const existingMembers = new Set(); + for (const sharedFolderUser of storage.getAll( + VaultObjectKind.SharedFolderUser, + )) { + if ( + sharedFolderUser.sharedFolderUid === sharedFolder.uid && + sharedFolderUser.accountUsername + ) { + existingMembers.add(sharedFolderUser.accountUsername.toLowerCase()); } + } - const newEmails = emails.filter((email) => !existingMembers.has(email)) - const usernameToKeys = await fetchUserPublicKeys(auth, newEmails) - - const usersToAdd: Folder.ISharedFolderUpdateUser[] = [] - const usersToUpdate: Folder.ISharedFolderUpdateUser[] = [] - const userResults: ShareFolderUserStatus[] = [] - - const newUserManageRecords = isBoolean(input.manageRecords) - ? input.manageRecords - : sharedFolder.defaultManageRecords - const newUserManageUsers = isBoolean(input.manageUsers) - ? input.manageUsers - : sharedFolder.defaultManageUsers - - for (const email of emails) { - if (existingMembers.has(email)) { - usersToUpdate.push({ - username: email, - manageRecords: toSetBoolean(input.manageRecords), - manageUsers: toSetBoolean(input.manageUsers), - }) - continue - } - - const publicKeys = usernameToKeys.get(email) - if (!publicKeys) { - userResults.push({ - email, - success: false, - status: ShareFolderUserResultStatus.MissingPublicKey, - message: `No public key returned for user "${email}" (folder="${resolved.displayName}")`, - }) - continue - } - if (publicKeys.errorCode) { - userResults.push({ - email, - success: false, - status: publicKeys.errorCode, - message: publicKeys.message || publicKeys.errorCode, - }) - continue - } - - let encryptedKey: Uint8Array - let encryptedKeyType: Folder.EncryptedKeyType - if (publicKeys.rsaPublicKey) { - const rsaPublicKeyBase64 = platform.bytesToBase64(publicKeys.rsaPublicKey) - encryptedKey = platform.publicEncrypt(sharedFolderKey, rsaPublicKeyBase64) - encryptedKeyType = Folder.EncryptedKeyType.encrypted_by_public_key - } else if (publicKeys.eccPublicKey) { - encryptedKey = await platform.publicEncryptEC(sharedFolderKey, publicKeys.eccPublicKey) - encryptedKeyType = Folder.EncryptedKeyType.encrypted_by_public_key_ecc - } else { - userResults.push({ - email, - success: false, - status: ShareFolderUserResultStatus.MissingPublicKey, - message: `No usable public key for user "${email}" (folder="${resolved.displayName}")`, - }) - continue - } - - usersToAdd.push({ - username: email, - manageRecords: toSetBoolean(newUserManageRecords), - manageUsers: toSetBoolean(newUserManageUsers), - typedSharedFolderKey: { encryptedKey, encryptedKeyType }, - }) + const existingTeams = new Set(); + const existingTeamByUid = new Map(); + for (const sharedFolderTeam of storage.getAll( + VaultObjectKind.SharedFolderTeam, + )) { + if ( + sharedFolderTeam.sharedFolderUid === sharedFolder.uid && + sharedFolderTeam.teamUid + ) { + existingTeams.add(sharedFolderTeam.teamUid); + existingTeamByUid.set(sharedFolderTeam.teamUid, sharedFolderTeam); } + } + + const newEmails = userEmails.filter((email) => !existingMembers.has(email)); + const newTeamUids = teamUids.filter((teamUid) => !existingTeams.has(teamUid)); + const usernameToKeys = await fetchUserPublicKeys(auth, newEmails); + const teamKeysByUid = await loadTeamKeys(auth, newTeamUids); - if (usersToAdd.length === 0 && usersToUpdate.length === 0) { - return { - success: false, - folderUid: resolved.folderUid, - sharedFolderUid: sharedFolder.uid, - folderKind: FolderKind.SharedFolder, - message: `No users could be processed for shared folder "${resolved.displayName}" (uid=${sharedFolder.uid}).`, - results: userResults, - } + const usersToAdd: Folder.ISharedFolderUpdateUser[] = []; + const usersToUpdate: Folder.ISharedFolderUpdateUser[] = []; + const teamsToAdd: Folder.ISharedFolderUpdateTeam[] = []; + const teamsToUpdate: Folder.ISharedFolderUpdateTeam[] = []; + const userResults: ShareFolderUserStatus[] = []; + + const newUserManageRecords = isBoolean(input.manageRecords) + ? input.manageRecords + : sharedFolder.defaultManageRecords; + const newUserManageUsers = isBoolean(input.manageUsers) + ? input.manageUsers + : sharedFolder.defaultManageUsers; + const hasPermissionChange = + isBoolean(input.manageRecords) || isBoolean(input.manageUsers); + const newTeamManageRecords = isBoolean(input.manageRecords) + ? input.manageRecords + : sharedFolder.defaultManageRecords === true; + const newTeamManageUsers = isBoolean(input.manageUsers) + ? input.manageUsers + : sharedFolder.defaultManageUsers === true; + + for (const email of userEmails) { + if (existingMembers.has(email)) { + if (hasPermissionChange) { + usersToUpdate.push({ + username: email, + manageRecords: toSetBoolean(input.manageRecords), + manageUsers: toSetBoolean(input.manageUsers), + }); + } else { + userResults.push({ + email, + success: true, + status: FolderResultStatus.Success, + }); + } + continue; } - const updateRequest: Folder.ISharedFolderUpdateV3Request = { - sharedFolderUid: normal64Bytes(sharedFolder.uid), - revision: sharedFolder.revision, - forceUpdate: false, + const publicKeys = usernameToKeys.get(email); + if (!publicKeys) { + userResults.push({ + email, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No public key returned for user "${email}" (folder="${resolved.displayName}")`, + }); + continue; + } + if (publicKeys.errorCode) { + userResults.push({ + email, + success: false, + status: publicKeys.errorCode, + message: publicKeys.message || publicKeys.errorCode, + }); + continue; } - if (usersToAdd.length > 0) updateRequest.sharedFolderAddUser = usersToAdd - if (usersToUpdate.length > 0) updateRequest.sharedFolderUpdateUser = usersToUpdate - let response: Folder.ISharedFolderUpdateV3ResponseV2 - try { - response = await auth.executeRest(sharedFolderUpdateV3Message({ sharedFoldersUpdateV3: [updateRequest] })) - } catch (err) { - return { - success: false, - folderUid: resolved.folderUid, - sharedFolderUid: sharedFolder.uid, - folderKind: FolderKind.SharedFolder, - message: `shared_folder_update_v3 (grant) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): ${extractErrorMessage(err)}`, - results: userResults, - } + let encryptedKey: Uint8Array; + let encryptedKeyType: Folder.EncryptedKeyType; + if (publicKeys.rsaPublicKey) { + const rsaPublicKeyBase64 = platform.bytesToBase64( + publicKeys.rsaPublicKey, + ); + encryptedKey = platform.publicEncrypt( + sharedFolderKey, + rsaPublicKeyBase64, + ); + encryptedKeyType = Folder.EncryptedKeyType.encrypted_by_public_key; + } else if (publicKeys.eccPublicKey) { + encryptedKey = await platform.publicEncryptEC( + sharedFolderKey, + publicKeys.eccPublicKey, + ); + encryptedKeyType = Folder.EncryptedKeyType.encrypted_by_public_key_ecc; + } else { + userResults.push({ + email, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No usable public key for user "${email}" (folder="${resolved.displayName}")`, + }); + continue; } - const innerResponse = (response.sharedFoldersUpdateV3Response || [])[0] - const requestOk = !innerResponse?.status || innerResponse.status === FolderResultStatus.Success + usersToAdd.push({ + username: email, + manageRecords: toSetBoolean(newUserManageRecords), + manageUsers: toSetBoolean(newUserManageUsers), + typedSharedFolderKey: { encryptedKey, encryptedKeyType }, + }); + } - for (const addUserStatus of innerResponse?.sharedFolderAddUserStatus || []) { - const status = addUserStatus.status || ShareFolderUserResultStatus.Unknown - const success = status === FolderResultStatus.Success || status === FolderResultStatus.Invited + for (const teamUid of teamUids) { + if (existingTeams.has(teamUid)) { + if (hasPermissionChange) { + const existingTeam = existingTeamByUid.get(teamUid); + teamsToUpdate.push({ + teamUid: normal64Bytes(teamUid), + manageRecords: isBoolean(input.manageRecords) + ? input.manageRecords + : existingTeam?.manageRecords === true, + manageUsers: isBoolean(input.manageUsers) + ? input.manageUsers + : existingTeam?.manageUsers === true, + }); + } else { userResults.push({ - email: addUserStatus.username || '', - success, - status, - }) + email: teamUid, + success: true, + status: FolderResultStatus.Success, + }); + } + continue; } - for (const updateUserStatus of innerResponse?.sharedFolderUpdateUserStatus || []) { - const status = updateUserStatus.status || ShareFolderUserResultStatus.Unknown - userResults.push({ - email: updateUserStatus.username || '', - success: status === FolderResultStatus.Success, - status, - }) + + const teamKeys = teamKeysByUid.get(teamUid); + const typedSharedFolderKey = teamKeys + ? await encryptSharedFolderKeyForTeam(sharedFolderKey, teamKeys) + : undefined; + if (!typedSharedFolderKey) { + userResults.push({ + email: teamUid, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No usable team key for "${teamUid}" (folder="${resolved.displayName}")`, + }); + continue; } - const allUsersOk = userResults.length > 0 && userResults.every((userResult) => userResult.success) + teamsToAdd.push({ + teamUid: normal64Bytes(teamUid), + manageRecords: newTeamManageRecords, + manageUsers: newTeamManageUsers, + typedSharedFolderKey, + }); + } + + if ( + usersToAdd.length === 0 && + usersToUpdate.length === 0 && + teamsToAdd.length === 0 && + teamsToUpdate.length === 0 + ) { + const allOk = + userResults.length > 0 && + userResults.every((userResult) => userResult.success); + return { + success: allOk, + folderUid: resolved.folderUid, + sharedFolderUid: sharedFolder.uid, + folderKind: FolderKind.SharedFolder, + message: allOk + ? undefined + : `No users or teams could be processed for shared folder "${resolved.displayName}" (uid=${sharedFolder.uid}).`, + results: userResults, + }; + } - const failureReason = !requestOk - ? innerResponse?.status || - `shared_folder_update_v3 (grant) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): server returned no status` - : undefined + const updateRequest: Folder.ISharedFolderUpdateV3Request = { + sharedFolderUid: normal64Bytes(sharedFolder.uid), + revision: sharedFolder.revision, + forceUpdate: false, + }; + if (usersToAdd.length > 0) updateRequest.sharedFolderAddUser = usersToAdd; + if (usersToUpdate.length > 0) + updateRequest.sharedFolderUpdateUser = usersToUpdate; + if (teamsToAdd.length > 0) updateRequest.sharedFolderAddTeam = teamsToAdd; + if (teamsToUpdate.length > 0) + updateRequest.sharedFolderUpdateTeam = teamsToUpdate; + let response: Folder.ISharedFolderUpdateV3ResponseV2; + try { + response = await auth.executeRest( + sharedFolderUpdateV3Message({ sharedFoldersUpdateV3: [updateRequest] }), + ); + } catch (err) { return { - success: requestOk && allUsersOk, - folderUid: resolved.folderUid, - sharedFolderUid: sharedFolder.uid, - folderKind: FolderKind.SharedFolder, - message: failureReason, - results: userResults, + success: false, + folderUid: resolved.folderUid, + sharedFolderUid: sharedFolder.uid, + folderKind: FolderKind.SharedFolder, + message: `shared_folder_update_v3 (grant) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): ${extractErrorMessage(err)}`, + results: userResults, + }; + } + + const innerResponse = (response.sharedFoldersUpdateV3Response || [])[0]; + const requestOk = + !innerResponse?.status || + innerResponse.status === FolderResultStatus.Success; + + for (const addUserStatus of innerResponse?.sharedFolderAddUserStatus || []) { + const status = addUserStatus.status || ShareFolderUserResultStatus.Unknown; + const success = + status === FolderResultStatus.Success || + status === FolderResultStatus.Invited; + userResults.push({ + email: addUserStatus.username || "", + success, + status, + }); + } + for (const updateUserStatus of innerResponse?.sharedFolderUpdateUserStatus || + []) { + const status = + updateUserStatus.status || ShareFolderUserResultStatus.Unknown; + userResults.push({ + email: updateUserStatus.username || "", + success: status === FolderResultStatus.Success, + status, + }); + } + for (const addTeamStatus of innerResponse?.sharedFolderAddTeamStatus || []) { + const status = addTeamStatus.status || ShareFolderUserResultStatus.Unknown; + userResults.push({ + email: teamUidFromStatus(addTeamStatus.teamUid as Uint8Array), + success: status === FolderResultStatus.Success, + status, + }); + } + for (const updateTeamStatus of innerResponse?.sharedFolderUpdateTeamStatus || + []) { + const status = + updateTeamStatus.status || ShareFolderUserResultStatus.Unknown; + userResults.push({ + email: teamUidFromStatus(updateTeamStatus.teamUid as Uint8Array), + success: status === FolderResultStatus.Success, + status, + }); + } + + if (requestOk) { + const reported = new Set( + userResults.map((userResult) => (userResult.email || "").toLowerCase()), + ); + for (const user of [...usersToAdd, ...usersToUpdate]) { + const email = (user.username || "").trim(); + const key = email.toLowerCase(); + if (!key || reported.has(key)) continue; + userResults.push({ + email, + success: true, + status: FolderResultStatus.Success, + }); + reported.add(key); + } + for (const team of [...teamsToAdd, ...teamsToUpdate]) { + const email = teamUidFromStatus(team.teamUid as Uint8Array); + const key = email.toLowerCase(); + if (!key || reported.has(key)) continue; + userResults.push({ + email, + success: true, + status: FolderResultStatus.Success, + }); + reported.add(key); } + } + + const allUsersOk = + userResults.length === 0 + ? requestOk + : userResults.every((userResult) => userResult.success); + + const failureReason = !requestOk + ? innerResponse?.status || + `shared_folder_update_v3 (grant) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): server returned no status` + : undefined; + + return { + success: requestOk && allUsersOk, + folderUid: resolved.folderUid, + sharedFolderUid: sharedFolder.uid, + folderKind: FolderKind.SharedFolder, + message: failureReason, + results: userResults, + }; } export async function shareFolder( - auth: Auth, - storage: InMemoryStorage, - input: ShareFolderInput + auth: Auth, + storage: InMemoryStorage, + input: ShareFolderInput, ): Promise { - const resolved = resolveFolder(storage, input.folder) - if (!resolved) { - throw new KeeperSdkError(`Folder "${input.folder}" was not found.`, 'folder_not_found') + const resolved = resolveFolder(storage, input.folder); + if (!resolved) { + throw new KeeperSdkError( + `Folder "${input.folder}" was not found.`, + "folder_not_found", + ); + } + + if (resolved.kind === FolderKind.UserFolder) { + throw new KeeperSdkError( + `"${resolved.displayName}" is a personal folder. Only shared folders can be shared.`, + "not_a_shared_folder", + ); + } + + return shareWithSharedFolder(auth, storage, resolved, input); +} + +export type SharedFolderMembershipUserGrant = { + email: string; + manageUsers?: boolean; + manageRecords?: boolean; +}; + +export type SharedFolderMembershipTeamGrant = { + teamUid: string; + manageUsers?: boolean; + manageRecords?: boolean; +}; + +export type UpdateSharedFolderMembershipInput = { + sharedFolderUid: string; + addUsers?: SharedFolderMembershipUserGrant[]; + updateUsers?: SharedFolderMembershipUserGrant[]; + removeUsers?: string[]; + addTeams?: SharedFolderMembershipTeamGrant[]; + updateTeams?: SharedFolderMembershipTeamGrant[]; + removeTeams?: string[]; +}; + +export type SharedFolderMembershipUpdateResult = { + success: boolean; + message?: string; + sharedFolderUid: string; + results: ShareFolderUserStatus[]; +}; + +/** + * Low-level shared-folder membership primitive used by both `shareFolder` + * and `applyMembership`: a single `shared_folder_update_v3` request that can + * add, update, and remove users/teams (each with its own permission flags) + * in one round trip. + */ +export async function updateSharedFolderMembership( + auth: Auth, + storage: InMemoryStorage, + input: UpdateSharedFolderMembershipInput, +): Promise { + const sharedFolder = storage.getByUid( + FolderKind.SharedFolder, + input.sharedFolderUid, + ); + if (!sharedFolder) { + throw new KeeperSdkError( + `Shared folder "${input.sharedFolderUid}" not found.`, + "shared_folder_not_found", + ); + } + + const addUsers = input.addUsers || []; + const updateUsers = input.updateUsers || []; + const removeUsers = dedupeTargets(input.removeUsers || []); + const addTeams = input.addTeams || []; + const updateTeams = input.updateTeams || []; + const removeTeams = [ + ...new Set( + (input.removeTeams || []).map((uid) => uid.trim()).filter(Boolean), + ), + ]; + + const results: ShareFolderUserStatus[] = []; + + let sharedFolderKey: Uint8Array | undefined; + if (addUsers.length > 0 || addTeams.length > 0) { + sharedFolderKey = await storage.getKeyBytes(sharedFolder.uid); + if (!sharedFolderKey) { + throw new KeeperSdkError( + "Shared folder encryption key not available. Sync the vault and try again.", + "shared_folder_key_missing", + ); } + } - if (resolved.kind === FolderKind.UserFolder) { - throw new KeeperSdkError( - `"${resolved.displayName}" is a personal folder. Only shared folders can be shared.`, - 'not_a_shared_folder' - ) + const usersToAdd: Folder.ISharedFolderUpdateUser[] = []; + const usersToUpdate: Folder.ISharedFolderUpdateUser[] = []; + const teamsToAdd: Folder.ISharedFolderUpdateTeam[] = []; + const teamsToUpdate: Folder.ISharedFolderUpdateTeam[] = []; + + if (addUsers.length > 0) { + const usernameToKeys = await fetchUserPublicKeys( + auth, + addUsers.map((grant) => grant.email), + ); + for (const grant of addUsers) { + const publicKeys = usernameToKeys.get(grant.email.toLowerCase()); + if (!publicKeys) { + results.push({ + email: grant.email, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No public key returned for user "${grant.email}"`, + }); + continue; + } + if (publicKeys.errorCode) { + results.push({ + email: grant.email, + success: false, + status: publicKeys.errorCode, + message: publicKeys.message || publicKeys.errorCode, + }); + continue; + } + + let encryptedKey: Uint8Array; + let encryptedKeyType: Folder.EncryptedKeyType; + if (publicKeys.rsaPublicKey) { + encryptedKey = platform.publicEncrypt( + sharedFolderKey!, + platform.bytesToBase64(publicKeys.rsaPublicKey), + ); + encryptedKeyType = Folder.EncryptedKeyType.encrypted_by_public_key; + } else if (publicKeys.eccPublicKey) { + encryptedKey = await platform.publicEncryptEC( + sharedFolderKey!, + publicKeys.eccPublicKey, + ); + encryptedKeyType = Folder.EncryptedKeyType.encrypted_by_public_key_ecc; + } else { + results.push({ + email: grant.email, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No usable public key for user "${grant.email}"`, + }); + continue; + } + + usersToAdd.push({ + username: grant.email, + manageRecords: toSetBoolean(grant.manageRecords), + manageUsers: toSetBoolean(grant.manageUsers), + typedSharedFolderKey: { encryptedKey, encryptedKeyType }, + }); + } + } + + for (const grant of updateUsers) { + usersToUpdate.push({ + username: grant.email, + manageRecords: toSetBoolean(grant.manageRecords), + manageUsers: toSetBoolean(grant.manageUsers), + }); + } + + if (addTeams.length > 0) { + const teamKeysByUid = await loadTeamKeys( + auth, + addTeams.map((grant) => grant.teamUid), + ); + for (const grant of addTeams) { + const teamKeys = teamKeysByUid.get(grant.teamUid); + const typedSharedFolderKey = teamKeys + ? await encryptSharedFolderKeyForTeam(sharedFolderKey!, teamKeys) + : undefined; + if (!typedSharedFolderKey) { + results.push({ + email: grant.teamUid, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No usable team key for "${grant.teamUid}"`, + }); + continue; + } + teamsToAdd.push({ + teamUid: normal64Bytes(grant.teamUid), + manageRecords: grant.manageRecords === true, + manageUsers: grant.manageUsers === true, + typedSharedFolderKey, + }); } + } + + for (const grant of updateTeams) { + teamsToUpdate.push({ + teamUid: normal64Bytes(grant.teamUid), + manageRecords: grant.manageRecords === true, + manageUsers: grant.manageUsers === true, + }); + } + + const hasServerChange = + usersToAdd.length > 0 || + usersToUpdate.length > 0 || + removeUsers.length > 0 || + teamsToAdd.length > 0 || + teamsToUpdate.length > 0 || + removeTeams.length > 0; + + if (!hasServerChange) { + const allOk = + results.length === 0 || results.every((result) => result.success); + return { success: allOk, sharedFolderUid: sharedFolder.uid, results }; + } + + const updateRequest: Folder.ISharedFolderUpdateV3Request = { + sharedFolderUid: normal64Bytes(sharedFolder.uid), + revision: sharedFolder.revision, + forceUpdate: false, + }; + if (usersToAdd.length > 0) updateRequest.sharedFolderAddUser = usersToAdd; + if (usersToUpdate.length > 0) + updateRequest.sharedFolderUpdateUser = usersToUpdate; + if (removeUsers.length > 0) + updateRequest.sharedFolderRemoveUser = removeUsers; + if (teamsToAdd.length > 0) updateRequest.sharedFolderAddTeam = teamsToAdd; + if (teamsToUpdate.length > 0) + updateRequest.sharedFolderUpdateTeam = teamsToUpdate; + if (removeTeams.length > 0) { + updateRequest.sharedFolderRemoveTeam = removeTeams.map((teamUid) => + normal64Bytes(teamUid), + ); + } + + let response: Folder.ISharedFolderUpdateV3ResponseV2; + try { + response = await auth.executeRest( + sharedFolderUpdateV3Message({ sharedFoldersUpdateV3: [updateRequest] }), + ); + } catch (err) { + return { + success: false, + sharedFolderUid: sharedFolder.uid, + message: `shared_folder_update_v3 failed for shared folder (uid=${sharedFolder.uid}): ${extractErrorMessage(err)}`, + results, + }; + } + + const innerResponse = (response.sharedFoldersUpdateV3Response || [])[0]; + const requestOk = + !innerResponse?.status || + innerResponse.status === FolderResultStatus.Success; + + for (const status of innerResponse?.sharedFolderAddUserStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown; + results.push({ + email: status.username || "", + success: + value === FolderResultStatus.Success || + value === FolderResultStatus.Invited, + status: value, + }); + } + for (const status of innerResponse?.sharedFolderUpdateUserStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown; + results.push({ + email: status.username || "", + success: value === FolderResultStatus.Success, + status: value, + }); + } + for (const status of innerResponse?.sharedFolderRemoveUserStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown; + results.push({ + email: status.username || "", + success: value === FolderResultStatus.Success, + status: value, + }); + } + for (const status of innerResponse?.sharedFolderAddTeamStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown; + results.push({ + email: teamUidFromStatus(status.teamUid as Uint8Array), + success: value === FolderResultStatus.Success, + status: value, + }); + } + for (const status of innerResponse?.sharedFolderUpdateTeamStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown; + results.push({ + email: teamUidFromStatus(status.teamUid as Uint8Array), + success: value === FolderResultStatus.Success, + status: value, + }); + } + for (const status of innerResponse?.sharedFolderRemoveTeamStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown; + results.push({ + email: teamUidFromStatus(status.teamUid as Uint8Array), + success: value === FolderResultStatus.Success, + status: value, + }); + } + + if (requestOk) { + const reported = new Set( + results.map((result) => (result.email || "").toLowerCase()), + ); + for (const user of [...usersToAdd, ...usersToUpdate]) { + const email = (user.username || "").trim(); + const key = email.toLowerCase(); + if (!key || reported.has(key)) continue; + results.push({ + email, + success: true, + status: FolderResultStatus.Success, + }); + reported.add(key); + } + for (const team of [...teamsToAdd, ...teamsToUpdate]) { + const email = teamUidFromStatus(team.teamUid as Uint8Array); + const key = email.toLowerCase(); + if (!key || reported.has(key)) continue; + results.push({ + email, + success: true, + status: FolderResultStatus.Success, + }); + reported.add(key); + } + for (const email of removeUsers) { + const key = email.toLowerCase(); + if (!key || reported.has(key)) continue; + results.push({ + email, + success: true, + status: FolderResultStatus.Success, + }); + reported.add(key); + } + for (const teamUid of removeTeams) { + const key = teamUid.toLowerCase(); + if (!key || reported.has(key)) continue; + results.push({ + email: teamUid, + success: true, + status: FolderResultStatus.Success, + }); + reported.add(key); + } + } + + const allOk = + results.length === 0 + ? requestOk + : results.every((result) => result.success); + + const failureReason = !requestOk + ? innerResponse?.status || + `shared_folder_update_v3 failed for shared folder (uid=${sharedFolder.uid}): server returned no status` + : undefined; - return shareWithSharedFolder(auth, storage, resolved, input) + return { + success: requestOk && allOk, + sharedFolderUid: sharedFolder.uid, + message: failureReason, + results, + }; } diff --git a/KeeperSdk/src/sharing/Sharing.ts b/KeeperSdk/src/sharing/Sharing.ts index 9046f369..2cbe1f1b 100644 --- a/KeeperSdk/src/sharing/Sharing.ts +++ b/KeeperSdk/src/sharing/Sharing.ts @@ -8,6 +8,9 @@ import { webSafe64FromBytes, recordsShareUpdateMessage, normal64Bytes, + sendShareInviteMessage, + record, + Folder, } from '@keeper-security/keeperapi' import { extractErrorMessage, KeeperSdkError } from '../utils/errors' @@ -47,14 +50,15 @@ export type RemoveShareResult = { message: string } -type UserKeys = { +export type UserShareKeys = { username: string + accountUid?: Uint8Array rsaPublicKey: Uint8Array | null eccPublicKey: Uint8Array | null errorCode: string | null } -async function loadUserPublicKey(auth: Auth, email: string): Promise { +export async function loadUserShareKeys(auth: Auth, email: string): Promise { const msg = getPublicKeysMessage({ usernames: [email] }) let response: Authentication.IGetPublicKeysResponse @@ -79,12 +83,128 @@ async function loadUserPublicKey(auth: Auth, email: string): Promise { return { username: entry.username || email, + accountUid: entry.accountUid?.length ? (entry.accountUid as Uint8Array) : undefined, rsaPublicKey: entry.publicKey && entry.publicKey.length > 0 ? (entry.publicKey as Uint8Array) : null, eccPublicKey: entry.publicEccKey && entry.publicEccKey.length > 0 ? (entry.publicEccKey as Uint8Array) : null, errorCode: entry.errorCode || null, } } +export async function encryptKeyForRecipient( + key: Uint8Array, + userKeys: UserShareKeys +): Promise<{ encryptedKey: Uint8Array; useEccKey: boolean }> { + if (userKeys.eccPublicKey) { + return { + encryptedKey: await platform.publicEncryptEC(key, userKeys.eccPublicKey), + useEccKey: true, + } + } + if (userKeys.rsaPublicKey) { + return { + encryptedKey: platform.publicEncrypt(key, platform.bytesToBase64(userKeys.rsaPublicKey)), + useEccKey: false, + } + } + throw new KeeperSdkError( + `No usable public key available for ${userKeys.username}`, + ShareStatus.MissingPublicKey + ) +} + +export async function sendShareInviteIfNeeded(auth: Auth, email: string): Promise { + await auth.executeRestAction( + sendShareInviteMessage(Authentication.SendShareInviteRequest.create({ email })) + ) +} + +export async function loadUserShareKeysOrInvite( + auth: Auth, + email: string, + errorCode: string = ShareStatus.MissingPublicKey +): Promise { + const userKeys = await loadUserShareKeys(auth, email) + if (!userKeys.rsaPublicKey && !userKeys.eccPublicKey) { + await sendShareInviteIfNeeded(auth, email) + throw new KeeperSdkError( + `User '${email}' has no public key. Share invitation sent.`, + errorCode + ) + } + if (!userKeys.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode) + } + return { ...userKeys, accountUid: userKeys.accountUid } +} + +export function parseRecordSharingStatus( + status: record.v3.sharing.IStatus | null | undefined +): { recordUid: string; success: boolean; message: string } { + if (!status?.recordUid?.length) { + return { recordUid: '', success: false, message: 'No status returned' } + } + const recordUid = webSafe64FromBytes(status.recordUid) + const sharingStatus = status.status ?? record.v3.sharing.SharingStatus.SUCCESS + const statusName = record.v3.sharing.SharingStatus[sharingStatus] ?? String(sharingStatus) + const success = + sharingStatus === record.v3.sharing.SharingStatus.SUCCESS || + sharingStatus === record.v3.sharing.SharingStatus.PENDING_ACCEPT + return { recordUid, success, message: status.message || statusName } +} + +export async function buildNsfRecordSharePermission( + auth: Auth, + recordUid: string, + recordKey: Uint8Array, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, + errorCode: string = ShareStatus.MissingPublicKey +): Promise { + const userKeys = await loadUserShareKeysOrInvite(auth, email, errorCode) + const { encryptedKey, useEccKey } = await encryptKeyForRecipient(recordKey, userKeys) + const recordUidBytes = normal64Bytes(recordUid) + const rules: Folder.IRecordAccessData = { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + owner: false, + accessRoleType, + } + if (expirationTimestamp != null) { + rules.tlaProperties = { expiration: expirationTimestamp } + } + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + recordKey: encryptedKey, + useEccKey, + rules, + } +} + +export async function buildNsfRecordRevokePermission( + auth: Auth, + recordUid: string, + email: string, + errorCode: string = ShareStatus.MissingPublicKey +): Promise { + const userKeys = await loadUserShareKeys(auth, email) + if (!userKeys.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode) + } + const recordUidBytes = normal64Bytes(recordUid) + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + rules: { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + }, + } +} + export async function shareRecord( auth: Auth, recordKey: Uint8Array, @@ -92,7 +212,7 @@ export async function shareRecord( ): Promise { const { recordUid, email, canEdit = false, canShare = false } = input - const userKeys = await loadUserPublicKey(auth, email) + const userKeys = await loadUserShareKeys(auth, email) let encryptedRecordKey: Uint8Array let useEccKey = false diff --git a/KeeperSdk/src/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 4ba42312..24eb7450 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -1,167 +1,184 @@ import type { - VaultStorage, - VaultStorageData, - VaultStorageKind, - VaultStorageResult, - Dependency, - Dependencies, - RemovedDependencies, - DRecord, -} from '@keeper-security/keeperapi' -import { webSafe64FromBytes } from '@keeper-security/keeperapi' -import { VaultObjectKind } from '../folders/folderHelpers' + VaultStorage, + VaultStorageData, + VaultStorageKind, + VaultStorageResult, + Dependency, + Dependencies, + RemovedDependencies, + DRecord, +} from "@keeper-security/keeperapi"; +import { webSafe64FromBytes } from "@keeper-security/keeperapi"; +import { VaultObjectKind } from "../folders/folderHelpers"; export class InMemoryStorage implements VaultStorage { - private keys = new Map() - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- KeyStorage.saveObject is unconstrained - private objects = new Map() - private store = new Map>() - private dependenciesByParent = new Map() - private arrayCache = new Map() - - public async getKeyBytes(keyId: string): Promise { - return this.keys.get(keyId) - } - - public async saveKeyBytes(keyId: string, key: Uint8Array): Promise { - this.keys.set(keyId, key) - } - - public async getObject(key: string): Promise { - return this.objects.get(key) as T | undefined - } - - public async saveObject(key: string, value: T): Promise { - this.objects.set(key, value) - } - - public async put(item: VaultStorageData): Promise { - const kind = item.kind - if (!this.store.has(kind)) { - this.store.set(kind, new Map()) + private keys = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- KeyStorage.saveObject is unconstrained + private objects = new Map(); + private store = new Map>(); + private dependenciesByParent = new Map(); + private arrayCache = new Map(); + + public async getKeyBytes(keyId: string): Promise { + return this.keys.get(keyId); + } + + public async saveKeyBytes(keyId: string, key: Uint8Array): Promise { + this.keys.set(keyId, key); + } + + public async getObject(key: string): Promise { + return this.objects.get(key) as T | undefined; + } + + public async saveObject(key: string, value: T): Promise { + this.objects.set(key, value); + } + + public async put(item: VaultStorageData): Promise { + const kind = item.kind; + if (!kind) { + throw new Error("VaultStorageData missing kind"); + } + if (!this.store.has(kind)) { + this.store.set(kind, new Map()); + } + const uid = this.extractUid(item); + this.store.get(kind)!.set(uid, item); + this.arrayCache.delete(kind); + } + + public async get( + kind: T, + uid?: string, + ): Promise> { + const kindMap = this.store.get(kind); + if (!kindMap) return undefined as VaultStorageResult; + + if (uid) { + return kindMap.get(uid) as VaultStorageResult; + } + const first = kindMap.values().next(); + return (first.done ? undefined : first.value) as VaultStorageResult; + } + + public async delete( + kind: VaultStorageKind, + uid: string | Uint8Array, + ): Promise { + const uidStr = typeof uid === "string" ? uid : webSafe64FromBytes(uid); + this.store.get(kind)?.delete(uidStr); + this.arrayCache.delete(kind); + } + + public async clear(): Promise { + this.store.clear(); + this.keys.clear(); + this.objects.clear(); + this.dependenciesByParent.clear(); + this.arrayCache.clear(); + } + + public async getDependencies(uid: string): Promise { + return this.dependenciesByParent.get(uid); + } + + public async addDependencies(dependencies: Dependencies): Promise { + for (const [parentUid, children] of Object.entries(dependencies)) { + if (!this.dependenciesByParent.has(parentUid)) { + this.dependenciesByParent.set(parentUid, []); + } + const existing = this.dependenciesByParent.get(parentUid)!; + const seenChildUids = new Set( + existing.map((dependency) => dependency.uid), + ); + for (const child of children) { + if (!seenChildUids.has(child.uid)) { + existing.push(child); + seenChildUids.add(child.uid); } - const uid = this.extractUid(item) - this.store.get(kind)!.set(uid, item) - this.arrayCache.delete(kind) - } - - public async get(kind: T, uid?: string): Promise> { - const kindMap = this.store.get(kind) - if (!kindMap) return undefined as VaultStorageResult - - if (uid) { - return kindMap.get(uid) as VaultStorageResult + } + } + } + + public async removeDependencies( + dependencies: RemovedDependencies, + ): Promise { + for (const [parentUid, children] of Object.entries(dependencies)) { + if (children === "*") { + this.dependenciesByParent.delete(parentUid); + } else { + const existing = this.dependenciesByParent.get(parentUid); + if (existing) { + const removeSet = children as Set; + this.dependenciesByParent.set( + parentUid, + existing.filter((dependency) => !removeSet.has(dependency.uid)), + ); } - const first = kindMap.values().next() - return (first.done ? undefined : first.value) as VaultStorageResult - } - - public async delete(kind: VaultStorageKind, uid: string | Uint8Array): Promise { - const uidStr = typeof uid === 'string' ? uid : webSafe64FromBytes(uid) - this.store.get(kind)?.delete(uidStr) - this.arrayCache.delete(kind) - } - - public async clear(): Promise { - this.store.clear() - this.keys.clear() - this.objects.clear() - this.dependenciesByParent.clear() - this.arrayCache.clear() - } - - public async getDependencies(uid: string): Promise { - return this.dependenciesByParent.get(uid) - } - - public async addDependencies(dependencies: Dependencies): Promise { - for (const [parentUid, children] of Object.entries(dependencies)) { - if (!this.dependenciesByParent.has(parentUid)) { - this.dependenciesByParent.set(parentUid, []) - } - const existing = this.dependenciesByParent.get(parentUid)! - const seenChildUids = new Set(existing.map((dependency) => dependency.uid)) - for (const child of children) { - if (!seenChildUids.has(child.uid)) { - existing.push(child) - seenChildUids.add(child.uid) - } - } - } - } - - public async removeDependencies(dependencies: RemovedDependencies): Promise { - for (const [parentUid, children] of Object.entries(dependencies)) { - if (children === '*') { - this.dependenciesByParent.delete(parentUid) - } else { - const existing = this.dependenciesByParent.get(parentUid) - if (existing) { - const removeSet = children as Set - this.dependenciesByParent.set( - parentUid, - existing.filter((dependency) => !removeSet.has(dependency.uid)) - ) - } - } - } - } - - public getAll(kind: VaultStorageKind): T[] { - const cached = this.arrayCache.get(kind) - if (cached) return cached as T[] - - const kindMap = this.store.get(kind) - if (!kindMap) return [] - - const arr = Array.from(kindMap.values()) - this.arrayCache.set(kind, arr) - return arr as T[] - } - - public getRecords(): DRecord[] { - return this.getAll(VaultObjectKind.Record) - } - - public getByUid(kind: VaultStorageKind, uid: string): T | undefined { - return this.store.get(kind)?.get(uid) as T | undefined - } - - public getCount(kind: VaultStorageKind): number { - return this.store.get(kind)?.size ?? 0 - } - - private extractUid(item: VaultStorageData): string { - const record = item as VaultStorageData & { - uid?: string - token?: string - sharedFolderUid?: string - recordUid?: string - folderUid?: string - accountUid?: string | Uint8Array - teamUid?: string - } - const accountUidStr = - typeof record.accountUid === 'string' - ? record.accountUid - : record.accountUid instanceof Uint8Array - ? webSafe64FromBytes(record.accountUid) - : undefined - if (record.uid) return record.uid - if (record.token) return record.token - if (record.sharedFolderUid && record.recordUid) { - return `${record.sharedFolderUid}:${record.recordUid}` - } - if (record.sharedFolderUid && accountUidStr) { - return `${record.sharedFolderUid}:${accountUidStr}` - } - if (record.sharedFolderUid && record.teamUid) { - return `${record.sharedFolderUid}:${record.teamUid}` - } - if (record.folderUid && record.recordUid) { - return `${record.folderUid}:${record.recordUid}` - } - if (item.kind === VaultObjectKind.User && accountUidStr) return accountUidStr - return '_singleton_' - } + } + } + } + + public getAll(kind: VaultStorageKind): T[] { + const cached = this.arrayCache.get(kind); + if (cached) return cached as T[]; + + const kindMap = this.store.get(kind); + if (!kindMap) return []; + + const arr = Array.from(kindMap.values()); + this.arrayCache.set(kind, arr); + return arr as T[]; + } + + public getRecords(): DRecord[] { + return this.getAll(VaultObjectKind.Record); + } + + public getByUid( + kind: VaultStorageKind, + uid: string, + ): T | undefined { + return this.store.get(kind)?.get(uid) as T | undefined; + } + + public getCount(kind: VaultStorageKind): number { + return this.store.get(kind)?.size ?? 0; + } + + private extractUid(item: VaultStorageData): string { + const record = item as VaultStorageData & { + uid?: string; + token?: string; + sharedFolderUid?: string; + recordUid?: string; + folderUid?: string; + accountUid?: string | Uint8Array; + teamUid?: string; + }; + const accountUidStr = + typeof record.accountUid === "string" + ? record.accountUid + : record.accountUid instanceof Uint8Array + ? webSafe64FromBytes(record.accountUid) + : undefined; + if (record.uid) return record.uid; + if (record.token) return record.token; + if (record.sharedFolderUid && record.recordUid) { + return `${record.sharedFolderUid}:${record.recordUid}`; + } + if (record.sharedFolderUid && accountUidStr) { + return `${record.sharedFolderUid}:${accountUidStr}`; + } + if (record.sharedFolderUid && record.teamUid) { + return `${record.sharedFolderUid}:${record.teamUid}`; + } + if (record.folderUid && record.recordUid) { + return `${record.folderUid}:${record.recordUid}`; + } + if (item.kind === VaultObjectKind.User && accountUidStr) + return accountUidStr; + return "_singleton_"; + } } diff --git a/KeeperSdk/src/teams/TeamManager.ts b/KeeperSdk/src/teams/TeamManager.ts index 4b8ad230..8c759dbd 100644 --- a/KeeperSdk/src/teams/TeamManager.ts +++ b/KeeperSdk/src/teams/TeamManager.ts @@ -1,125 +1,154 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' +import type { Auth } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; import { - formatTeamsTable, - listTeams, - renderTeamsAsciiTable, - type FormatTeamsTableOptions, - type FormattedTeamsTable, - type ListTeamRow, - type ListTeamsOptions, -} from './listTeams' + formatTeamsTable, + listTeams, + renderTeamsAsciiTable, + type FormatTeamsTableOptions, + type FormattedTeamsTable, + type ListTeamRow, + type ListTeamsOptions, +} from "./listTeams"; import { - formatTeamView, - teamViewTable, - viewTeam, - type FormatTeamViewOptions, - type FormattedTeamViewTable, - type TeamView, -} from './viewTeam' + formatTeamView, + teamViewTable, + viewTeam, + type FormatTeamViewOptions, + type FormattedTeamViewTable, + type TeamView, +} from "./viewTeam"; import { - addTeams, - formatAddTeamResult, - renderAddTeamAsciiTable, - type AddTeamInput, - type AddTeamResult, - type FormatAddTeamResultOptions, - type FormattedAddTeamTable, -} from './addTeam' + addTeams, + formatAddTeamResult, + renderAddTeamAsciiTable, + type AddTeamInput, + type AddTeamResult, + type FormatAddTeamResultOptions, + type FormattedAddTeamTable, +} from "./addTeam"; import { - formatUpdateTeamResult, - renderUpdateTeamAsciiTable, - updateTeams, - type FormattedUpdateTeamTable, - type UpdateTeamInput, - type UpdateTeamResult, -} from './updateTeam' + formatUpdateTeamResult, + renderUpdateTeamAsciiTable, + updateTeams, + type FormattedUpdateTeamTable, + type UpdateTeamInput, + type UpdateTeamResult, +} from "./updateTeam"; import { - deleteTeams, - formatDeleteTeamResult, - renderDeleteTeamAsciiTable, - type DeleteTeamInput, - type DeleteTeamResult, - type FormattedDeleteTeamTable, -} from './deleteTeam' + deleteTeams, + formatDeleteTeamResult, + renderDeleteTeamAsciiTable, + type DeleteTeamInput, + type DeleteTeamResult, + type FormattedDeleteTeamTable, +} from "./deleteTeam"; +import { + changeTeamRoles, + type ChangeTeamRolesInput, + type TeamRoleResult, +} from "./teamRole"; -export type AuthProvider = () => Auth +export type AuthProvider = () => Auth; export class TeamManager { - private readonly authProvider: AuthProvider - - constructor(authProvider: AuthProvider) { - this.authProvider = authProvider - } - - public async listTeams(options: ListTeamsOptions = {}): Promise { - return listTeams(this.requireAuth(), options) - } - - public formatTeamsTable(rows: ListTeamRow[], options: FormatTeamsTableOptions = {}): FormattedTeamsTable { - return formatTeamsTable(rows, options) - } - - public renderTeamsAsciiTable(table: FormattedTeamsTable, options: { minColWidth?: number } = {}): string { - return renderTeamsAsciiTable(table, options) - } - - public async viewTeam(identifier: string): Promise { - return viewTeam(this.requireAuth(), identifier) - } - - public formatTeamView(view: TeamView, options: FormatTeamViewOptions = {}): FormattedTeamViewTable { - return formatTeamView(view, options) - } - - public teamViewTable(table: FormattedTeamViewTable): string { - return teamViewTable(table) - } - - public async addTeams(input: AddTeamInput): Promise { - return addTeams(this.requireAuth(), input) - } - - public formatAddTeamResult( - result: AddTeamResult, - options: FormatAddTeamResultOptions = {} - ): FormattedAddTeamTable { - return formatAddTeamResult(result, options) - } - - public renderAddTeamAsciiTable(table: FormattedAddTeamTable): string { - return renderAddTeamAsciiTable(table) - } - - public async updateTeams(input: UpdateTeamInput): Promise { - return updateTeams(this.requireAuth(), input) - } - - public formatUpdateTeamResult(result: UpdateTeamResult): FormattedUpdateTeamTable { - return formatUpdateTeamResult(result) - } - - public renderUpdateTeamAsciiTable(table: FormattedUpdateTeamTable): string { - return renderUpdateTeamAsciiTable(table) - } - - public async deleteTeams(input: DeleteTeamInput): Promise { - return deleteTeams(this.requireAuth(), input) - } - - public formatDeleteTeamResult(result: DeleteTeamResult): FormattedDeleteTeamTable { - return formatDeleteTeamResult(result) - } - - public renderDeleteTeamAsciiTable(table: FormattedDeleteTeamTable): string { - return renderDeleteTeamAsciiTable(table) - } - - private requireAuth(): Auth { - const auth = this.authProvider() - if (!auth) { - throw new KeeperSdkError('You are not logged in. Please log in first.', ResultCodes.NOT_LOGGED_IN) - } - return auth - } + private readonly authProvider: AuthProvider; + + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider; + } + + public async listTeams( + options: ListTeamsOptions = {}, + ): Promise { + return listTeams(this.requireAuth(), options); + } + + public formatTeamsTable( + rows: ListTeamRow[], + options: FormatTeamsTableOptions = {}, + ): FormattedTeamsTable { + return formatTeamsTable(rows, options); + } + + public renderTeamsAsciiTable( + table: FormattedTeamsTable, + options: { minColWidth?: number } = {}, + ): string { + return renderTeamsAsciiTable(table, options); + } + + public async viewTeam(identifier: string): Promise { + return viewTeam(this.requireAuth(), identifier); + } + + public formatTeamView( + view: TeamView, + options: FormatTeamViewOptions = {}, + ): FormattedTeamViewTable { + return formatTeamView(view, options); + } + + public teamViewTable(table: FormattedTeamViewTable): string { + return teamViewTable(table); + } + + public async addTeams(input: AddTeamInput): Promise { + return addTeams(this.requireAuth(), input); + } + + public formatAddTeamResult( + result: AddTeamResult, + options: FormatAddTeamResultOptions = {}, + ): FormattedAddTeamTable { + return formatAddTeamResult(result, options); + } + + public renderAddTeamAsciiTable(table: FormattedAddTeamTable): string { + return renderAddTeamAsciiTable(table); + } + + public async updateTeams(input: UpdateTeamInput): Promise { + return updateTeams(this.requireAuth(), input); + } + + public formatUpdateTeamResult( + result: UpdateTeamResult, + ): FormattedUpdateTeamTable { + return formatUpdateTeamResult(result); + } + + public renderUpdateTeamAsciiTable(table: FormattedUpdateTeamTable): string { + return renderUpdateTeamAsciiTable(table); + } + + public async deleteTeams(input: DeleteTeamInput): Promise { + return deleteTeams(this.requireAuth(), input); + } + + public formatDeleteTeamResult( + result: DeleteTeamResult, + ): FormattedDeleteTeamTable { + return formatDeleteTeamResult(result); + } + + public renderDeleteTeamAsciiTable(table: FormattedDeleteTeamTable): string { + return renderDeleteTeamAsciiTable(table); + } + + public async changeTeamRoles( + input: ChangeTeamRolesInput, + ): Promise { + return changeTeamRoles(this.requireAuth(), input); + } + + private requireAuth(): Auth { + const auth = this.authProvider(); + if (!auth) { + throw new KeeperSdkError( + "You are not logged in. Please log in first.", + ResultCodes.NOT_LOGGED_IN, + ); + } + return auth; + } } diff --git a/KeeperSdk/src/teams/addTeam.ts b/KeeperSdk/src/teams/addTeam.ts index 732c7912..39b9b7b8 100644 --- a/KeeperSdk/src/teams/addTeam.ts +++ b/KeeperSdk/src/teams/addTeam.ts @@ -1,490 +1,525 @@ import { - encryptForStorage, - encryptKey, - generateEncryptionKey, - generateUid, - platform, - webSafe64FromBytes, - type Auth, - type KeeperResponse, - type RestCommand, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' + encryptForStorage, + encryptKey, + generateEncryptionKey, + generateUid, + platform, + webSafe64FromBytes, + type Auth, + type KeeperResponse, + type RestCommand, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseNode, - type EnterpriseQueuedTeamRecord, - type EnterpriseTeamRecord, -} from './enterpriseData' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseQueuedTeamRecord, + type EnterpriseTeamRecord, +} from "./enterpriseData"; import { - applyDecryptedNodeNames, - applyEnterpriseNameToRoot, - NODE_PATH_SEPARATOR, - parentNeedsNameLookup, - resolveParentNode, - TEAM_TABLE_HEADERS, - validateTeamName, -} from './teamUtils' - -const TEAM_ADD_COMMAND = 'team_add' + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + NODE_PATH_SEPARATOR, + parentNeedsNameLookup, + resolveParentNode, + TEAM_TABLE_HEADERS, + validateTeamName, +} from "./teamUtils"; + +const TEAM_ADD_COMMAND = "team_add"; const ADD_TEAM_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Nodes, - EnterpriseDataInclude.Teams, - EnterpriseDataInclude.QueuedTeams, -] + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, +]; export enum TeamRestriction { - On = 'on', - Off = 'off', + On = "on", + Off = "off", } -export type TeamRestrictionInput = TeamRestriction | `${TeamRestriction}` | null | undefined +export type TeamRestrictionInput = + | TeamRestriction + | `${TeamRestriction}` + | null + | undefined; export enum AddTeamStatus { - Created = 'created', - Skipped = 'skipped', - Failed = 'failed', + Created = "created", + Skipped = "skipped", + Failed = "failed", } export enum AddTeamSkipReason { - AlreadyExistsInParent = 'already_exists_in_parent', - ExistsElsewhereDeclined = 'exists_elsewhere_declined', + AlreadyExistsInParent = "already_exists_in_parent", + ExistsElsewhereDeclined = "exists_elsewhere_declined", } export enum AddTeamSourceKind { - NewName = 'new_name', - QueuedTeam = 'queued_team', + NewName = "new_name", + QueuedTeam = "queued_team", } export type AddTeamConflictPrompt = { - teamName: string - parentNodeId: number - parentNodeName: string - existingTeamUid: string - existingTeamName: string - existingNodeId: number - existingNodeName: string -} - -export type AddTeamConfirm = (prompt: AddTeamConflictPrompt) => boolean | Promise + teamName: string; + parentNodeId: number; + parentNodeName: string; + existingTeamUid: string; + existingTeamName: string; + existingNodeId: number; + existingNodeName: string; +}; + +export type AddTeamConfirm = ( + prompt: AddTeamConflictPrompt, +) => boolean | Promise; export type AddTeamInput = { - teams: string[] - parent?: string | number | null - restrictEdit?: TeamRestrictionInput - restrictShare?: TeamRestrictionInput - restrictView?: TeamRestrictionInput - force?: boolean - confirm?: AddTeamConfirm -} + teams: string[]; + parent?: string | number | null; + restrictEdit?: TeamRestrictionInput; + restrictShare?: TeamRestrictionInput; + restrictView?: TeamRestrictionInput; + force?: boolean; + confirm?: AddTeamConfirm; +}; export type AddTeamItemResult = { - teamUid: string - teamName: string - nodeId: number - source: AddTeamSourceKind - status: AddTeamStatus - skipReason?: AddTeamSkipReason - message?: string -} + teamUid: string; + teamName: string; + nodeId: number; + source: AddTeamSourceKind; + status: AddTeamStatus; + skipReason?: AddTeamSkipReason; + message?: string; +}; export type AddTeamResult = { - success: boolean - parentNodeId: number - parentNodeName: string - items: AddTeamItemResult[] - created: number - skipped: number - failed: number -} + success: boolean; + parentNodeId: number; + parentNodeName: string; + items: AddTeamItemResult[]; + created: number; + skipped: number; + failed: number; +}; type ResolvedRequest = - | { kind: AddTeamSourceKind.NewName; teamUid: string; teamName: string } - | { - kind: AddTeamSourceKind.QueuedTeam - teamUid: string - teamName: string - queued: EnterpriseQueuedTeamRecord - } + | { kind: AddTeamSourceKind.NewName; teamUid: string; teamName: string } + | { + kind: AddTeamSourceKind.QueuedTeam; + teamUid: string; + teamName: string; + queued: EnterpriseQueuedTeamRecord; + }; type TeamAddRequestPayload = { - team_uid: string - team_name: string - node_id: number - public_key: string - private_key: string - team_key: string - encrypted_team_key: string - ecc_public_key: string - ecc_private_key: string - restrict_edit: boolean - restrict_share: boolean - restrict_view: boolean - manage_only: boolean -} - -export async function addTeams(auth: Auth, input: AddTeamInput): Promise { - const requestedNames = (input.teams || []) - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter((value) => value.length > 0) - - if (requestedNames.length === 0) { - throw new KeeperSdkError('No teams to add.', ResultCodes.NO_TEAMS_TO_ADD) + team_uid: string; + team_name: string; + node_id: number; + public_key: string; + private_key: string; + team_key: string; + encrypted_team_key: string; + ecc_public_key: string; + ecc_private_key: string; + restrict_edit: boolean; + restrict_share: boolean; + restrict_view: boolean; + manage_only: boolean; +}; + +export async function addTeams( + auth: Auth, + input: AddTeamInput, +): Promise { + const requestedNames = (input.teams || []) + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter((value) => value.length > 0); + + if (requestedNames.length === 0) { + throw new KeeperSdkError("No teams to add.", ResultCodes.NO_TEAMS_TO_ADD); + } + + for (const name of requestedNames) { + validateTeamName(name); + } + + const force = input.force === true; + const restrictEdit = resolveRestriction(input.restrictEdit); + const restrictShare = resolveRestriction(input.restrictShare); + const restrictView = resolveRestriction(input.restrictView); + + const needsNameLookup = parentNeedsNameLookup(input.parent ?? null); + + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(ADD_TEAM_INCLUDES); + const nodes = response.nodes || []; + if (needsNameLookup) { + const displayNames = await enterpriseData.getDisplayNames(); + applyDecryptedNodeNames(nodes, displayNames.nodes); + await enterpriseData.decryptNodeNames(nodes); + } + applyEnterpriseNameToRoot(nodes, response.enterprise_name); + + const parentNode = resolveParentNode(nodes, input.parent ?? null); + const parentNodeId = parentNode.node_id; + const parentNodeName = nodePathOrFallback(nodes, parentNode); + + const existingTeams = response.teams || []; + const queuedTeams = response.queued_teams || []; + const queuedByUid = buildQueuedByUid(queuedTeams); + const queuedByLowerName = buildQueuedByLowerName(queuedTeams); + const teamsByLowerName = buildTeamsByLowerName(existingTeams); + + const queuedRequests: ResolvedRequest[] = []; + const newRequests: ResolvedRequest[] = []; + const items: AddTeamItemResult[] = []; + const seenLowerNames = new Set(); + + for (const raw of requestedNames) { + const lower = raw.toLowerCase(); + if (seenLowerNames.has(lower)) continue; + seenLowerNames.add(lower); + + const queued = queuedByUid.get(raw) || queuedByLowerName.get(lower); + if (queued) { + queuedRequests.push({ + kind: AddTeamSourceKind.QueuedTeam, + teamUid: queued.team_uid, + teamName: queued.name || raw, + queued, + }); + continue; } - for (const name of requestedNames) { - validateTeamName(name) + const conflicts = teamsByLowerName.get(lower) || []; + const sameParent = conflicts.find((team) => team.node_id === parentNodeId); + if (sameParent) { + items.push({ + teamUid: sameParent.team_uid, + teamName: sameParent.name || raw, + nodeId: sameParent.node_id, + source: AddTeamSourceKind.NewName, + status: AddTeamStatus.Skipped, + skipReason: AddTeamSkipReason.AlreadyExistsInParent, + message: `Team "${sameParent.name || raw}" already exists in parent node ${parentNodeId}.`, + }); + continue; } - const force = input.force === true - const restrictEdit = resolveRestriction(input.restrictEdit) - const restrictShare = resolveRestriction(input.restrictShare) - const restrictView = resolveRestriction(input.restrictView) - - const needsNameLookup = parentNeedsNameLookup(input.parent ?? null) - - const enterpriseData = new EnterpriseDataManager(auth) - const response = await enterpriseData.getData(ADD_TEAM_INCLUDES) - const nodes = response.nodes || [] - if (needsNameLookup) { - const displayNames = await enterpriseData.getDisplayNames() - applyDecryptedNodeNames(nodes, displayNames.nodes) - await enterpriseData.decryptNodeNames(nodes) - } - applyEnterpriseNameToRoot(nodes, response.enterprise_name) - - const parentNode = resolveParentNode(nodes, input.parent ?? null) - const parentNodeId = parentNode.node_id - const parentNodeName = nodePathOrFallback(nodes, parentNode) - - const existingTeams = response.teams || [] - const queuedTeams = response.queued_teams || [] - const queuedByUid = buildQueuedByUid(queuedTeams) - const queuedByLowerName = buildQueuedByLowerName(queuedTeams) - const teamsByLowerName = buildTeamsByLowerName(existingTeams) - - const queuedRequests: ResolvedRequest[] = [] - const newRequests: ResolvedRequest[] = [] - const items: AddTeamItemResult[] = [] - const seenLowerNames = new Set() - - for (const raw of requestedNames) { - const lower = raw.toLowerCase() - if (seenLowerNames.has(lower)) continue - seenLowerNames.add(lower) - - const queued = queuedByUid.get(raw) || queuedByLowerName.get(lower) - if (queued) { - queuedRequests.push({ - kind: AddTeamSourceKind.QueuedTeam, - teamUid: queued.team_uid, - teamName: queued.name || raw, - queued, - }) - continue - } - - const conflicts = teamsByLowerName.get(lower) || [] - const sameParent = conflicts.find((team) => team.node_id === parentNodeId) - if (sameParent) { - items.push({ - teamUid: sameParent.team_uid, - teamName: sameParent.name || raw, - nodeId: sameParent.node_id, - source: AddTeamSourceKind.NewName, - status: AddTeamStatus.Skipped, - skipReason: AddTeamSkipReason.AlreadyExistsInParent, - message: `Team "${sameParent.name || raw}" already exists in parent node ${parentNodeId}.`, - }) - continue - } - - if (conflicts.length > 0 && !force) { - const confirmed = await confirmCrossNode(input.confirm, conflicts[0], { - teamName: raw, - parentNodeId, - parentNodeName, - existingNodeName: nodePathById(nodes, conflicts[0].node_id), - }) - if (!confirmed) { - items.push({ - teamUid: conflicts[0].team_uid, - teamName: raw, - nodeId: conflicts[0].node_id, - source: AddTeamSourceKind.NewName, - status: AddTeamStatus.Skipped, - skipReason: AddTeamSkipReason.ExistsElsewhereDeclined, - message: `Team "${raw}" already exists in node ${conflicts[0].node_id}; creation declined.`, - }) - continue - } - } - - newRequests.push({ - kind: AddTeamSourceKind.NewName, - teamUid: generateUid(), - teamName: raw, - }) - } - - const planned = [...queuedRequests, ...newRequests] - if (planned.length === 0) { - return finalizeResult(items, parentNodeId, parentNodeName) - } - - const treeKey = await enterpriseData.getTreeKey() - if (!treeKey) { - throw new KeeperSdkError( - 'Enterprise tree key is unavailable. The current user may not have permission to administer teams.', - ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE - ) - } - const dataKey = auth.dataKey - if (!dataKey) { - throw new KeeperSdkError( - 'Data key not available. Ensure you are logged in.', - ResultCodes.DATA_KEY_MISSING - ) + if (conflicts.length > 0 && !force) { + const confirmed = await confirmCrossNode(input.confirm, conflicts[0], { + teamName: raw, + parentNodeId, + parentNodeName, + existingNodeName: nodePathById(nodes, conflicts[0].node_id), + }); + if (!confirmed) { + items.push({ + teamUid: conflicts[0].team_uid, + teamName: raw, + nodeId: conflicts[0].node_id, + source: AddTeamSourceKind.NewName, + status: AddTeamStatus.Skipped, + skipReason: AddTeamSkipReason.ExistsElsewhereDeclined, + message: `Team "${raw}" already exists in node ${conflicts[0].node_id}; creation declined.`, + }); + continue; + } } - for (const request of planned) { - try { - await sendTeamAdd(auth, request, parentNodeId, dataKey, treeKey, { - restrictEdit, - restrictShare, - restrictView, - }) - items.push({ - teamUid: request.teamUid, - teamName: request.teamName, - nodeId: parentNodeId, - source: request.kind, - status: AddTeamStatus.Created, - }) - } catch (err) { - items.push({ - teamUid: request.teamUid, - teamName: request.teamName, - nodeId: parentNodeId, - source: request.kind, - status: AddTeamStatus.Failed, - message: extractErrorMessage(err), - }) - } + newRequests.push({ + kind: AddTeamSourceKind.NewName, + teamUid: generateUid(), + teamName: raw, + }); + } + + const planned = [...queuedRequests, ...newRequests]; + if (planned.length === 0) { + return finalizeResult(items, parentNodeId, parentNodeName); + } + + const treeKey = await enterpriseData.getTreeKey(); + if (!treeKey) { + throw new KeeperSdkError( + "Enterprise tree key is unavailable. The current user may not have permission to administer teams.", + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + const dataKey = auth.dataKey; + if (!dataKey) { + throw new KeeperSdkError( + "Data key not available. Ensure you are logged in.", + ResultCodes.DATA_KEY_MISSING, + ); + } + + for (const request of planned) { + try { + await sendTeamAdd(auth, request, parentNodeId, dataKey, treeKey, { + restrictEdit, + restrictShare, + restrictView, + }); + items.push({ + teamUid: request.teamUid, + teamName: request.teamName, + nodeId: parentNodeId, + source: request.kind, + status: AddTeamStatus.Created, + }); + } catch (err) { + items.push({ + teamUid: request.teamUid, + teamName: request.teamName, + nodeId: parentNodeId, + source: request.kind, + status: AddTeamStatus.Failed, + message: extractErrorMessage(err), + }); } + } - return finalizeResult(items, parentNodeId, parentNodeName) + return finalizeResult(items, parentNodeId, parentNodeName); } function resolveRestriction(input: TeamRestrictionInput): boolean { - if (input === undefined || input === null) return false - return String(input).toLowerCase() === TeamRestriction.On + if (input === undefined || input === null) return false; + return String(input).toLowerCase() === TeamRestriction.On; } -function nodePathOrFallback(nodes: EnterpriseNode[], node: EnterpriseNode): string { - const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { - omitRoot: false, - separator: NODE_PATH_SEPARATOR, - }) - return path || (node.displayName || '').trim() || String(node.node_id) +function nodePathOrFallback( + nodes: EnterpriseNode[], + node: EnterpriseNode, +): string { + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }); + return path || (node.displayName || "").trim() || String(node.node_id); } function nodePathById(nodes: EnterpriseNode[], nodeId: number): string { - return ( - EnterpriseDataManager.getNodePath(nodes, nodeId, { - omitRoot: false, - separator: NODE_PATH_SEPARATOR, - }) || String(nodeId) - ) + return ( + EnterpriseDataManager.getNodePath(nodes, nodeId, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) || String(nodeId) + ); } -function buildTeamsByLowerName(teams: EnterpriseTeamRecord[]): Map { - const map = new Map() - for (const team of teams) { - const key = (team.name || '').trim().toLowerCase() - if (!key) continue - const existing = map.get(key) - if (existing) existing.push(team) - else map.set(key, [team]) - } - return map +function buildTeamsByLowerName( + teams: EnterpriseTeamRecord[], +): Map { + const map = new Map(); + for (const team of teams) { + const key = (team.name || "").trim().toLowerCase(); + if (!key) continue; + const existing = map.get(key); + if (existing) existing.push(team); + else map.set(key, [team]); + } + return map; } -function buildQueuedByUid(queued: EnterpriseQueuedTeamRecord[]): Map { - const map = new Map() - for (const team of queued) { - if (team.team_uid) map.set(team.team_uid, team) - } - return map +function buildQueuedByUid( + queued: EnterpriseQueuedTeamRecord[], +): Map { + const map = new Map(); + for (const team of queued) { + if (team.team_uid) map.set(team.team_uid, team); + } + return map; } function buildQueuedByLowerName( - queued: EnterpriseQueuedTeamRecord[] + queued: EnterpriseQueuedTeamRecord[], ): Map { - const map = new Map() - for (const team of queued) { - const key = (team.name || '').trim().toLowerCase() - if (!key) continue - if (!map.has(key)) map.set(key, team) - } - return map + const map = new Map(); + for (const team of queued) { + const key = (team.name || "").trim().toLowerCase(); + if (!key) continue; + if (!map.has(key)) map.set(key, team); + } + return map; } async function confirmCrossNode( - confirm: AddTeamConfirm | undefined, - existing: EnterpriseTeamRecord, - context: { - teamName: string - parentNodeId: number - parentNodeName: string - existingNodeName: string - } + confirm: AddTeamConfirm | undefined, + existing: EnterpriseTeamRecord, + context: { + teamName: string; + parentNodeId: number; + parentNodeName: string; + existingNodeName: string; + }, ): Promise { - if (!confirm) return false - const result = await confirm({ - teamName: context.teamName, - parentNodeId: context.parentNodeId, - parentNodeName: context.parentNodeName, - existingTeamUid: existing.team_uid, - existingTeamName: existing.name || context.teamName, - existingNodeId: existing.node_id, - existingNodeName: context.existingNodeName, - }) - return result === true + if (!confirm) return false; + const result = await confirm({ + teamName: context.teamName, + parentNodeId: context.parentNodeId, + parentNodeName: context.parentNodeName, + existingTeamUid: existing.team_uid, + existingTeamName: existing.name || context.teamName, + existingNodeId: existing.node_id, + existingNodeName: context.existingNodeName, + }); + return result === true; } async function sendTeamAdd( - auth: Auth, - request: ResolvedRequest, - parentNodeId: number, - dataKey: Uint8Array, - treeKey: Uint8Array, - restrictions: { restrictEdit: boolean; restrictShare: boolean; restrictView: boolean } + auth: Auth, + request: ResolvedRequest, + parentNodeId: number, + dataKey: Uint8Array, + treeKey: Uint8Array, + restrictions: { + restrictEdit: boolean; + restrictShare: boolean; + restrictView: boolean; + }, ): Promise { - const teamKeyBytes = generateEncryptionKey() - const [rsaKeyPair, eccKeyPair] = await Promise.all([ - platform.generateRSAKeyPair(), - platform.generateECKeyPair(), - ]) - const encryptedPrivateKey = await encryptForStorage(rsaKeyPair.privateKey, teamKeyBytes) - const encryptedEccPrivateKey = await encryptKey(eccKeyPair.privateKey, teamKeyBytes) - const encryptedTeamKeyByDataKey = await encryptForStorage(teamKeyBytes, dataKey) - const encryptedTeamKeyByTreeKey = await encryptKey(teamKeyBytes, treeKey) - - const payload: TeamAddRequestPayload = { - team_uid: request.teamUid, - team_name: request.teamName, - node_id: parentNodeId, - public_key: webSafe64FromBytes(rsaKeyPair.publicKey), - private_key: encryptedPrivateKey, - team_key: encryptedTeamKeyByDataKey, - encrypted_team_key: encryptedTeamKeyByTreeKey, - ecc_public_key: webSafe64FromBytes(eccKeyPair.publicKey), - ecc_private_key: encryptedEccPrivateKey, - restrict_edit: restrictions.restrictEdit, - restrict_share: restrictions.restrictShare, - restrict_view: restrictions.restrictView, - manage_only: true, - } - - const command: RestCommand = { - baseRequest: { command: TEAM_ADD_COMMAND }, - request: payload, - authorization: {}, - } - - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || - response.result_code || - `team_add failed for "${request.teamName}" (uid=${request.teamUid})`, - response.result_code || ResultCodes.TEAM_ADD_FAILED - ) - } + const teamKeyBytes = generateEncryptionKey(); + const [rsaKeyPair, eccKeyPair] = await Promise.all([ + platform.generateRSAKeyPair(), + platform.generateECKeyPair(), + ]); + const encryptedPrivateKey = await encryptForStorage( + rsaKeyPair.privateKey, + teamKeyBytes, + ); + const encryptedEccPrivateKey = await encryptKey( + eccKeyPair.privateKey, + teamKeyBytes, + ); + const encryptedTeamKeyByDataKey = await encryptForStorage( + teamKeyBytes, + dataKey, + ); + const encryptedTeamKeyByTreeKey = await encryptKey(teamKeyBytes, treeKey); + + const payload: TeamAddRequestPayload = { + team_uid: request.teamUid, + team_name: request.teamName, + node_id: parentNodeId, + public_key: webSafe64FromBytes(rsaKeyPair.publicKey), + private_key: encryptedPrivateKey, + team_key: encryptedTeamKeyByDataKey, + encrypted_team_key: encryptedTeamKeyByTreeKey, + ecc_public_key: webSafe64FromBytes(eccKeyPair.publicKey), + ecc_private_key: encryptedEccPrivateKey, + restrict_edit: restrictions.restrictEdit, + restrict_share: restrictions.restrictShare, + restrict_view: restrictions.restrictView, + manage_only: true, + }; + + const command: RestCommand = { + baseRequest: { command: TEAM_ADD_COMMAND }, + request: payload, + authorization: {}, + }; + + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_add failed for "${request.teamName}" (uid=${request.teamUid})`, + response.result_code || ResultCodes.TEAM_ADD_FAILED, + ); + } } function finalizeResult( - items: AddTeamItemResult[], - parentNodeId: number, - parentNodeName: string + items: AddTeamItemResult[], + parentNodeId: number, + parentNodeName: string, ): AddTeamResult { - const created = items.filter((item) => item.status === AddTeamStatus.Created).length - const skipped = items.filter((item) => item.status === AddTeamStatus.Skipped).length - const failed = items.filter((item) => item.status === AddTeamStatus.Failed).length - return { - success: failed === 0 && created > 0, - parentNodeId, - parentNodeName, - items, - created, - skipped, - failed, - } + const created = items.filter( + (item) => item.status === AddTeamStatus.Created, + ).length; + const skipped = items.filter( + (item) => item.status === AddTeamStatus.Skipped, + ).length; + const failed = items.filter( + (item) => item.status === AddTeamStatus.Failed, + ).length; + return { + success: failed === 0 && created > 0, + parentNodeId, + parentNodeName, + items, + created, + skipped, + failed, + }; } export type FormatAddTeamResultOptions = { - showSkipped?: boolean -} + showSkipped?: boolean; +}; export type FormattedAddTeamRow = { - status: string - name: string - teamUid: string - nodeId: number - detail: string -} + status: string; + name: string; + teamUid: string; + nodeId: number; + detail: string; +}; export type FormattedAddTeamTable = { - headers: string[] - rows: string[][] - parentNodeName: string - summary: string -} + headers: string[]; + rows: string[][]; + parentNodeName: string; + summary: string; +}; export function formatAddTeamResult( - result: AddTeamResult, - options: FormatAddTeamResultOptions = {} + result: AddTeamResult, + options: FormatAddTeamResultOptions = {}, ): FormattedAddTeamTable { - const showSkipped = options.showSkipped !== false - const visible = result.items.filter( - (item) => showSkipped || item.status !== AddTeamStatus.Skipped - ) - - const rows = visible.map((item, index) => [ - String(index + 1), - item.status, - item.teamName, - item.teamUid, - String(item.nodeId), - item.message || (item.skipReason ? item.skipReason : ''), - ]) - - return { - headers: [...TEAM_TABLE_HEADERS], - rows, - parentNodeName: result.parentNodeName, - summary: `Created: ${result.created} Skipped: ${result.skipped} Failed: ${result.failed}`, - } + const showSkipped = options.showSkipped !== false; + const visible = result.items.filter( + (item) => showSkipped || item.status !== AddTeamStatus.Skipped, + ); + + const rows = visible.map((item, index) => [ + String(index + 1), + item.status, + item.teamName, + item.teamUid, + String(item.nodeId), + item.message || (item.skipReason ? item.skipReason : ""), + ]); + + return { + headers: [...TEAM_TABLE_HEADERS], + rows, + parentNodeName: result.parentNodeName, + summary: `Created: ${result.created} Skipped: ${result.skipped} Failed: ${result.failed}`, + }; } export function renderAddTeamAsciiTable(table: FormattedAddTeamTable): string { - const { headers, rows } = table - const widths = headers.map((header, index) => - Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) - ) - const padCell = (cell: string, columnIndex: number): string => - cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) - const formatRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - const lines: string[] = [] - lines.push(`Parent: ${table.parentNodeName}`) - lines.push(formatRow(headers)) - lines.push(formatRow(widths.map((width) => '-'.repeat(width)))) - for (const row of rows) lines.push(formatRow(row)) - lines.push(table.summary) - return lines.join('\n') + const { headers, rows } = table; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + const lines: string[] = []; + lines.push(`Parent: ${table.parentNodeName}`); + lines.push(formatRow(headers)); + lines.push(formatRow(widths.map((width) => "-".repeat(width)))); + for (const row of rows) lines.push(formatRow(row)); + lines.push(table.summary); + return lines.join("\n"); } diff --git a/KeeperSdk/src/teams/deleteTeam.ts b/KeeperSdk/src/teams/deleteTeam.ts index 704969e3..4ffa709a 100644 --- a/KeeperSdk/src/teams/deleteTeam.ts +++ b/KeeperSdk/src/teams/deleteTeam.ts @@ -1,154 +1,171 @@ -import type { Auth, KeeperResponse, RestCommand } from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' -import { - EnterpriseDataInclude, - EnterpriseDataManager, -} from './enterpriseData' -import { resolveExistingTeams, TEAM_TABLE_HEADERS } from './teamUtils' - -const TEAM_DELETE_COMMAND = 'team_delete' -const DELETE_TEAM_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Teams] +import type { + Auth, + KeeperResponse, + RestCommand, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; +import { EnterpriseDataInclude, EnterpriseDataManager } from "./enterpriseData"; +import { resolveExistingTeams, TEAM_TABLE_HEADERS } from "./teamUtils"; + +const TEAM_DELETE_COMMAND = "team_delete"; +const DELETE_TEAM_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Teams, +]; export enum DeleteTeamStatus { - Deleted = 'deleted', - Failed = 'failed', + Deleted = "deleted", + Failed = "failed", } export type DeleteTeamInput = { - teams: string[] -} + teams: string[]; +}; export type DeleteTeamItemResult = { - teamUid: string - teamName: string - nodeId: number - status: DeleteTeamStatus - message?: string -} + teamUid: string; + teamName: string; + nodeId: number; + status: DeleteTeamStatus; + message?: string; +}; export type DeleteTeamResult = { - success: boolean - items: DeleteTeamItemResult[] - deleted: number - failed: number -} + success: boolean; + items: DeleteTeamItemResult[]; + deleted: number; + failed: number; +}; type TeamDeleteRequestPayload = { - team_uid: string -} - -export async function deleteTeams(auth: Auth, input: DeleteTeamInput): Promise { - const requestedIdentifiers = (input.teams || []) - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter((value) => value.length > 0) - - if (requestedIdentifiers.length === 0) { - throw new KeeperSdkError('No teams to delete.', ResultCodes.NO_TEAMS_TO_DELETE) - } - - const enterpriseData = new EnterpriseDataManager(auth) - const response = await enterpriseData.getData(DELETE_TEAM_INCLUDES) - const allTeams = response.teams || [] - const resolvedTeams = resolveExistingTeams(allTeams, requestedIdentifiers) - - const items: DeleteTeamItemResult[] = [] - for (const team of resolvedTeams) { - try { - await sendTeamDelete(auth, team.team_uid) - items.push({ - teamUid: team.team_uid, - teamName: team.name || '', - nodeId: team.node_id, - status: DeleteTeamStatus.Deleted, - }) - } catch (err) { - items.push({ - teamUid: team.team_uid, - teamName: team.name || '', - nodeId: team.node_id, - status: DeleteTeamStatus.Failed, - message: extractErrorMessage(err), - }) - } + team_uid: string; +}; + +export async function deleteTeams( + auth: Auth, + input: DeleteTeamInput, +): Promise { + const requestedIdentifiers = (input.teams || []) + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter((value) => value.length > 0); + + if (requestedIdentifiers.length === 0) { + throw new KeeperSdkError( + "No teams to delete.", + ResultCodes.NO_TEAMS_TO_DELETE, + ); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(DELETE_TEAM_INCLUDES); + const allTeams = response.teams || []; + const resolvedTeams = resolveExistingTeams(allTeams, requestedIdentifiers); + + const items: DeleteTeamItemResult[] = []; + for (const team of resolvedTeams) { + try { + await sendTeamDelete(auth, team.team_uid); + items.push({ + teamUid: team.team_uid, + teamName: team.name || "", + nodeId: team.node_id, + status: DeleteTeamStatus.Deleted, + }); + } catch (err) { + items.push({ + teamUid: team.team_uid, + teamName: team.name || "", + nodeId: team.node_id, + status: DeleteTeamStatus.Failed, + message: extractErrorMessage(err), + }); } + } - return finalizeResult(items) + return finalizeResult(items); } async function sendTeamDelete(auth: Auth, teamUid: string): Promise { - const command: RestCommand = { - baseRequest: { command: TEAM_DELETE_COMMAND }, - request: { team_uid: teamUid }, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || - response.result_code || - `team_delete failed for team_uid=${teamUid}`, - response.result_code || ResultCodes.TEAM_DELETE_FAILED - ) - } + const command: RestCommand = { + baseRequest: { command: TEAM_DELETE_COMMAND }, + request: { team_uid: teamUid }, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_delete failed for team_uid=${teamUid}`, + response.result_code || ResultCodes.TEAM_DELETE_FAILED, + ); + } } function finalizeResult(items: DeleteTeamItemResult[]): DeleteTeamResult { - const deleted = items.filter((item) => item.status === DeleteTeamStatus.Deleted).length - const failed = items.filter((item) => item.status === DeleteTeamStatus.Failed).length - return { - success: failed === 0 && deleted > 0, - items, - deleted, - failed, - } + const deleted = items.filter( + (item) => item.status === DeleteTeamStatus.Deleted, + ).length; + const failed = items.filter( + (item) => item.status === DeleteTeamStatus.Failed, + ).length; + return { + success: failed === 0 && deleted > 0, + items, + deleted, + failed, + }; } export type FormattedDeleteTeamRow = { - status: string - teamName: string - teamUid: string - nodeId: number - detail: string -} + status: string; + teamName: string; + teamUid: string; + nodeId: number; + detail: string; +}; export type FormattedDeleteTeamTable = { - headers: string[] - rows: string[][] - summary: string -} - -export function formatDeleteTeamResult(result: DeleteTeamResult): FormattedDeleteTeamTable { - const rows = result.items.map((item, index) => [ - String(index + 1), - item.status, - item.teamName, - item.teamUid, - String(item.nodeId), - item.message || '', - ]) - - return { - headers: [...TEAM_TABLE_HEADERS], - rows, - summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, - } + headers: string[]; + rows: string[][]; + summary: string; +}; + +export function formatDeleteTeamResult( + result: DeleteTeamResult, +): FormattedDeleteTeamTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.teamName, + item.teamUid, + String(item.nodeId), + item.message || "", + ]); + + return { + headers: [...TEAM_TABLE_HEADERS], + rows, + summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, + }; } -export function renderDeleteTeamAsciiTable(table: FormattedDeleteTeamTable): string { - const { headers, rows } = table - const widths = headers.map((header, index) => - Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) - ) - const padCell = (cell: string, columnIndex: number): string => - cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) - const formatRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - const lines: string[] = [] - lines.push(formatRow(headers)) - lines.push(formatRow(widths.map((width) => '-'.repeat(width)))) - for (const row of rows) lines.push(formatRow(row)) - lines.push(table.summary) - return lines.join('\n') +export function renderDeleteTeamAsciiTable( + table: FormattedDeleteTeamTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + const lines: string[] = []; + lines.push(formatRow(headers)); + lines.push(formatRow(widths.map((width) => "-".repeat(width)))); + for (const row of rows) lines.push(formatRow(row)); + lines.push(table.summary); + return lines.join("\n"); } diff --git a/KeeperSdk/src/teams/enterpriseData.ts b/KeeperSdk/src/teams/enterpriseData.ts index d9336218..d73bd268 100644 --- a/KeeperSdk/src/teams/enterpriseData.ts +++ b/KeeperSdk/src/teams/enterpriseData.ts @@ -1,661 +1,795 @@ import { - decryptObjectFromStorage, - Enterprise, - getEnterpriseDataForUserMessage, - getEnterpriseDataKeysMessage, - normal64Bytes, - platform, - webSafe64FromBytes, - type Auth, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, isNumber, logger } from '../utils' - -const DEFAULT_NODE_PATH_SEPARATOR = '\\' -const MAX_CONTINUATIONS = 50 - + decryptObjectFromStorage, + Enterprise, + getEnterpriseDataForUserMessage, + getEnterpriseDataKeysMessage, + normal64Bytes, + platform, + webSafe64FromBytes, + type Auth, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, isNumber, logger } from "../utils"; + +const DEFAULT_NODE_PATH_SEPARATOR = "\\"; +const MAX_CONTINUATIONS = 50; export enum EnterpriseDataInclude { - Nodes = 'nodes', - Users = 'users', - Roles = 'roles', - RoleUsers = 'role_users', - RoleTeams = 'role_teams', - RolePrivileges = 'role_privileges', - ManagedNodes = 'managed_nodes', - RoleEnforcements = 'role_enforcements', - Teams = 'teams', - TeamUsers = 'team_users', - QueuedTeams = 'queued_teams', - QueuedTeamUsers = 'queued_team_users', - UserAliases = 'user_aliases', + Nodes = "nodes", + Users = "users", + Roles = "roles", + RoleUsers = "role_users", + RoleTeams = "role_teams", + RolePrivileges = "role_privileges", + ManagedNodes = "managed_nodes", + RoleEnforcements = "role_enforcements", + Teams = "teams", + TeamUsers = "team_users", + QueuedTeams = "queued_teams", + QueuedTeamUsers = "queued_team_users", + UserAliases = "user_aliases", } -const INCLUDE_TO_ENTITY: Record = { - [EnterpriseDataInclude.Nodes]: Enterprise.EnterpriseDataEntity.NODES, - [EnterpriseDataInclude.Users]: Enterprise.EnterpriseDataEntity.USERS, - [EnterpriseDataInclude.Roles]: Enterprise.EnterpriseDataEntity.ROLES, - [EnterpriseDataInclude.RoleUsers]: Enterprise.EnterpriseDataEntity.ROLE_USERS, - [EnterpriseDataInclude.RoleTeams]: Enterprise.EnterpriseDataEntity.ROLE_TEAMS, - [EnterpriseDataInclude.RolePrivileges]: Enterprise.EnterpriseDataEntity.ROLE_PRIVILEGES, - [EnterpriseDataInclude.ManagedNodes]: Enterprise.EnterpriseDataEntity.MANAGED_NODES, - [EnterpriseDataInclude.RoleEnforcements]: Enterprise.EnterpriseDataEntity.ROLE_ENFORCEMENTS, - [EnterpriseDataInclude.Teams]: Enterprise.EnterpriseDataEntity.TEAMS, - [EnterpriseDataInclude.TeamUsers]: Enterprise.EnterpriseDataEntity.TEAM_USERS, - [EnterpriseDataInclude.QueuedTeams]: Enterprise.EnterpriseDataEntity.QUEUED_TEAMS, - [EnterpriseDataInclude.QueuedTeamUsers]: Enterprise.EnterpriseDataEntity.QUEUED_TEAM_USERS, - [EnterpriseDataInclude.UserAliases]: Enterprise.EnterpriseDataEntity.USER_ALIASES, -} +const INCLUDE_TO_ENTITY: Record< + EnterpriseDataInclude, + Enterprise.EnterpriseDataEntity +> = { + [EnterpriseDataInclude.Nodes]: Enterprise.EnterpriseDataEntity.NODES, + [EnterpriseDataInclude.Users]: Enterprise.EnterpriseDataEntity.USERS, + [EnterpriseDataInclude.Roles]: Enterprise.EnterpriseDataEntity.ROLES, + [EnterpriseDataInclude.RoleUsers]: Enterprise.EnterpriseDataEntity.ROLE_USERS, + [EnterpriseDataInclude.RoleTeams]: Enterprise.EnterpriseDataEntity.ROLE_TEAMS, + [EnterpriseDataInclude.RolePrivileges]: + Enterprise.EnterpriseDataEntity.ROLE_PRIVILEGES, + [EnterpriseDataInclude.ManagedNodes]: + Enterprise.EnterpriseDataEntity.MANAGED_NODES, + [EnterpriseDataInclude.RoleEnforcements]: + Enterprise.EnterpriseDataEntity.ROLE_ENFORCEMENTS, + [EnterpriseDataInclude.Teams]: Enterprise.EnterpriseDataEntity.TEAMS, + [EnterpriseDataInclude.TeamUsers]: Enterprise.EnterpriseDataEntity.TEAM_USERS, + [EnterpriseDataInclude.QueuedTeams]: + Enterprise.EnterpriseDataEntity.QUEUED_TEAMS, + [EnterpriseDataInclude.QueuedTeamUsers]: + Enterprise.EnterpriseDataEntity.QUEUED_TEAM_USERS, + [EnterpriseDataInclude.UserAliases]: + Enterprise.EnterpriseDataEntity.USER_ALIASES, +}; export type EnterpriseNode = { - node_id: number - parent_id?: number - encrypted_data?: string - displayName?: string -} + node_id: number; + parent_id?: number; + encrypted_data?: string; + displayName?: string; +}; export type EnterpriseUser = { - enterprise_user_id: number - username: string - status?: string - node_id?: number - encrypted_data?: string - full_name?: string - job_title?: string - lock?: number - account_share_expiration?: number - tfa_enabled?: boolean -} + enterprise_user_id: number; + username: string; + status?: string; + node_id?: number; + encrypted_data?: string; + full_name?: string; + job_title?: string; + lock?: number; + account_share_expiration?: number; + tfa_enabled?: boolean; +}; export type EnterpriseRole = { - role_id: number - node_id?: number - encrypted_data?: string - displayName?: string - visible_below?: boolean - new_user_inherit?: boolean -} + role_id: number; + node_id?: number; + encrypted_data?: string; + displayName?: string; + visible_below?: boolean; + new_user_inherit?: boolean; +}; export type EnterpriseRolePrivilegeLink = { - role_id: number - managed_node_id: number - privilege_type?: string -} + role_id: number; + managed_node_id: number; + privilege_type?: string; +}; export type EnterpriseRoleManagedNodeLink = { - role_id: number - managed_node_id: number - cascade_node_management: boolean -} + role_id: number; + managed_node_id: number; + cascade_node_management: boolean; +}; export type EnterpriseRoleEnforcementLink = { - role_id: number - enforcement_type: string - value?: string -} + role_id: number; + enforcement_type: string; + value?: string; +}; export type EnterpriseTeamRecord = { - team_uid: string - name: string - node_id: number - restrict_view?: boolean - restrict_edit?: boolean - restrict_share?: boolean - restrict_sharing?: boolean - encrypted_data?: string - encrypted_team_key?: string -} + team_uid: string; + name: string; + node_id: number; + restrict_view?: boolean; + restrict_edit?: boolean; + restrict_share?: boolean; + restrict_sharing?: boolean; + encrypted_data?: string; + encrypted_team_key?: string; +}; export type EnterpriseTeamUserLink = { - team_uid: string - enterprise_user_id: number - user_type?: string -} + team_uid: string; + enterprise_user_id: number; + user_type?: string; +}; export type EnterpriseRoleUserLink = { - role_id: number - enterprise_user_id: number -} + role_id: number; + enterprise_user_id: number; +}; export type EnterpriseRoleTeamLink = { - role_id: number - team_uid: string -} + role_id: number; + team_uid: string; +}; export type EnterpriseQueuedTeamRecord = { - team_uid: string - name: string - node_id: number - encrypted_data?: string -} + team_uid: string; + name: string; + node_id: number; + encrypted_data?: string; +}; export type EnterpriseQueuedTeamUserLink = { - team_uid: string - enterprise_user_id: number -} + team_uid: string; + enterprise_user_id: number; +}; export type EnterpriseUserAliasLink = { - enterprise_user_id: number - username: string -} + enterprise_user_id: number; + username: string; +}; export type GetEnterpriseDataResponse = { - enterprise_name?: string - nodes?: EnterpriseNode[] - users?: EnterpriseUser[] - roles?: EnterpriseRole[] - teams?: EnterpriseTeamRecord[] - team_users?: EnterpriseTeamUserLink[] - role_users?: EnterpriseRoleUserLink[] - role_teams?: EnterpriseRoleTeamLink[] - role_privileges?: EnterpriseRolePrivilegeLink[] - managed_nodes?: EnterpriseRoleManagedNodeLink[] - role_enforcements?: EnterpriseRoleEnforcementLink[] - queued_teams?: EnterpriseQueuedTeamRecord[] - queued_team_users?: EnterpriseQueuedTeamUserLink[] - user_aliases?: EnterpriseUserAliasLink[] -} + enterprise_name?: string; + nodes?: EnterpriseNode[]; + users?: EnterpriseUser[]; + roles?: EnterpriseRole[]; + teams?: EnterpriseTeamRecord[]; + team_users?: EnterpriseTeamUserLink[]; + role_users?: EnterpriseRoleUserLink[]; + role_teams?: EnterpriseRoleTeamLink[]; + role_privileges?: EnterpriseRolePrivilegeLink[]; + managed_nodes?: EnterpriseRoleManagedNodeLink[]; + role_enforcements?: EnterpriseRoleEnforcementLink[]; + queued_teams?: EnterpriseQueuedTeamRecord[]; + queued_team_users?: EnterpriseQueuedTeamUserLink[]; + user_aliases?: EnterpriseUserAliasLink[]; +}; export type NodePathOptions = { - omitRoot?: boolean - separator?: string -} + omitRoot?: boolean; + separator?: string; +}; -export type DecryptedNodeNames = Map -export type DecryptedRoleNames = Map +export type DecryptedNodeNames = Map; +export type DecryptedRoleNames = Map; export type EnterpriseDisplayNames = { - nodes: DecryptedNodeNames - roles: DecryptedRoleNames -} + nodes: DecryptedNodeNames; + roles: DecryptedRoleNames; +}; -type LongLike = number | { toNumber: () => number; toString: () => string } | undefined | null +type LongLike = + | number + | { toNumber: () => number; toString: () => string } + | undefined + | null; export interface EnterpriseDataManagerApi { - getData(includes: EnterpriseDataInclude[]): Promise - getDisplayNames(): Promise - getTreeKey(): Promise - decryptNodeNames(nodes: EnterpriseNode[]): Promise - clearCache(): void + getData( + includes: EnterpriseDataInclude[], + ): Promise; + getDisplayNames(): Promise; + getTreeKey(): Promise; + decryptNodeNames(nodes: EnterpriseNode[]): Promise; + clearCache(): void; } export class EnterpriseDataManager implements EnterpriseDataManagerApi { - private readonly auth: Auth - private displayNamesPromise: Promise | null = null - private treeKeyPromise: Promise | null = null - private readonly dataCache = new Map>() - - constructor(auth: Auth) { - this.auth = auth - } - - public async getData(includes: EnterpriseDataInclude[]): Promise { - const key = EnterpriseDataManager.cacheKeyForIncludes(includes) - let promise = this.dataCache.get(key) - if (!promise) { - promise = this.fetchEnterpriseData(includes) - this.dataCache.set(key, promise) - } - return promise - } - - public async getDisplayNames(): Promise { - if (!this.displayNamesPromise) { - this.displayNamesPromise = this.fetchDisplayNames() - } - return this.displayNamesPromise - } - - public async decryptNodeNames(nodes: EnterpriseNode[]): Promise { - if (!nodes || nodes.length === 0) return 0 - - const needsDecrypt = nodes.some( - (node) => !!node.encrypted_data && !(node.displayName || '').trim() - ) - if (!needsDecrypt) return 0 - - const displayNames = await this.getDisplayNames() - if (displayNames.nodes.size > 0) { - for (const node of nodes) { - if ((node.displayName || '').trim()) continue - const cached = displayNames.nodes.get(node.node_id) - if (cached) node.displayName = cached - } - } - - const treeKey = await this.getTreeKey() - if (!treeKey) { - logger.warn( - 'Enterprise node names unavailable: could not decrypt enterprise tree key. ' + - 'Use numeric node IDs (visible in error listings).' - ) - return 0 - } - - let decryptedCount = 0 - for (const node of nodes) { - if ((node.displayName || '').trim()) continue - if (!node.encrypted_data) continue - const display = await EnterpriseDataManager.decryptDisplayName(node.encrypted_data, treeKey) - if (display) { - node.displayName = display - decryptedCount++ - } - } - if (decryptedCount > 0) { - logger.debug(`Decrypted ${decryptedCount} enterprise node name(s) using tree key.`) - } - return decryptedCount - } - - public clearCache(): void { - this.dataCache.clear() - this.displayNamesPromise = null - this.treeKeyPromise = null - } - - public static getNodePath( - nodes: EnterpriseNode[], - nodeId: number, - options: NodePathOptions = {} - ): string { - const { omitRoot = true, separator = DEFAULT_NODE_PATH_SEPARATOR } = options - const byId = new Map() - for (const node of nodes) byId.set(node.node_id, node) - - const visited = new Set() - const segments: string[] = [] - let currentId: number | undefined = nodeId - while (isNumber(currentId) && currentId > 0 && !visited.has(currentId)) { - visited.add(currentId) - const node = byId.get(currentId) - if (!node) break - const parentId = node.parent_id || 0 - const isRoot = parentId === 0 - if (!isRoot || !omitRoot) { - const segmentName = (node.displayName || '').trim() - if (segmentName) segments.push(segmentName) - } - currentId = parentId - } - segments.reverse() - return segments.join(separator) - } - - private static cacheKeyForIncludes(includes: EnterpriseDataInclude[]): string { - return [...includes].sort().join(',') - } - - private async fetchEnterpriseData( - includes: EnterpriseDataInclude[] - ): Promise { - const interesting = new Set( - includes.map((key) => INCLUDE_TO_ENTITY[key]) - ) - const aggregate: GetEnterpriseDataResponse = {} - let continuationToken: Uint8Array | undefined - - for (let iteration = 0; iteration < MAX_CONTINUATIONS; iteration += 1) { - const request: Enterprise.IEnterpriseDataRequest = {} - if (continuationToken) request.continuationToken = continuationToken - - const response = await this.auth.executeRest(getEnterpriseDataForUserMessage(request)) - - if (response.generalData?.enterpriseName) { - aggregate.enterprise_name = response.generalData.enterpriseName - } - - for (const chunk of response.data || []) { - const entity = chunk.entity ?? Enterprise.EnterpriseDataEntity.UNKNOWN - if (!interesting.has(entity)) continue - EnterpriseDataManager.applyChunk(chunk, aggregate) - } - - if (!response.hasMore) break - if (!response.continuationToken || response.continuationToken.length === 0) break - continuationToken = response.continuationToken - } - - return aggregate + private readonly auth: Auth; + private displayNamesPromise: Promise | null = null; + private treeKeyPromise: Promise | null = null; + private readonly dataCache = new Map< + string, + Promise + >(); + + constructor(auth: Auth) { + this.auth = auth; + } + + public async getData( + includes: EnterpriseDataInclude[], + ): Promise { + const key = EnterpriseDataManager.cacheKeyForIncludes(includes); + let promise = this.dataCache.get(key); + if (!promise) { + promise = this.fetchEnterpriseData(includes); + this.dataCache.set(key, promise); } + return promise; + } - private async fetchDisplayNames(): Promise { - const empty: EnterpriseDisplayNames = { nodes: new Map(), roles: new Map() } - - const treeKey = await this.getTreeKey() - if (!treeKey) return empty - - const data = await this.getData([EnterpriseDataInclude.Nodes, EnterpriseDataInclude.Roles]) - - const nodes: DecryptedNodeNames = new Map() - for (const node of data.nodes || []) { - if (!isNumber(node.node_id)) continue - if (!node.encrypted_data) continue - const display = await EnterpriseDataManager.decryptDisplayName(node.encrypted_data, treeKey) - if (display) nodes.set(node.node_id, display) - } - - const roles: DecryptedRoleNames = new Map() - for (const role of data.roles || []) { - if (!isNumber(role.role_id)) continue - if (!role.encrypted_data) continue - const display = await EnterpriseDataManager.decryptDisplayName(role.encrypted_data, treeKey) - if (display) roles.set(role.role_id, display) - } - - return { nodes, roles } + public async getDisplayNames(): Promise { + if (!this.displayNamesPromise) { + this.displayNamesPromise = this.fetchDisplayNames(); } - - public async getTreeKey(): Promise { - if (!this.treeKeyPromise) { - this.treeKeyPromise = this.fetchAndDecryptTreeKey() - } - return this.treeKeyPromise + return this.displayNamesPromise; + } + + public async decryptNodeNames(nodes: EnterpriseNode[]): Promise { + if (!nodes || nodes.length === 0) return 0; + + const needsDecrypt = nodes.some( + (node) => !!node.encrypted_data && !(node.displayName || "").trim(), + ); + if (!needsDecrypt) return 0; + + const displayNames = await this.getDisplayNames(); + if (displayNames.nodes.size > 0) { + for (const node of nodes) { + if ((node.displayName || "").trim()) continue; + const cached = displayNames.nodes.get(node.node_id); + if (cached) node.displayName = cached; + } } - private async fetchAndDecryptTreeKey(): Promise { - let response: Enterprise.IGetEnterpriseDataKeysResponse - try { - response = await this.auth.executeRest(getEnterpriseDataKeysMessage()) - } catch (err) { - logger.debug(`enterprise/get_enterprise_data_keys failed: ${extractErrorMessage(err)}`) - return null - } - - const treeKey = response.treeKey - if (!treeKey || !treeKey.treeKey) { - logger.debug( - `enterprise/get_enterprise_data_keys: no tree key returned (user is likely not an enterprise admin)` - ) - return null - } - - return this.decryptTreeKey(treeKey) + const treeKey = await this.getTreeKey(); + if (!treeKey) { + logger.warn( + "Enterprise node names unavailable: could not decrypt enterprise tree key. " + + "Use numeric node IDs (visible in error listings).", + ); + return 0; } - private async decryptTreeKey(treeKey: Enterprise.ITreeKey): Promise { - if (!treeKey.treeKey) return null - const encrypted = normal64Bytes(treeKey.treeKey) - const keyType = (treeKey.keyTypeId ?? 0) as Enterprise.BackupKeyType - - try { - switch (keyType) { - case Enterprise.BackupKeyType.ENCRYPTED_BY_DATA_KEY: { - if (!this.auth.dataKey) return null - return await platform.aesCbcDecrypt(encrypted, this.auth.dataKey, true) - } - case Enterprise.BackupKeyType.ENCRYPTED_BY_DATA_KEY_GCM: { - if (!this.auth.dataKey) return null - return await platform.aesGcmDecrypt(encrypted, this.auth.dataKey) - } - case Enterprise.BackupKeyType.ENCRYPTED_BY_PUBLIC_KEY: { - if (!this.auth.privateKey) return null - return platform.privateDecrypt(encrypted, this.auth.privateKey) - } - case Enterprise.BackupKeyType.ENCRYPTED_BY_PUBLIC_KEY_ECC: { - if (!this.auth.eccPrivateKey) return null - return await platform.privateDecryptEC(encrypted, this.auth.eccPrivateKey) - } - default: - logger.debug(`Unsupported tree-key keyTypeId=${keyType}`) - return null - } - } catch (err) { - logger.debug( - `Tree-key decryption failed (keyTypeId=${keyType}): ${extractErrorMessage(err)}` - ) - return null - } + let decryptedCount = 0; + for (const node of nodes) { + if ((node.displayName || "").trim()) continue; + if (!node.encrypted_data) continue; + const display = await EnterpriseDataManager.decryptDisplayName( + node.encrypted_data, + treeKey, + ); + if (display) { + node.displayName = display; + decryptedCount++; + } } - - private static async decryptDisplayName(encrypted: string, treeKey: Uint8Array): Promise { - try { - const decrypted = await decryptObjectFromStorage<{ displayname?: string }>(encrypted, treeKey) - return (decrypted?.displayname || '').trim() - } catch { - return '' - } + if (decryptedCount > 0) { + logger.debug( + `Decrypted ${decryptedCount} enterprise node name(s) using tree key.`, + ); } - - private static toNumber(value: LongLike): number { - if (value == null) return 0 - if (isNumber(value)) return value - if (typeof value.toNumber === 'function') return value.toNumber() - const parsed = Number(value.toString()) - return Number.isFinite(parsed) ? parsed : 0 + return decryptedCount; + } + + public clearCache(): void { + this.dataCache.clear(); + this.displayNamesPromise = null; + this.treeKeyPromise = null; + } + + public static getNodePath( + nodes: EnterpriseNode[], + nodeId: number, + options: NodePathOptions = {}, + ): string { + const { omitRoot = true, separator = DEFAULT_NODE_PATH_SEPARATOR } = + options; + const byId = new Map(); + for (const node of nodes) byId.set(node.node_id, node); + + const visited = new Set(); + const segments: string[] = []; + let currentId: number | undefined = nodeId; + while (isNumber(currentId) && currentId > 0 && !visited.has(currentId)) { + visited.add(currentId); + const node = byId.get(currentId); + if (!node) break; + const parentId = node.parent_id || 0; + const isRoot = parentId === 0; + if (!isRoot || !omitRoot) { + const segmentName = (node.displayName || "").trim(); + if (segmentName) segments.push(segmentName); + } + currentId = parentId; } - - private static toUid(bytes: Uint8Array | null | undefined): string { - return bytes && bytes.length > 0 ? webSafe64FromBytes(bytes) : '' + segments.reverse(); + return segments.join(separator); + } + + private static cacheKeyForIncludes( + includes: EnterpriseDataInclude[], + ): string { + return [...includes].sort().join(","); + } + + private async fetchEnterpriseData( + includes: EnterpriseDataInclude[], + ): Promise { + const interesting = new Set( + includes.map((key) => INCLUDE_TO_ENTITY[key]), + ); + const aggregate: GetEnterpriseDataResponse = {}; + let continuationToken: Uint8Array | undefined; + + for (let iteration = 0; iteration < MAX_CONTINUATIONS; iteration += 1) { + const request: Enterprise.IEnterpriseDataRequest = {}; + if (continuationToken) request.continuationToken = continuationToken; + + const response = await this.auth.executeRest( + getEnterpriseDataForUserMessage(request), + ); + + if (response.generalData?.enterpriseName) { + aggregate.enterprise_name = response.generalData.enterpriseName; + } + + for (const chunk of response.data || []) { + const entity = chunk.entity ?? Enterprise.EnterpriseDataEntity.UNKNOWN; + if (!interesting.has(entity)) continue; + EnterpriseDataManager.applyChunk(chunk, aggregate); + } + + if (!response.hasMore) break; + if ( + !response.continuationToken || + response.continuationToken.length === 0 + ) + break; + continuationToken = response.continuationToken; } - private static decodeChunk( - chunkData: Uint8Array[] | null | undefined, - decoder: (bytes: Uint8Array) => T - ): T[] { - if (!chunkData || chunkData.length === 0) return [] - const results: T[] = [] - for (const bytes of chunkData) { - try { - results.push(decoder(bytes)) - } catch { - } - } - return results + return aggregate; + } + + private async fetchDisplayNames(): Promise { + const empty: EnterpriseDisplayNames = { + nodes: new Map(), + roles: new Map(), + }; + + const treeKey = await this.getTreeKey(); + if (!treeKey) return empty; + + const data = await this.getData([ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + ]); + + const nodes: DecryptedNodeNames = new Map(); + for (const node of data.nodes || []) { + if (!isNumber(node.node_id)) continue; + if (!node.encrypted_data) continue; + const display = await EnterpriseDataManager.decryptDisplayName( + node.encrypted_data, + treeKey, + ); + if (display) nodes.set(node.node_id, display); } - private static decodeNodeChunk(bytes: Uint8Array): EnterpriseNode { - const message = Enterprise.Node.decode(bytes) - const node: EnterpriseNode = { node_id: EnterpriseDataManager.toNumber(message.nodeId) } - if (message.parentId != null) node.parent_id = EnterpriseDataManager.toNumber(message.parentId) - if (message.encryptedData) node.encrypted_data = message.encryptedData - return node + const roles: DecryptedRoleNames = new Map(); + for (const role of data.roles || []) { + if (!isNumber(role.role_id)) continue; + if (!role.encrypted_data) continue; + const display = await EnterpriseDataManager.decryptDisplayName( + role.encrypted_data, + treeKey, + ); + if (display) roles.set(role.role_id, display); } - private static decodeUserChunk(bytes: Uint8Array): EnterpriseUser { - const message = Enterprise.User.decode(bytes) - const user: EnterpriseUser = { - enterprise_user_id: EnterpriseDataManager.toNumber(message.enterpriseUserId), - username: message.username || '', - } - if (message.status) user.status = message.status - if (message.nodeId != null) user.node_id = EnterpriseDataManager.toNumber(message.nodeId) - if (message.encryptedData) user.encrypted_data = message.encryptedData - if (message.fullName) user.full_name = message.fullName - if (message.jobTitle) user.job_title = message.jobTitle - if (message.lock != null) user.lock = message.lock - if (message.accountShareExpiration != null) { - user.account_share_expiration = EnterpriseDataManager.toNumber(message.accountShareExpiration) - } - if (message.tfaEnabled != null) user.tfa_enabled = message.tfaEnabled - return user - } + return { nodes, roles }; + } - private static decodeRoleChunk(bytes: Uint8Array): EnterpriseRole { - const message = Enterprise.Role.decode(bytes) - const out: EnterpriseRole = { role_id: EnterpriseDataManager.toNumber(message.roleId) } - if (message.nodeId != null) out.node_id = EnterpriseDataManager.toNumber(message.nodeId) - if (message.encryptedData) out.encrypted_data = message.encryptedData - if (message.visibleBelow != null) out.visible_below = message.visibleBelow - if (message.newUserInherit != null) out.new_user_inherit = message.newUserInherit - return out + public async getTreeKey(): Promise { + if (!this.treeKeyPromise) { + this.treeKeyPromise = this.fetchAndDecryptTreeKey(); } - - private static decodeRolePrivilegeChunk(bytes: Uint8Array): EnterpriseRolePrivilegeLink { - const message = Enterprise.RolePrivilege.decode(bytes) - const out: EnterpriseRolePrivilegeLink = { - role_id: EnterpriseDataManager.toNumber(message.roleId), - managed_node_id: EnterpriseDataManager.toNumber(message.managedNodeId), - } - if (message.privilegeType) out.privilege_type = message.privilegeType - return out + return this.treeKeyPromise; + } + + private async fetchAndDecryptTreeKey(): Promise { + let response: Enterprise.IGetEnterpriseDataKeysResponse; + try { + response = await this.auth.executeRest(getEnterpriseDataKeysMessage()); + } catch (err) { + logger.debug( + `enterprise/get_enterprise_data_keys failed: ${extractErrorMessage(err)}`, + ); + return null; } - private static decodeManagedNodeChunk(bytes: Uint8Array): EnterpriseRoleManagedNodeLink { - const message = Enterprise.ManagedNode.decode(bytes) - return { - role_id: EnterpriseDataManager.toNumber(message.roleId), - managed_node_id: EnterpriseDataManager.toNumber(message.managedNodeId), - cascade_node_management: message.cascadeNodeManagement === true, - } + const treeKey = response.treeKey; + if (!treeKey || !treeKey.treeKey) { + logger.debug( + `enterprise/get_enterprise_data_keys: no tree key returned (user is likely not an enterprise admin)`, + ); + return null; } - private static decodeRoleEnforcementChunk(bytes: Uint8Array): EnterpriseRoleEnforcementLink { - const message = Enterprise.RoleEnforcement.decode(bytes) - const out: EnterpriseRoleEnforcementLink = { - role_id: EnterpriseDataManager.toNumber(message.roleId), - enforcement_type: message.enforcementType || '', + return this.decryptTreeKey(treeKey); + } + + private async decryptTreeKey( + treeKey: Enterprise.ITreeKey, + ): Promise { + if (!treeKey.treeKey) return null; + const encrypted = normal64Bytes(treeKey.treeKey); + const keyType = (treeKey.keyTypeId ?? 0) as Enterprise.BackupKeyType; + + try { + switch (keyType) { + case Enterprise.BackupKeyType.ENCRYPTED_BY_DATA_KEY: { + if (!this.auth.dataKey) return null; + return await platform.aesCbcDecrypt( + encrypted, + this.auth.dataKey, + true, + ); } - if (message.value) out.value = message.value - return out - } - - private static decodeTeamChunk(bytes: Uint8Array): EnterpriseTeamRecord { - const message = Enterprise.Team.decode(bytes) - const team: EnterpriseTeamRecord = { - team_uid: EnterpriseDataManager.toUid(message.teamUid), - name: message.name || '', - node_id: EnterpriseDataManager.toNumber(message.nodeId), - restrict_view: message.restrictView === true, - restrict_edit: message.restrictEdit === true, - restrict_share: message.restrictShare === true, + case Enterprise.BackupKeyType.ENCRYPTED_BY_DATA_KEY_GCM: { + if (!this.auth.dataKey) return null; + return await platform.aesGcmDecrypt(encrypted, this.auth.dataKey); } - if (message.encryptedData) team.encrypted_data = message.encryptedData - if (message.encryptedTeamKey) team.encrypted_team_key = message.encryptedTeamKey - return team - } - - private static decodeTeamUserChunk(bytes: Uint8Array): EnterpriseTeamUserLink { - const message = Enterprise.TeamUser.decode(bytes) - const teamUser: EnterpriseTeamUserLink = { - team_uid: EnterpriseDataManager.toUid(message.teamUid), - enterprise_user_id: EnterpriseDataManager.toNumber(message.enterpriseUserId), + case Enterprise.BackupKeyType.ENCRYPTED_BY_PUBLIC_KEY: { + if (!this.auth.privateKey) return null; + return platform.privateDecrypt(encrypted, this.auth.privateKey); } - if (message.userType) teamUser.user_type = message.userType - return teamUser - } - - private static decodeRoleUserChunk(bytes: Uint8Array): EnterpriseRoleUserLink { - const message = Enterprise.RoleUser.decode(bytes) - return { - role_id: EnterpriseDataManager.toNumber(message.roleId), - enterprise_user_id: EnterpriseDataManager.toNumber(message.enterpriseUserId), + case Enterprise.BackupKeyType.ENCRYPTED_BY_PUBLIC_KEY_ECC: { + if (!this.auth.eccPrivateKey) return null; + return await platform.privateDecryptEC( + encrypted, + this.auth.eccPrivateKey, + ); } + default: + logger.debug(`Unsupported tree-key keyTypeId=${keyType}`); + return null; + } + } catch (err) { + logger.debug( + `Tree-key decryption failed (keyTypeId=${keyType}): ${extractErrorMessage(err)}`, + ); + return null; } - - private static decodeRoleTeamChunk(bytes: Uint8Array): EnterpriseRoleTeamLink { - const message = Enterprise.RoleTeam.decode(bytes) - return { - role_id: EnterpriseDataManager.toNumber(message.roleId), - team_uid: EnterpriseDataManager.toUid(message.teamUid), - } + } + + private static async decryptDisplayName( + encrypted: string, + treeKey: Uint8Array, + ): Promise { + try { + const decrypted = await decryptObjectFromStorage<{ + displayname?: string; + }>(encrypted, treeKey); + return (decrypted?.displayname || "").trim(); + } catch { + return ""; } - - private static decodeQueuedTeamChunk(bytes: Uint8Array): EnterpriseQueuedTeamRecord { - const message = Enterprise.QueuedTeam.decode(bytes) - const queuedTeam: EnterpriseQueuedTeamRecord = { - team_uid: EnterpriseDataManager.toUid(message.teamUid), - name: message.name || '', - node_id: EnterpriseDataManager.toNumber(message.nodeId), - } - if (message.encryptedData) queuedTeam.encrypted_data = message.encryptedData - return queuedTeam + } + + private static toNumber(value: LongLike): number { + if (value == null) return 0; + if (isNumber(value)) return value; + if (typeof value.toNumber === "function") return value.toNumber(); + const parsed = Number(value.toString()); + return Number.isFinite(parsed) ? parsed : 0; + } + + private static toUid(bytes: Uint8Array | null | undefined): string { + return bytes && bytes.length > 0 ? webSafe64FromBytes(bytes) : ""; + } + + private static decodeChunk( + chunkData: Uint8Array[] | null | undefined, + decoder: (bytes: Uint8Array) => T, + ): T[] { + if (!chunkData || chunkData.length === 0) return []; + const results: T[] = []; + for (const bytes of chunkData) { + try { + results.push(decoder(bytes)); + } catch {} } - - private static decodeUserAliasChunk(bytes: Uint8Array): EnterpriseUserAliasLink { - const message = Enterprise.UserAlias.decode(bytes) - return { - enterprise_user_id: EnterpriseDataManager.toNumber(message.enterpriseUserId), - username: message.username || '', - } + return results; + } + + private static decodeNodeChunk(bytes: Uint8Array): EnterpriseNode { + const message = Enterprise.Node.decode(bytes); + const node: EnterpriseNode = { + node_id: EnterpriseDataManager.toNumber(message.nodeId), + }; + if (message.parentId != null) + node.parent_id = EnterpriseDataManager.toNumber(message.parentId); + if (message.encryptedData) node.encrypted_data = message.encryptedData; + return node; + } + + private static decodeUserChunk(bytes: Uint8Array): EnterpriseUser { + const message = Enterprise.User.decode(bytes); + const user: EnterpriseUser = { + enterprise_user_id: EnterpriseDataManager.toNumber( + message.enterpriseUserId, + ), + username: message.username || "", + }; + if (message.status) user.status = message.status; + if (message.nodeId != null) + user.node_id = EnterpriseDataManager.toNumber(message.nodeId); + if (message.encryptedData) user.encrypted_data = message.encryptedData; + if (message.fullName) user.full_name = message.fullName; + if (message.jobTitle) user.job_title = message.jobTitle; + if (message.lock != null) user.lock = message.lock; + if (message.accountShareExpiration != null) { + user.account_share_expiration = EnterpriseDataManager.toNumber( + message.accountShareExpiration, + ); } - - private static applyChunk( - chunk: Enterprise.IEnterpriseData, - target: GetEnterpriseDataResponse - ): void { - const entity = chunk.entity ?? Enterprise.EnterpriseDataEntity.UNKNOWN - const data = chunk.data || [] - - switch (entity) { - case Enterprise.EnterpriseDataEntity.NODES: - target.nodes = (target.nodes || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeNodeChunk) - ) - break - case Enterprise.EnterpriseDataEntity.USERS: - target.users = (target.users || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeUserChunk) - ) - break - case Enterprise.EnterpriseDataEntity.ROLES: - target.roles = (target.roles || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeRoleChunk) - ) - break - case Enterprise.EnterpriseDataEntity.TEAMS: - target.teams = (target.teams || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeTeamChunk) - ) - break - case Enterprise.EnterpriseDataEntity.TEAM_USERS: - target.team_users = (target.team_users || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeTeamUserChunk) - ) - break - case Enterprise.EnterpriseDataEntity.ROLE_USERS: - target.role_users = (target.role_users || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeRoleUserChunk) - ) - break - case Enterprise.EnterpriseDataEntity.ROLE_TEAMS: - target.role_teams = (target.role_teams || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeRoleTeamChunk) - ) - break - case Enterprise.EnterpriseDataEntity.QUEUED_TEAMS: - target.queued_teams = (target.queued_teams || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeQueuedTeamChunk) - ) - break - case Enterprise.EnterpriseDataEntity.QUEUED_TEAM_USERS: { - const flatLinks: EnterpriseQueuedTeamUserLink[] = [] - for (const bytes of data) { - try { - const message = Enterprise.QueuedTeamUser.decode(bytes) - const teamUid = EnterpriseDataManager.toUid(message.teamUid) - if (!teamUid) continue - for (const userId of message.users || []) { - flatLinks.push({ - team_uid: teamUid, - enterprise_user_id: EnterpriseDataManager.toNumber(userId), - }) - } - } catch {} - } - target.queued_team_users = (target.queued_team_users || []).concat(flatLinks) - break + if (message.tfaEnabled != null) user.tfa_enabled = message.tfaEnabled; + return user; + } + + private static decodeRoleChunk(bytes: Uint8Array): EnterpriseRole { + const message = Enterprise.Role.decode(bytes); + const out: EnterpriseRole = { + role_id: EnterpriseDataManager.toNumber(message.roleId), + }; + if (message.nodeId != null) + out.node_id = EnterpriseDataManager.toNumber(message.nodeId); + if (message.encryptedData) out.encrypted_data = message.encryptedData; + if (message.visibleBelow != null) out.visible_below = message.visibleBelow; + if (message.newUserInherit != null) + out.new_user_inherit = message.newUserInherit; + return out; + } + + private static decodeRolePrivilegeChunk( + bytes: Uint8Array, + ): EnterpriseRolePrivilegeLink { + const message = Enterprise.RolePrivilege.decode(bytes); + const out: EnterpriseRolePrivilegeLink = { + role_id: EnterpriseDataManager.toNumber(message.roleId), + managed_node_id: EnterpriseDataManager.toNumber(message.managedNodeId), + }; + if (message.privilegeType) out.privilege_type = message.privilegeType; + return out; + } + + private static decodeManagedNodeChunk( + bytes: Uint8Array, + ): EnterpriseRoleManagedNodeLink { + const message = Enterprise.ManagedNode.decode(bytes); + return { + role_id: EnterpriseDataManager.toNumber(message.roleId), + managed_node_id: EnterpriseDataManager.toNumber(message.managedNodeId), + cascade_node_management: message.cascadeNodeManagement === true, + }; + } + + private static decodeRoleEnforcementChunk( + bytes: Uint8Array, + ): EnterpriseRoleEnforcementLink { + const message = Enterprise.RoleEnforcement.decode(bytes); + const out: EnterpriseRoleEnforcementLink = { + role_id: EnterpriseDataManager.toNumber(message.roleId), + enforcement_type: message.enforcementType || "", + }; + if (message.value) out.value = message.value; + return out; + } + + private static decodeTeamChunk(bytes: Uint8Array): EnterpriseTeamRecord { + const message = Enterprise.Team.decode(bytes); + const team: EnterpriseTeamRecord = { + team_uid: EnterpriseDataManager.toUid(message.teamUid), + name: message.name || "", + node_id: EnterpriseDataManager.toNumber(message.nodeId), + restrict_view: message.restrictView === true, + restrict_edit: message.restrictEdit === true, + restrict_share: message.restrictShare === true, + }; + if (message.encryptedData) team.encrypted_data = message.encryptedData; + if (message.encryptedTeamKey) + team.encrypted_team_key = message.encryptedTeamKey; + return team; + } + + private static decodeTeamUserChunk( + bytes: Uint8Array, + ): EnterpriseTeamUserLink { + const message = Enterprise.TeamUser.decode(bytes); + const teamUser: EnterpriseTeamUserLink = { + team_uid: EnterpriseDataManager.toUid(message.teamUid), + enterprise_user_id: EnterpriseDataManager.toNumber( + message.enterpriseUserId, + ), + }; + if (message.userType) teamUser.user_type = message.userType; + return teamUser; + } + + private static decodeRoleUserChunk( + bytes: Uint8Array, + ): EnterpriseRoleUserLink { + const message = Enterprise.RoleUser.decode(bytes); + return { + role_id: EnterpriseDataManager.toNumber(message.roleId), + enterprise_user_id: EnterpriseDataManager.toNumber( + message.enterpriseUserId, + ), + }; + } + + private static decodeRoleTeamChunk( + bytes: Uint8Array, + ): EnterpriseRoleTeamLink { + const message = Enterprise.RoleTeam.decode(bytes); + return { + role_id: EnterpriseDataManager.toNumber(message.roleId), + team_uid: EnterpriseDataManager.toUid(message.teamUid), + }; + } + + private static decodeQueuedTeamChunk( + bytes: Uint8Array, + ): EnterpriseQueuedTeamRecord { + const message = Enterprise.QueuedTeam.decode(bytes); + const queuedTeam: EnterpriseQueuedTeamRecord = { + team_uid: EnterpriseDataManager.toUid(message.teamUid), + name: message.name || "", + node_id: EnterpriseDataManager.toNumber(message.nodeId), + }; + if (message.encryptedData) + queuedTeam.encrypted_data = message.encryptedData; + return queuedTeam; + } + + private static decodeUserAliasChunk( + bytes: Uint8Array, + ): EnterpriseUserAliasLink { + const message = Enterprise.UserAlias.decode(bytes); + return { + enterprise_user_id: EnterpriseDataManager.toNumber( + message.enterpriseUserId, + ), + username: message.username || "", + }; + } + + private static applyChunk( + chunk: Enterprise.IEnterpriseData, + target: GetEnterpriseDataResponse, + ): void { + const entity = chunk.entity ?? Enterprise.EnterpriseDataEntity.UNKNOWN; + const data = chunk.data || []; + + switch (entity) { + case Enterprise.EnterpriseDataEntity.NODES: + target.nodes = (target.nodes || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeNodeChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.USERS: + target.users = (target.users || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeUserChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.ROLES: + target.roles = (target.roles || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeRoleChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.TEAMS: + target.teams = (target.teams || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeTeamChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.TEAM_USERS: + target.team_users = (target.team_users || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeTeamUserChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.ROLE_USERS: + target.role_users = (target.role_users || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeRoleUserChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.ROLE_TEAMS: + target.role_teams = (target.role_teams || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeRoleTeamChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.QUEUED_TEAMS: + target.queued_teams = (target.queued_teams || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeQueuedTeamChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.QUEUED_TEAM_USERS: { + const flatLinks: EnterpriseQueuedTeamUserLink[] = []; + for (const bytes of data) { + try { + const message = Enterprise.QueuedTeamUser.decode(bytes); + const teamUid = EnterpriseDataManager.toUid(message.teamUid); + if (!teamUid) continue; + for (const userId of message.users || []) { + flatLinks.push({ + team_uid: teamUid, + enterprise_user_id: EnterpriseDataManager.toNumber(userId), + }); } - case Enterprise.EnterpriseDataEntity.USER_ALIASES: - target.user_aliases = (target.user_aliases || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeUserAliasChunk) - ) - break - case Enterprise.EnterpriseDataEntity.ROLE_PRIVILEGES: - target.role_privileges = (target.role_privileges || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeRolePrivilegeChunk) - ) - break - case Enterprise.EnterpriseDataEntity.MANAGED_NODES: - target.managed_nodes = (target.managed_nodes || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeManagedNodeChunk) - ) - break - case Enterprise.EnterpriseDataEntity.ROLE_ENFORCEMENTS: - target.role_enforcements = (target.role_enforcements || []).concat( - EnterpriseDataManager.decodeChunk(data, EnterpriseDataManager.decodeRoleEnforcementChunk) - ) - break - default: - break + } catch {} } + target.queued_team_users = (target.queued_team_users || []).concat( + flatLinks, + ); + break; + } + case Enterprise.EnterpriseDataEntity.USER_ALIASES: + target.user_aliases = (target.user_aliases || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeUserAliasChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.ROLE_PRIVILEGES: + target.role_privileges = (target.role_privileges || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeRolePrivilegeChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.MANAGED_NODES: + target.managed_nodes = (target.managed_nodes || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeManagedNodeChunk, + ), + ); + break; + case Enterprise.EnterpriseDataEntity.ROLE_ENFORCEMENTS: + target.role_enforcements = (target.role_enforcements || []).concat( + EnterpriseDataManager.decodeChunk( + data, + EnterpriseDataManager.decodeRoleEnforcementChunk, + ), + ); + break; + default: + break; } -} \ No newline at end of file + } +} diff --git a/KeeperSdk/src/teams/listTeams.ts b/KeeperSdk/src/teams/listTeams.ts index 391238aa..21a660b4 100644 --- a/KeeperSdk/src/teams/listTeams.ts +++ b/KeeperSdk/src/teams/listTeams.ts @@ -1,373 +1,437 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { isNumber, TOKEN_SEPARATOR_PATTERN } from '../utils' +import type { Auth } from "@keeper-security/keeperapi"; +import { isNumber, TOKEN_SEPARATOR_PATTERN } from "../utils"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type DecryptedRoleNames, - type EnterpriseDisplayNames, - type EnterpriseNode, - type EnterpriseRole, - type EnterpriseTeamRecord, - type EnterpriseTeamUserLink, - type EnterpriseUser, - type GetEnterpriseDataResponse, -} from './enterpriseData' + EnterpriseDataInclude, + EnterpriseDataManager, + type DecryptedRoleNames, + type EnterpriseDisplayNames, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseTeamRecord, + type EnterpriseTeamUserLink, + type EnterpriseUser, + type GetEnterpriseDataResponse, +} from "./enterpriseData"; export enum TeamColumn { - Restricts = 'restricts', - Node = 'node', - UserCount = 'user_count', - Users = 'users', - RoleCount = 'role_count', - Roles = 'roles', + Restricts = "restricts", + Node = "node", + UserCount = "user_count", + Users = "users", + RoleCount = "role_count", + Roles = "roles", } -export type TeamColumnInput = TeamColumn | `${TeamColumn}` +export type TeamColumnInput = TeamColumn | `${TeamColumn}`; -export const SUPPORTED_TEAM_COLUMNS: readonly TeamColumn[] = Object.values(TeamColumn) +export const SUPPORTED_TEAM_COLUMNS: readonly TeamColumn[] = + Object.values(TeamColumn); -export const DEFAULT_TEAM_COLUMNS: readonly TeamColumn[] = [ - TeamColumn.Restricts, - TeamColumn.Node, - TeamColumn.UserCount, - TeamColumn.RoleCount, -] +export const DEFAULT_TEAM_COLUMNS: readonly TeamColumn[] = []; -const NODE_PATH_SEPARATOR = '\\' -const MIN_ASCII_COL_WIDTH = 2 -const ALL_COLUMNS_WILDCARD = '*' +const NODE_PATH_SEPARATOR = "\\"; +const MIN_ASCII_COL_WIDTH = 2; +const ALL_COLUMNS_WILDCARD = "*"; const HEADER_BY_COLUMN: Record = { - [TeamColumn.Restricts]: 'Restricts', - [TeamColumn.Node]: 'Node', - [TeamColumn.UserCount]: 'User Count', - [TeamColumn.Users]: 'Users', - [TeamColumn.RoleCount]: 'Role Count', - [TeamColumn.Roles]: 'Roles', -} + [TeamColumn.Restricts]: "Restricts", + [TeamColumn.Node]: "Node", + [TeamColumn.UserCount]: "User Count", + [TeamColumn.Users]: "Users", + [TeamColumn.RoleCount]: "Role Count", + [TeamColumn.Roles]: "Roles", +}; export type ListTeamsOptions = { - pattern?: string | null - columns?: TeamColumnInput[] | typeof ALL_COLUMNS_WILDCARD | string | null -} + pattern?: string | null; + columns?: TeamColumnInput[] | typeof ALL_COLUMNS_WILDCARD | string | null; +}; export type ListTeamRow = { - team_uid: string - name: string - restricts?: string - node?: string - user_count?: number - users?: string[] - role_count?: number - roles?: string[] -} + team_uid: string; + name: string; + /** Enterprise / company name (Commander list-team "Company" column). */ + company?: string; + restricts?: string; + node?: string; + user_count?: number; + users?: string[]; + role_count?: number; + roles?: string[]; +}; export type FormattedTeamsTable = { - headers: string[] - rows: string[][] -} + headers: string[]; + rows: string[][]; +}; export type FormatTeamsTableOptions = { - columns?: ListTeamsOptions['columns'] -} + columns?: ListTeamsOptions["columns"]; + usersColumnTitle?: string; +}; type DecorateContext = { - nodePaths: Map - teamUsers: Map> - roleTeams: Map> - usernameById: Map - roleNameById: Map -} + nodePaths: Map; + teamUsers: Map>; + roleTeams: Map>; + usernameById: Map; + roleNameById: Map; +}; export function formatTeamRestricts(team: EnterpriseTeamRecord): string { - const r = team.restrict_view === true ? 'R ' : ' ' - const w = team.restrict_edit === true ? 'W ' : ' ' - const restrictShare = team.restrict_share === true || team.restrict_sharing === true - const s = restrictShare ? 'S' : ' ' - return r + w + s + const r = team.restrict_view === true ? "R " : " "; + const w = team.restrict_edit === true ? "W " : " "; + const restrictShare = + team.restrict_share === true || team.restrict_sharing === true; + const s = restrictShare ? "S" : " "; + return r + w + s; } -export async function listTeams(auth: Auth, options: ListTeamsOptions = {}): Promise { - const columns = resolveColumns(options.columns) - const includes = includesForColumns(columns) - const wantsDisplayNames = columns.includes(TeamColumn.Node) || columns.includes(TeamColumn.Roles) - - const enterpriseData = new EnterpriseDataManager(auth) - const emptyDisplayNames: EnterpriseDisplayNames = { nodes: new Map(), roles: new Map() } - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(includes), - wantsDisplayNames ? enterpriseData.getDisplayNames() : Promise.resolve(emptyDisplayNames), - ]) - - const teams = response.teams || [] - const nodes = response.nodes || [] - applyDecryptedNodeNames(nodes, displayNames.nodes) - - const context: DecorateContext = { - nodePaths: buildNodePathLookup(nodes), - teamUsers: buildTeamUserMap(response.team_users || []), - roleTeams: buildRoleTeamMap(response), - usernameById: buildUserUsernameMap(response.users || []), - roleNameById: buildRoleNameMap(response.roles || [], displayNames.roles), - } - - const pattern = options.pattern?.trim() || null - const rows: ListTeamRow[] = [] - for (const team of teams) { - const row: ListTeamRow = { - team_uid: team.team_uid, - name: teamDisplayName(team), - } - decorateRow(row, team, columns, context) - if (pattern && !rowMatchesPattern(row, pattern)) continue - rows.push(row) - } - - rows.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) - return rows +export async function listTeams( + auth: Auth, + options: ListTeamsOptions = {}, +): Promise { + const columns = resolveColumns(options.columns); + const includes = includesForColumns(columns); + const wantsDisplayNames = + columns.includes(TeamColumn.Node) || columns.includes(TeamColumn.Roles); + + const enterpriseData = new EnterpriseDataManager(auth); + const emptyDisplayNames: EnterpriseDisplayNames = { + nodes: new Map(), + roles: new Map(), + }; + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(includes), + wantsDisplayNames + ? enterpriseData.getDisplayNames() + : Promise.resolve(emptyDisplayNames), + ]); + + const teams = response.teams || []; + const nodes = response.nodes || []; + applyDecryptedNodeNames(nodes, displayNames.nodes); + + const context: DecorateContext = { + nodePaths: buildNodePathLookup(nodes), + teamUsers: buildTeamUserMap(response.team_users || []), + roleTeams: buildRoleTeamMap(response), + usernameById: buildUserUsernameMap(response.users || []), + roleNameById: buildRoleNameMap(response.roles || [], displayNames.roles), + }; + + const pattern = options.pattern?.trim() || null; + const company = (response.enterprise_name || "").trim(); + const rows: ListTeamRow[] = []; + for (const team of teams) { + const row: ListTeamRow = { + team_uid: team.team_uid, + name: teamDisplayName(team), + company, + }; + decorateRow(row, team, columns, context); + if (pattern && !rowMatchesPattern(row, pattern)) continue; + rows.push(row); + } + + rows.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + return rows; } export function formatTeamsTable( - rows: ListTeamRow[], - options: FormatTeamsTableOptions = {} + rows: ListTeamRow[], + options: FormatTeamsTableOptions = {}, ): FormattedTeamsTable { - const columns = resolveColumns(options.columns) - const headers: string[] = ['#', 'Team UID', 'Name', ...columns.map((column) => HEADER_BY_COLUMN[column])] - - const outRows: string[][] = rows.map((row, rowIndex) => { - const cells: string[] = [String(rowIndex + 1), row.team_uid, row.name] - for (const column of columns) cells.push(formatCell(row, column)) - return cells - }) - - return { headers, rows: outRows } + const columns = resolveColumns(options.columns); + const usersTitle = + options.usersColumnTitle?.trim() || HEADER_BY_COLUMN[TeamColumn.Users]; + const headers: string[] = [ + "#", + "Company", + "Team UID", + "Name", + ...columns.map((column) => + column === TeamColumn.Users ? usersTitle : HEADER_BY_COLUMN[column], + ), + ]; + + const outRows: string[][] = rows.map((row, rowIndex) => { + const cells: string[] = [ + String(rowIndex + 1), + row.company ?? "", + row.team_uid, + row.name, + ]; + for (const column of columns) cells.push(formatCell(row, column)); + return cells; + }); + + return { headers, rows: outRows }; } export function renderTeamsAsciiTable( - table: FormattedTeamsTable, - options: { minColWidth?: number } = {} + table: FormattedTeamsTable, + options: { minColWidth?: number } = {}, ): string { - const { minColWidth = MIN_ASCII_COL_WIDTH } = options - const { headers, rows } = table - const columnCount = headers.length - - const expandedRows: string[][][] = rows.map((row) => - row.map((cell) => (cell.includes('\n') ? cell.split('\n') : [cell])) - ) - - const columnWidths = new Array(columnCount).fill(0) + const { minColWidth = MIN_ASCII_COL_WIDTH } = options; + const { headers, rows } = table; + const columnCount = headers.length; + + const expandedRows: string[][][] = rows.map((row) => + row.map((cell) => (cell.includes("\n") ? cell.split("\n") : [cell])), + ); + + const columnWidths = new Array(columnCount).fill(0); + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + columnWidths[columnIndex] = Math.max( + headers[columnIndex].length, + minColWidth, + ); + } + for (const row of expandedRows) { for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { - columnWidths[columnIndex] = Math.max(headers[columnIndex].length, minColWidth) + for (const line of row[columnIndex]) { + columnWidths[columnIndex] = Math.max( + columnWidths[columnIndex], + line.length, + minColWidth, + ); + } } - for (const row of expandedRows) { - for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { - for (const line of row[columnIndex]) { - columnWidths[columnIndex] = Math.max(columnWidths[columnIndex], line.length, minColWidth) - } - } - } - - const padCell = (cell: string, columnIndex: number): string => - cell + ' '.repeat(columnWidths[columnIndex] - cell.length) - const formatPhysicalRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - const ruleRow = formatPhysicalRow(columnWidths.map((width) => '-'.repeat(width))) - const lines: string[] = [formatPhysicalRow(headers), ruleRow] - - for (const row of expandedRows) { - const physicalLineCount = Math.max(...row.map((cell) => cell.length)) - for (let lineIndex = 0; lineIndex < physicalLineCount; lineIndex += 1) { - const physicalCells: string[] = row.map((cell) => cell[lineIndex] ?? '') - lines.push(formatPhysicalRow(physicalCells)) - } + } + + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(columnWidths[columnIndex] - cell.length); + const formatPhysicalRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + const ruleRow = formatPhysicalRow( + columnWidths.map((width) => "-".repeat(width)), + ); + const lines: string[] = [formatPhysicalRow(headers), ruleRow]; + + for (const row of expandedRows) { + const physicalLineCount = Math.max(...row.map((cell) => cell.length)); + for (let lineIndex = 0; lineIndex < physicalLineCount; lineIndex += 1) { + const physicalCells: string[] = row.map((cell) => cell[lineIndex] ?? ""); + lines.push(formatPhysicalRow(physicalCells)); } - return lines.join('\n') + } + return lines.join("\n"); } -function includesForColumns(columns: readonly TeamColumn[]): EnterpriseDataInclude[] { - const set = new Set([EnterpriseDataInclude.Teams]) - for (const column of columns) { - switch (column) { - case TeamColumn.Node: - set.add(EnterpriseDataInclude.Nodes) - break - case TeamColumn.UserCount: - case TeamColumn.Users: - set.add(EnterpriseDataInclude.TeamUsers) - if (column === TeamColumn.Users) set.add(EnterpriseDataInclude.Users) - break - case TeamColumn.RoleCount: - case TeamColumn.Roles: - set.add(EnterpriseDataInclude.RoleTeams) - if (column === TeamColumn.Roles) set.add(EnterpriseDataInclude.Roles) - break - } +function includesForColumns( + columns: readonly TeamColumn[], +): EnterpriseDataInclude[] { + const set = new Set([EnterpriseDataInclude.Teams]); + for (const column of columns) { + switch (column) { + case TeamColumn.Node: + set.add(EnterpriseDataInclude.Nodes); + break; + case TeamColumn.UserCount: + case TeamColumn.Users: + set.add(EnterpriseDataInclude.TeamUsers); + if (column === TeamColumn.Users) set.add(EnterpriseDataInclude.Users); + break; + case TeamColumn.RoleCount: + case TeamColumn.Roles: + set.add(EnterpriseDataInclude.RoleTeams); + if (column === TeamColumn.Roles) set.add(EnterpriseDataInclude.Roles); + break; } - return Array.from(set) + } + return Array.from(set); } -function resolveColumns(input: ListTeamsOptions['columns']): TeamColumn[] { - if (input == null) return [...DEFAULT_TEAM_COLUMNS] - if (input === ALL_COLUMNS_WILDCARD) return [...SUPPORTED_TEAM_COLUMNS] - - const requested = Array.isArray(input) ? input : input.split(',').map((part) => part.trim()) - const allowed = new Set(SUPPORTED_TEAM_COLUMNS) - const seen = new Set() - for (const column of requested) { - if (column && allowed.has(column)) seen.add(column as TeamColumn) - } - if (seen.size === 0) return [...DEFAULT_TEAM_COLUMNS] - return SUPPORTED_TEAM_COLUMNS.filter((column) => seen.has(column)) +function resolveColumns(input: ListTeamsOptions["columns"]): TeamColumn[] { + if (input == null) return [...DEFAULT_TEAM_COLUMNS]; + if (input === ALL_COLUMNS_WILDCARD) return [...SUPPORTED_TEAM_COLUMNS]; + + const requested = Array.isArray(input) + ? input + : input.split(",").map((part) => part.trim()); + const allowed = new Set(SUPPORTED_TEAM_COLUMNS); + const seen = new Set(); + for (const column of requested) { + if (column && allowed.has(column)) seen.add(column as TeamColumn); + } + if (seen.size === 0) return [...DEFAULT_TEAM_COLUMNS]; + return SUPPORTED_TEAM_COLUMNS.filter((column) => seen.has(column)); } function teamDisplayName(team: { name?: string; team_uid: string }): string { - return (team.name || team.team_uid || '').trim() || team.team_uid + return (team.name || team.team_uid || "").trim() || team.team_uid; } function tokenize(text: string): string[] { - return text.split(TOKEN_SEPARATOR_PATTERN).filter((token) => token.length > 0) + return text + .split(TOKEN_SEPARATOR_PATTERN) + .filter((token) => token.length > 0); } function rowMatchesPattern(row: ListTeamRow, pattern: string): boolean { - const lowered = pattern.toLowerCase() - const tokens: string[] = [] - tokens.push(row.team_uid.toLowerCase()) - tokens.push(...tokenize(row.name.toLowerCase())) - if (row.restricts) tokens.push(row.restricts.toLowerCase()) - if (row.node) tokens.push(...tokenize(row.node.toLowerCase())) - if (row.user_count != null) tokens.push(String(row.user_count)) - if (row.role_count != null) tokens.push(String(row.role_count)) - for (const list of [row.users, row.roles]) { - if (!list) continue - for (const value of list) tokens.push(...tokenize(value.toLowerCase())) - } - return tokens.some((token) => token.includes(lowered)) + const lowered = pattern.toLowerCase(); + const tokens: string[] = []; + tokens.push(row.team_uid.toLowerCase()); + if (row.company) tokens.push(...tokenize(row.company.toLowerCase())); + tokens.push(...tokenize(row.name.toLowerCase())); + if (row.restricts) tokens.push(row.restricts.toLowerCase()); + if (row.node) tokens.push(...tokenize(row.node.toLowerCase())); + if (row.user_count != null) tokens.push(String(row.user_count)); + if (row.role_count != null) tokens.push(String(row.role_count)); + for (const list of [row.users, row.roles]) { + if (!list) continue; + for (const value of list) tokens.push(...tokenize(value.toLowerCase())); + } + return tokens.some((token) => token.includes(lowered)); } -function applyDecryptedNodeNames(nodes: EnterpriseNode[], decrypted: Map): void { - if (decrypted.size === 0) return - for (const node of nodes) { - const display = decrypted.get(node.node_id) - if (display) node.displayName = display - } +function applyDecryptedNodeNames( + nodes: EnterpriseNode[], + decrypted: Map, +): void { + if (decrypted.size === 0) return; + for (const node of nodes) { + const display = decrypted.get(node.node_id); + if (display) node.displayName = display; + } } function buildNodePathLookup(nodes: EnterpriseNode[]): Map { - return new Map( - nodes.map((node) => [ - node.node_id, - EnterpriseDataManager.getNodePath(nodes, node.node_id, { separator: NODE_PATH_SEPARATOR }), - ]) - ) + return new Map( + nodes.map((node) => [ + node.node_id, + EnterpriseDataManager.getNodePath(nodes, node.node_id, { + separator: NODE_PATH_SEPARATOR, + }), + ]), + ); } -function buildTeamUserMap(links: EnterpriseTeamUserLink[]): Map> { - const byTeam = new Map>() - for (const link of links) { - if (!link.team_uid) continue - const set = byTeam.get(link.team_uid) - if (set) set.add(link.enterprise_user_id) - else byTeam.set(link.team_uid, new Set([link.enterprise_user_id])) - } - return byTeam +function buildTeamUserMap( + links: EnterpriseTeamUserLink[], +): Map> { + const byTeam = new Map>(); + for (const link of links) { + if (!link.team_uid) continue; + const set = byTeam.get(link.team_uid); + if (set) set.add(link.enterprise_user_id); + else byTeam.set(link.team_uid, new Set([link.enterprise_user_id])); + } + return byTeam; } -function buildRoleTeamMap(response: GetEnterpriseDataResponse): Map> { - const map = new Map>() - for (const link of response.role_teams || []) { - if (!link.team_uid) continue - const set = map.get(link.team_uid) - if (set) set.add(link.role_id) - else map.set(link.team_uid, new Set([link.role_id])) - } - return map +function buildRoleTeamMap( + response: GetEnterpriseDataResponse, +): Map> { + const map = new Map>(); + for (const link of response.role_teams || []) { + if (!link.team_uid) continue; + const set = map.get(link.team_uid); + if (set) set.add(link.role_id); + else map.set(link.team_uid, new Set([link.role_id])); + } + return map; } function buildUserUsernameMap(users: EnterpriseUser[]): Map { - const map = new Map() - for (const user of users) { - if (isNumber(user.enterprise_user_id ) && user.username) { - map.set(user.enterprise_user_id, user.username) - } + const map = new Map(); + for (const user of users) { + if (isNumber(user.enterprise_user_id) && user.username) { + map.set(user.enterprise_user_id, user.username); } - return map + } + return map; } -function buildRoleNameMap(roles: EnterpriseRole[], decrypted: DecryptedRoleNames): Map { - const map = new Map() - for (const role of roles) { - if (isNumber(role.role_id)) continue - const display = (role.displayName || decrypted.get(role.role_id) || '').trim() - map.set(role.role_id, display || String(role.role_id)) - } - return map +function buildRoleNameMap( + roles: EnterpriseRole[], + decrypted: DecryptedRoleNames, +): Map { + const map = new Map(); + for (const role of roles) { + if (isNumber(role.role_id)) continue; + const display = ( + role.displayName || + decrypted.get(role.role_id) || + "" + ).trim(); + map.set(role.role_id, display || String(role.role_id)); + } + return map; } -function resolveSortedNames(ids: Set, nameById: Map): string[] { - const result: string[] = [] - for (const id of ids) { - const name = nameById.get(id) - if (name) result.push(name) - } - result.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) - return result +function resolveSortedNames( + ids: Set, + nameById: Map, +): string[] { + const result: string[] = []; + for (const id of ids) { + const name = nameById.get(id); + if (name) result.push(name); + } + result.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + return result; } function decorateRow( - row: ListTeamRow, - team: EnterpriseTeamRecord, - columns: readonly TeamColumn[], - context: DecorateContext + row: ListTeamRow, + team: EnterpriseTeamRecord, + columns: readonly TeamColumn[], + context: DecorateContext, ): void { - const { nodePaths, teamUsers, roleTeams, usernameById, roleNameById } = context - - for (const column of columns) { - switch (column) { - case TeamColumn.Restricts: - row.restricts = formatTeamRestricts(team) - break - case TeamColumn.Node: - row.node = nodePaths.get(team.node_id) || '' - break - case TeamColumn.UserCount: - row.user_count = teamUsers.get(team.team_uid)?.size ?? 0 - break - case TeamColumn.Users: { - const ids = teamUsers.get(team.team_uid) - row.users = ids ? resolveSortedNames(ids, usernameById) : [] - break - } - case TeamColumn.RoleCount: - row.role_count = roleTeams.get(team.team_uid)?.size ?? 0 - break - case TeamColumn.Roles: { - const ids = roleTeams.get(team.team_uid) - row.roles = ids ? resolveSortedNames(ids, roleNameById) : [] - break - } - } + const { nodePaths, teamUsers, roleTeams, usernameById, roleNameById } = + context; + + for (const column of columns) { + switch (column) { + case TeamColumn.Restricts: + row.restricts = formatTeamRestricts(team); + break; + case TeamColumn.Node: + row.node = nodePaths.get(team.node_id) || ""; + break; + case TeamColumn.UserCount: + row.user_count = teamUsers.get(team.team_uid)?.size ?? 0; + break; + case TeamColumn.Users: { + const ids = teamUsers.get(team.team_uid); + row.users = ids ? resolveSortedNames(ids, usernameById) : []; + break; + } + case TeamColumn.RoleCount: + row.role_count = roleTeams.get(team.team_uid)?.size ?? 0; + break; + case TeamColumn.Roles: { + const ids = roleTeams.get(team.team_uid); + row.roles = ids ? resolveSortedNames(ids, roleNameById) : []; + break; + } } + } } function formatListCell(values: string[] | undefined): string { - if (!values || values.length === 0) return '' - return values.join('\n') + if (!values || values.length === 0) return ""; + return values.join("\n"); } function formatCell(row: ListTeamRow, column: TeamColumn): string { - switch (column) { - case TeamColumn.Restricts: - return row.restricts ?? '' - case TeamColumn.Node: - return row.node ?? '' - case TeamColumn.UserCount: - return row.user_count == null ? '' : String(row.user_count) - case TeamColumn.Users: - return formatListCell(row.users) - case TeamColumn.RoleCount: - return row.role_count == null ? '' : String(row.role_count) - case TeamColumn.Roles: - return formatListCell(row.roles) - } + switch (column) { + case TeamColumn.Restricts: + return row.restricts ?? ""; + case TeamColumn.Node: + return row.node ?? ""; + case TeamColumn.UserCount: + return row.user_count == null ? "" : String(row.user_count); + case TeamColumn.Users: + return formatListCell(row.users); + case TeamColumn.RoleCount: + return row.role_count == null ? "" : String(row.role_count); + case TeamColumn.Roles: + return formatListCell(row.roles); + } } diff --git a/KeeperSdk/src/teams/teamRole.ts b/KeeperSdk/src/teams/teamRole.ts new file mode 100644 index 00000000..d72bd69a --- /dev/null +++ b/KeeperSdk/src/teams/teamRole.ts @@ -0,0 +1,237 @@ +import { + type Auth, + createInMessage, + Enterprise, + normal64Bytes, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRole, + type EnterpriseRoleTeamLink, +} from "./enterpriseData"; +import { + applyDecryptedRoleNames, + resolveExistingRoles, +} from "../roles/roleUtils"; +import { resolveExistingTeams } from "./teamUtils"; + +const TEAM_ROLE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleTeams, +]; + +export type ChangeTeamRolesInput = { + teams: string[]; + addRoles?: string[]; + removeRoles?: string[]; +}; + +export enum TeamRoleStatus { + Added = "added", + Removed = "removed", + Skipped = "skipped", + Failed = "failed", +} + +export type TeamRoleItemResult = { + status: TeamRoleStatus; + roleName: string; + roleId: number; + teamName: string; + teamUid: string; + message?: string; +}; + +export type TeamRoleResult = { + success: boolean; + items: TeamRoleItemResult[]; + succeeded: number; + failed: number; + skipped: number; +}; + +type ResolvedTeam = { team_uid: string; name: string }; + +async function loadTeamRoleContext(auth: Auth, teamIds: string[]) { + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(TEAM_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + const roles = response.roles || []; + applyDecryptedRoleNames(roles, displayNames.roles); + const teams = resolveExistingTeams(response.teams || [], teamIds); + return { teams, allRoles: roles, roleTeams: response.role_teams || [] }; +} + +function existingRoleTeamLinks( + roleTeams: EnterpriseRoleTeamLink[], + teamUid: string, +): Set { + const ids = new Set(); + for (const link of roleTeams) { + if (link.team_uid === teamUid) ids.add(link.role_id); + } + return ids; +} + +function expandRemoveRoles( + removeRoles: string[], + roleTeams: EnterpriseRoleTeamLink[], + teamUid: string, + allRoles: EnterpriseRole[], +): EnterpriseRole[] { + if (removeRoles.some((role) => role.trim().toLowerCase() === "@all")) { + const ids = existingRoleTeamLinks(roleTeams, teamUid); + return allRoles.filter((role) => ids.has(role.role_id)); + } + return resolveExistingRoles(allRoles, removeRoles); +} + +async function sendRoleTeamBatch( + auth: Auth, + links: Array<{ roleId: number; teamUid: string }>, + add: boolean, +): Promise { + if (links.length === 0) return; + const payload = Enterprise.RoleTeams.create({ + roleTeam: links.map((link) => + Enterprise.RoleTeam.create({ + roleId: link.roleId, + teamUid: normal64Bytes(link.teamUid), + }), + ), + }); + const path = add ? "enterprise/role_team_add" : "enterprise/role_team_remove"; + const message = createInMessage(payload, path, Enterprise.RoleTeams); + await auth.executeRestAction(message); +} + +function roleDisplayName(role: { + role_id: number; + displayName?: string; +}): string { + return (role.displayName || "").trim() || String(role.role_id); +} + +export async function changeTeamRoles( + auth: Auth, + input: ChangeTeamRolesInput, +): Promise { + const teamIds = (input.teams || []).map((t) => t.trim()).filter(Boolean); + const addRoleIds = (input.addRoles || []) + .map((r) => r.trim()) + .filter(Boolean); + const removeRoleIds = (input.removeRoles || []) + .map((r) => r.trim()) + .filter(Boolean); + + if (teamIds.length === 0) { + throw new KeeperSdkError( + "No teams specified.", + ResultCodes.NO_TEAMS_TO_UPDATE, + ); + } + if (addRoleIds.length === 0 && removeRoleIds.length === 0) { + throw new KeeperSdkError("No roles specified.", ResultCodes.ROLE_NOT_FOUND); + } + + const ctx = await loadTeamRoleContext(auth, teamIds); + const items: TeamRoleItemResult[] = []; + const toAdd: Array<{ roleId: number; teamUid: string }> = []; + const toRemove: Array<{ roleId: number; teamUid: string }> = []; + + for (const team of ctx.teams as ResolvedTeam[]) { + const teamName = team.name || team.team_uid; + const linkedRoleIds = existingRoleTeamLinks(ctx.roleTeams, team.team_uid); + + if (addRoleIds.length > 0) { + const rolesToAdd = resolveExistingRoles(ctx.allRoles, addRoleIds); + for (const role of rolesToAdd) { + const base = { + roleName: roleDisplayName(role), + roleId: role.role_id, + teamName, + teamUid: team.team_uid, + }; + if (linkedRoleIds.has(role.role_id)) { + items.push({ + ...base, + status: TeamRoleStatus.Skipped, + message: "Role is already assigned to team", + }); + continue; + } + toAdd.push({ roleId: role.role_id, teamUid: team.team_uid }); + items.push({ ...base, status: TeamRoleStatus.Added }); + } + } + + if (removeRoleIds.length > 0) { + const rolesToRemove = expandRemoveRoles( + removeRoleIds, + ctx.roleTeams, + team.team_uid, + ctx.allRoles, + ); + for (const role of rolesToRemove) { + const base = { + roleName: roleDisplayName(role), + roleId: role.role_id, + teamName, + teamUid: team.team_uid, + }; + if (!linkedRoleIds.has(role.role_id)) { + items.push({ + ...base, + status: TeamRoleStatus.Skipped, + message: "Role is not assigned to team", + }); + continue; + } + toRemove.push({ roleId: role.role_id, teamUid: team.team_uid }); + items.push({ ...base, status: TeamRoleStatus.Removed }); + } + } + } + + try { + await sendRoleTeamBatch(auth, toRemove, false); + await sendRoleTeamBatch(auth, toAdd, true); + } catch (err) { + const message = extractErrorMessage(err); + for (const item of items) { + if ( + item.status === TeamRoleStatus.Added || + item.status === TeamRoleStatus.Removed + ) { + item.status = TeamRoleStatus.Failed; + item.message = message; + } + } + } + + let succeeded = 0; + let skipped = 0; + let failed = 0; + for (const item of items) { + if ( + item.status === TeamRoleStatus.Added || + item.status === TeamRoleStatus.Removed + ) + succeeded++; + else if (item.status === TeamRoleStatus.Skipped) skipped++; + else failed++; + } + + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + }; +} diff --git a/KeeperSdk/src/teams/teamUtils.ts b/KeeperSdk/src/teams/teamUtils.ts index 35cb89d9..ccd9acdf 100644 --- a/KeeperSdk/src/teams/teamUtils.ts +++ b/KeeperSdk/src/teams/teamUtils.ts @@ -1,205 +1,235 @@ -import { isNumber, KeeperSdkError, ResultCodes } from '../utils' -import { EnterpriseDataManager, type EnterpriseNode, type EnterpriseTeamRecord } from './enterpriseData' - -export const NODE_PATH_SEPARATOR = '\\' -export const MAX_TEAM_NAME_LENGTH = 100 -export const TEAM_TABLE_HEADERS = ['#', 'Status', 'Team Name', 'Team UID', 'Node ID', 'Detail'] +import { isNumber, KeeperSdkError, ResultCodes } from "../utils"; +import { + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseTeamRecord, +} from "./enterpriseData"; + +export const NODE_PATH_SEPARATOR = "\\"; +export const MAX_TEAM_NAME_LENGTH = 100; +export const TEAM_TABLE_HEADERS = [ + "#", + "Status", + "Team Name", + "Team UID", + "Node ID", + "Detail", +]; export function validateTeamName(name: string): void { - if (name.length > MAX_TEAM_NAME_LENGTH) { - throw new KeeperSdkError( - `Team name exceeds ${MAX_TEAM_NAME_LENGTH} characters: "${name.substring(0, 30)}..."`, - ResultCodes.TEAM_NAME_TOO_LONG - ) - } + if (name.length > MAX_TEAM_NAME_LENGTH) { + throw new KeeperSdkError( + `Team name exceeds ${MAX_TEAM_NAME_LENGTH} characters: "${name.substring(0, 30)}..."`, + ResultCodes.TEAM_NAME_TOO_LONG, + ); + } } export function resolveExistingTeams( - teams: EnterpriseTeamRecord[], - identifiers: string[], - queuedTeams: EnterpriseTeamRecord[] = [] + teams: EnterpriseTeamRecord[], + identifiers: string[], + queuedTeams: EnterpriseTeamRecord[] = [], ): EnterpriseTeamRecord[] { - const byUid = new Map() - const byLowerName = new Map() - for (const team of teams) { - if (!team.team_uid) continue - byUid.set(team.team_uid, team) - const key = (team.name || '').trim().toLowerCase() - if (!key) continue - const existing = byLowerName.get(key) - if (existing) existing.push(team) - else byLowerName.set(key, [team]) - } - for (const team of queuedTeams) { - if (!team.team_uid || byUid.has(team.team_uid)) continue - const resolved: EnterpriseTeamRecord = { - team_uid: team.team_uid, - name: team.name, - node_id: team.node_id ?? 0, - ...(team.encrypted_team_key ? { encrypted_team_key: team.encrypted_team_key } : {}), - } - byUid.set(team.team_uid, resolved) - const key = (team.name || '').trim().toLowerCase() - if (!key || byLowerName.has(key)) continue - byLowerName.set(key, [resolved]) - } - - const found = new Map() - const missing: string[] = [] - for (const identifier of identifiers) { - const uidMatch = byUid.get(identifier) - if (uidMatch) { - found.set(uidMatch.team_uid, uidMatch) - continue - } - const nameMatches = byLowerName.get(identifier.toLowerCase()) - if (!nameMatches || nameMatches.length === 0) { - missing.push(identifier) - continue - } - if (nameMatches.length > 1) { - throw new KeeperSdkError( - `Team name "${identifier}" is not unique. Use Team UID.`, - ResultCodes.MULTIPLE_TEAM_MATCHES - ) - } - found.set(nameMatches[0].team_uid, nameMatches[0]) - } - - if (missing.length > 0) { - throw new KeeperSdkError( - `Team name(s) "${missing.join(', ')}" could not be resolved.`, - ResultCodes.TEAM_NOT_FOUND - ) - } - return Array.from(found.values()) + const byUid = new Map(); + const byLowerName = new Map(); + for (const team of teams) { + if (!team.team_uid) continue; + byUid.set(team.team_uid, team); + const key = (team.name || "").trim().toLowerCase(); + if (!key) continue; + const existing = byLowerName.get(key); + if (existing) existing.push(team); + else byLowerName.set(key, [team]); + } + for (const team of queuedTeams) { + if (!team.team_uid || byUid.has(team.team_uid)) continue; + const resolved: EnterpriseTeamRecord = { + team_uid: team.team_uid, + name: team.name, + node_id: team.node_id ?? 0, + ...(team.encrypted_team_key + ? { encrypted_team_key: team.encrypted_team_key } + : {}), + }; + byUid.set(team.team_uid, resolved); + const key = (team.name || "").trim().toLowerCase(); + if (!key || byLowerName.has(key)) continue; + byLowerName.set(key, [resolved]); + } + + const found = new Map(); + const missing: string[] = []; + for (const identifier of identifiers) { + const uidMatch = byUid.get(identifier); + if (uidMatch) { + found.set(uidMatch.team_uid, uidMatch); + continue; + } + const nameMatches = byLowerName.get(identifier.toLowerCase()); + if (!nameMatches || nameMatches.length === 0) { + missing.push(identifier); + continue; + } + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Team name "${identifier}" is not unique. Use Team UID.`, + ResultCodes.MULTIPLE_TEAM_MATCHES, + ); + } + found.set(nameMatches[0].team_uid, nameMatches[0]); + } + + if (missing.length > 0) { + throw new KeeperSdkError( + `Team name(s) "${missing.join(", ")}" could not be resolved.`, + ResultCodes.TEAM_NOT_FOUND, + ); + } + return Array.from(found.values()); } -function buildNodeNotFoundMessage(nodes: EnterpriseNode[], requested: string): string { - const lines: string[] = [`Parent node "${requested}" was not found.`] - if (nodes.length === 0) { - lines.push('No enterprise nodes are visible to the current user.') - return lines.join(' ') - } - const sorted = [...nodes].sort((a, b) => a.node_id - b.node_id) - const visible = sorted.slice(0, 25) - lines.push('Available nodes (id parent_id name):') - for (const node of visible) { - const own = (node.displayName || '').trim() || '' - const parent = node.parent_id || 0 - lines.push(` ${node.node_id} ${parent} ${own}`) - } - if (sorted.length > visible.length) { - lines.push(` ...and ${sorted.length - visible.length} more`) - } - return lines.join('\n') +function buildNodeNotFoundMessage( + nodes: EnterpriseNode[], + requested: string, +): string { + const lines: string[] = [`Parent node "${requested}" was not found.`]; + if (nodes.length === 0) { + lines.push("No enterprise nodes are visible to the current user."); + return lines.join(" "); + } + const sorted = [...nodes].sort((a, b) => a.node_id - b.node_id); + const visible = sorted.slice(0, 25); + lines.push("Available nodes (id parent_id name):"); + for (const node of visible) { + const own = (node.displayName || "").trim() || ""; + const parent = node.parent_id || 0; + lines.push(` ${node.node_id} ${parent} ${own}`); + } + if (sorted.length > visible.length) { + lines.push(` ...and ${sorted.length - visible.length} more`); + } + return lines.join("\n"); } export function resolveParentNode( - nodes: EnterpriseNode[], - identifier: string | number | null + nodes: EnterpriseNode[], + identifier: string | number | null, ): EnterpriseNode { - if (identifier === null || identifier === undefined || identifier === '') { - const root = nodes.find((node) => !node.parent_id || node.parent_id === 0) - if (!root) { - throw new KeeperSdkError( - buildNodeNotFoundMessage(nodes, ''), - ResultCodes.PARENT_NODE_NOT_FOUND - ) - } - return root - } - - if (typeof identifier === 'number') { - if (!isNumber(identifier)) { - throw new KeeperSdkError( - `Parent node "${identifier}" is not a valid node id.`, - ResultCodes.PARENT_NODE_REQUIRED - ) - } - const byId = nodes.find((node) => node.node_id === identifier) - if (!byId) { - throw new KeeperSdkError( - buildNodeNotFoundMessage(nodes, String(identifier)), - ResultCodes.PARENT_NODE_NOT_FOUND - ) - } - return byId - } - - const trimmed = identifier.trim() - if (!trimmed) { - throw new KeeperSdkError('Parent node is required.', ResultCodes.PARENT_NODE_REQUIRED) - } - - const numeric = Number(trimmed) - if (Number.isFinite(numeric) && Number.isInteger(numeric)) { - const byId = nodes.find((node) => node.node_id === numeric) - if (byId) return byId - } - - const lowered = trimmed.toLowerCase() - const byExactName = nodes.filter((node) => (node.displayName || '').trim().toLowerCase() === lowered) - if (byExactName.length === 1) return byExactName[0] - if (byExactName.length > 1) { - throw new KeeperSdkError( - `Multiple nodes match name "${trimmed}". Specify the node id instead.`, - ResultCodes.MULTIPLE_PARENT_NODE_MATCHES - ) - } - - const byPath = nodes.filter((node) => { - const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { - omitRoot: false, - separator: NODE_PATH_SEPARATOR, - }) - return path.trim().toLowerCase() === lowered - }) - if (byPath.length === 1) return byPath[0] - if (byPath.length > 1) { - throw new KeeperSdkError( - `Multiple nodes match path "${trimmed}". Specify the node id instead.`, - ResultCodes.MULTIPLE_PARENT_NODE_MATCHES - ) - } - + if (identifier === null || identifier === undefined || identifier === "") { + const root = nodes.find((node) => !node.parent_id || node.parent_id === 0); + if (!root) { + throw new KeeperSdkError( + buildNodeNotFoundMessage(nodes, ""), + ResultCodes.PARENT_NODE_NOT_FOUND, + ); + } + return root; + } + + if (typeof identifier === "number") { + if (!isNumber(identifier)) { + throw new KeeperSdkError( + `Parent node "${identifier}" is not a valid node id.`, + ResultCodes.PARENT_NODE_REQUIRED, + ); + } + const byId = nodes.find((node) => node.node_id === identifier); + if (!byId) { + throw new KeeperSdkError( + buildNodeNotFoundMessage(nodes, String(identifier)), + ResultCodes.PARENT_NODE_NOT_FOUND, + ); + } + return byId; + } + + const trimmed = identifier.trim(); + if (!trimmed) { + throw new KeeperSdkError( + "Parent node is required.", + ResultCodes.PARENT_NODE_REQUIRED, + ); + } + + const numeric = Number(trimmed); + if (Number.isFinite(numeric) && Number.isInteger(numeric)) { + const byId = nodes.find((node) => node.node_id === numeric); + if (byId) return byId; + } + + const lowered = trimmed.toLowerCase(); + const byExactName = nodes.filter( + (node) => (node.displayName || "").trim().toLowerCase() === lowered, + ); + if (byExactName.length === 1) return byExactName[0]; + if (byExactName.length > 1) { throw new KeeperSdkError( - buildNodeNotFoundMessage(nodes, trimmed), - ResultCodes.PARENT_NODE_NOT_FOUND - ) + `Multiple nodes match name "${trimmed}". Specify the node id instead.`, + ResultCodes.MULTIPLE_PARENT_NODE_MATCHES, + ); + } + + const byPath = nodes.filter((node) => { + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }); + return path.trim().toLowerCase() === lowered; + }); + if (byPath.length === 1) return byPath[0]; + if (byPath.length > 1) { + throw new KeeperSdkError( + `Multiple nodes match path "${trimmed}". Specify the node id instead.`, + ResultCodes.MULTIPLE_PARENT_NODE_MATCHES, + ); + } + + throw new KeeperSdkError( + buildNodeNotFoundMessage(nodes, trimmed), + ResultCodes.PARENT_NODE_NOT_FOUND, + ); } -export function applyDecryptedNodeNames(nodes: EnterpriseNode[], decrypted: Map): void { - if (decrypted.size === 0) return - for (const node of nodes) { - const display = decrypted.get(node.node_id) - if (display) node.displayName = display - } +export function applyDecryptedNodeNames( + nodes: EnterpriseNode[], + decrypted: Map, +): void { + if (decrypted.size === 0) return; + for (const node of nodes) { + const display = decrypted.get(node.node_id); + if (display) node.displayName = display; + } } export function applyEnterpriseNameToRoot( - nodes: EnterpriseNode[], - enterpriseName: string | undefined + nodes: EnterpriseNode[], + enterpriseName: string | undefined, ): void { - const fallback = (enterpriseName || '').trim() - if (!fallback) return - for (const node of nodes) { - const isRoot = !node.parent_id || node.parent_id === 0 - if (!isRoot) continue - if (!(node.displayName || '').trim()) { - node.displayName = fallback - } - } + const fallback = (enterpriseName || "").trim(); + if (!fallback) return; + for (const node of nodes) { + const isRoot = !node.parent_id || node.parent_id === 0; + if (!isRoot) continue; + if (!(node.displayName || "").trim()) { + node.displayName = fallback; + } + } } -export function parentNeedsNameLookup(parent: string | number | null | undefined): boolean { - if (parent === undefined || parent === null || parent === '') return false - if (typeof parent === 'number') return false - const trimmed = parent.trim() - if (!trimmed) return false - const numeric = Number(trimmed) - if (Number.isFinite(numeric) && Number.isInteger(numeric) && trimmed === String(numeric)) { - return false - } - return true +export function parentNeedsNameLookup( + parent: string | number | null | undefined, +): boolean { + if (parent === undefined || parent === null || parent === "") return false; + if (typeof parent === "number") return false; + const trimmed = parent.trim(); + if (!trimmed) return false; + const numeric = Number(trimmed); + if ( + Number.isFinite(numeric) && + Number.isInteger(numeric) && + trimmed === String(numeric) + ) { + return false; + } + return true; } diff --git a/KeeperSdk/src/teams/updateTeam.ts b/KeeperSdk/src/teams/updateTeam.ts index 50056b28..d54c6701 100644 --- a/KeeperSdk/src/teams/updateTeam.ts +++ b/KeeperSdk/src/teams/updateTeam.ts @@ -1,231 +1,257 @@ -import type { Auth, KeeperResponse, RestCommand } from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import type { + Auth, + KeeperResponse, + RestCommand, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; +import { EnterpriseDataInclude, EnterpriseDataManager } from "./enterpriseData"; +import { TeamRestriction, type TeamRestrictionInput } from "./addTeam"; import { - EnterpriseDataInclude, - EnterpriseDataManager, -} from './enterpriseData' -import { TeamRestriction, type TeamRestrictionInput } from './addTeam' -import { - applyDecryptedNodeNames, - applyEnterpriseNameToRoot, - parentNeedsNameLookup, - resolveExistingTeams, - resolveParentNode, - TEAM_TABLE_HEADERS, - validateTeamName, -} from './teamUtils' - -const TEAM_UPDATE_COMMAND = 'team_update' + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveExistingTeams, + resolveParentNode, + TEAM_TABLE_HEADERS, + validateTeamName, +} from "./teamUtils"; + +const TEAM_UPDATE_COMMAND = "team_update"; const UPDATE_TEAM_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Nodes, - EnterpriseDataInclude.Teams, -] + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Teams, +]; export enum UpdateTeamStatus { - Updated = 'updated', - Failed = 'failed', + Updated = "updated", + Failed = "failed", } export type UpdateTeamInput = { - teams: string[] - name?: string - parent?: string | number | null - restrictEdit?: TeamRestrictionInput - restrictShare?: TeamRestrictionInput - restrictView?: TeamRestrictionInput -} + teams: string[]; + name?: string; + parent?: string | number | null; + restrictEdit?: TeamRestrictionInput; + restrictShare?: TeamRestrictionInput; + restrictView?: TeamRestrictionInput; +}; export type UpdateTeamItemResult = { - teamUid: string - teamName: string - nodeId: number - status: UpdateTeamStatus - message?: string -} + teamUid: string; + teamName: string; + nodeId: number; + status: UpdateTeamStatus; + message?: string; +}; export type UpdateTeamResult = { - success: boolean - items: UpdateTeamItemResult[] - updated: number - failed: number -} + success: boolean; + items: UpdateTeamItemResult[]; + updated: number; + failed: number; +}; type TeamUpdateRequestPayload = { - team_uid: string - team_name: string - node_id: number - restrict_edit: boolean - restrict_share: boolean - restrict_view: boolean -} + team_uid: string; + team_name: string; + node_id: number; + restrict_edit: boolean; + restrict_share: boolean; + restrict_view: boolean; +}; -export async function updateTeams(auth: Auth, input: UpdateTeamInput): Promise { - const requestedIdentifiers = (input.teams || []) - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter((value) => value.length > 0) +export async function updateTeams( + auth: Auth, + input: UpdateTeamInput, +): Promise { + const requestedIdentifiers = (input.teams || []) + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter((value) => value.length > 0); - if (requestedIdentifiers.length === 0) { - throw new KeeperSdkError('No teams to update.', ResultCodes.NO_TEAMS_TO_UPDATE) - } + if (requestedIdentifiers.length === 0) { + throw new KeeperSdkError( + "No teams to update.", + ResultCodes.NO_TEAMS_TO_UPDATE, + ); + } - const newName = input.name?.trim() ? input.name.trim() : undefined - if (newName) validateTeamName(newName) - if (newName && requestedIdentifiers.length > 1) { - throw new KeeperSdkError( - 'Cannot rename more than one team in a single update.', - ResultCodes.MULTIPLE_TEAM_RENAME_NOT_ALLOWED - ) - } + const newName = input.name?.trim() ? input.name.trim() : undefined; + if (newName) validateTeamName(newName); + if (newName && requestedIdentifiers.length > 1) { + throw new KeeperSdkError( + "Cannot rename more than one team in a single update.", + ResultCodes.MULTIPLE_TEAM_RENAME_NOT_ALLOWED, + ); + } - const restrictEditOverride = resolveOptionalRestriction(input.restrictEdit) - const restrictShareOverride = resolveOptionalRestriction(input.restrictShare) - const restrictViewOverride = resolveOptionalRestriction(input.restrictView) + const restrictEditOverride = resolveOptionalRestriction(input.restrictEdit); + const restrictShareOverride = resolveOptionalRestriction(input.restrictShare); + const restrictViewOverride = resolveOptionalRestriction(input.restrictView); - const parentIdentifier = input.parent - const needsNameLookup = parentNeedsNameLookup(parentIdentifier) + const parentIdentifier = input.parent; + const needsNameLookup = parentNeedsNameLookup(parentIdentifier); - const enterpriseData = new EnterpriseDataManager(auth) - const response = await enterpriseData.getData(UPDATE_TEAM_INCLUDES) - const nodes = response.nodes || [] - applyEnterpriseNameToRoot(nodes, response.enterprise_name) + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(UPDATE_TEAM_INCLUDES); + const nodes = response.nodes || []; + applyEnterpriseNameToRoot(nodes, response.enterprise_name); - if (needsNameLookup) { - const displayNames = await enterpriseData.getDisplayNames() - applyDecryptedNodeNames(nodes, displayNames.nodes) - await enterpriseData.decryptNodeNames(nodes) - } + if (needsNameLookup) { + const displayNames = await enterpriseData.getDisplayNames(); + applyDecryptedNodeNames(nodes, displayNames.nodes); + await enterpriseData.decryptNodeNames(nodes); + } + + const allTeams = response.teams || []; + const resolvedTeams = resolveExistingTeams(allTeams, requestedIdentifiers); - const allTeams = response.teams || [] - const resolvedTeams = resolveExistingTeams(allTeams, requestedIdentifiers) - - let overrideNodeId: number | null = null - if (parentIdentifier !== undefined && parentIdentifier !== null && parentIdentifier !== '') { - try { - overrideNodeId = resolveParentNode(nodes, parentIdentifier).node_id - } catch (err) { - throw new KeeperSdkError( - extractErrorMessage(err), - ResultCodes.PARENT_NODE_NOT_FOUND - ) - } + let overrideNodeId: number | null = null; + if ( + parentIdentifier !== undefined && + parentIdentifier !== null && + parentIdentifier !== "" + ) { + try { + overrideNodeId = resolveParentNode(nodes, parentIdentifier).node_id; + } catch (err) { + throw new KeeperSdkError( + extractErrorMessage(err), + ResultCodes.PARENT_NODE_NOT_FOUND, + ); } + } - const items: UpdateTeamItemResult[] = [] - for (const team of resolvedTeams) { - const targetNodeId = overrideNodeId ?? team.node_id - const payload: TeamUpdateRequestPayload = { - team_uid: team.team_uid, - team_name: newName || team.name || '', - node_id: targetNodeId, - restrict_edit: restrictEditOverride ?? team.restrict_edit === true, - restrict_share: restrictShareOverride ?? (team.restrict_share === true || team.restrict_sharing === true), - restrict_view: restrictViewOverride ?? team.restrict_view === true, - } - - try { - await sendTeamUpdate(auth, payload) - items.push({ - teamUid: team.team_uid, - teamName: payload.team_name, - nodeId: targetNodeId, - status: UpdateTeamStatus.Updated, - }) - } catch (err) { - items.push({ - teamUid: team.team_uid, - teamName: team.name || '', - nodeId: team.node_id, - status: UpdateTeamStatus.Failed, - message: extractErrorMessage(err), - }) - } + const items: UpdateTeamItemResult[] = []; + for (const team of resolvedTeams) { + const targetNodeId = overrideNodeId ?? team.node_id; + const payload: TeamUpdateRequestPayload = { + team_uid: team.team_uid, + team_name: newName || team.name || "", + node_id: targetNodeId, + restrict_edit: restrictEditOverride ?? team.restrict_edit === true, + restrict_share: + restrictShareOverride ?? + (team.restrict_share === true || team.restrict_sharing === true), + restrict_view: restrictViewOverride ?? team.restrict_view === true, + }; + + try { + await sendTeamUpdate(auth, payload); + items.push({ + teamUid: team.team_uid, + teamName: payload.team_name, + nodeId: targetNodeId, + status: UpdateTeamStatus.Updated, + }); + } catch (err) { + items.push({ + teamUid: team.team_uid, + teamName: team.name || "", + nodeId: team.node_id, + status: UpdateTeamStatus.Failed, + message: extractErrorMessage(err), + }); } + } - return finalizeResult(items) + return finalizeResult(items); } -function resolveOptionalRestriction(input: TeamRestrictionInput): boolean | undefined { - if (input === undefined || input === null) return undefined - const lower = String(input).toLowerCase() - if (lower === TeamRestriction.On) return true - if (lower === TeamRestriction.Off) return false - return undefined +function resolveOptionalRestriction( + input: TeamRestrictionInput, +): boolean | undefined { + if (input === undefined || input === null) return undefined; + const lower = String(input).toLowerCase(); + if (lower === TeamRestriction.On) return true; + if (lower === TeamRestriction.Off) return false; + return undefined; } -async function sendTeamUpdate(auth: Auth, payload: TeamUpdateRequestPayload): Promise { - const command: RestCommand = { - baseRequest: { command: TEAM_UPDATE_COMMAND }, - request: payload, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || - response.result_code || - `team_update failed for team_uid=${payload.team_uid}`, - response.result_code || ResultCodes.TEAM_UPDATE_FAILED - ) - } +async function sendTeamUpdate( + auth: Auth, + payload: TeamUpdateRequestPayload, +): Promise { + const command: RestCommand = { + baseRequest: { command: TEAM_UPDATE_COMMAND }, + request: payload, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_update failed for team_uid=${payload.team_uid}`, + response.result_code || ResultCodes.TEAM_UPDATE_FAILED, + ); + } } function finalizeResult(items: UpdateTeamItemResult[]): UpdateTeamResult { - const updated = items.filter((item) => item.status === UpdateTeamStatus.Updated).length - const failed = items.filter((item) => item.status === UpdateTeamStatus.Failed).length - return { - success: failed === 0 && updated > 0, - items, - updated, - failed, - } + const updated = items.filter( + (item) => item.status === UpdateTeamStatus.Updated, + ).length; + const failed = items.filter( + (item) => item.status === UpdateTeamStatus.Failed, + ).length; + return { + success: failed === 0 && updated > 0, + items, + updated, + failed, + }; } export type FormattedUpdateTeamRow = { - status: string - teamName: string - teamUid: string - nodeId: number - detail: string -} + status: string; + teamName: string; + teamUid: string; + nodeId: number; + detail: string; +}; export type FormattedUpdateTeamTable = { - headers: string[] - rows: string[][] - summary: string -} + headers: string[]; + rows: string[][]; + summary: string; +}; -export function formatUpdateTeamResult(result: UpdateTeamResult): FormattedUpdateTeamTable { - const rows = result.items.map((item, index) => [ - String(index + 1), - item.status, - item.teamName, - item.teamUid, - String(item.nodeId), - item.message || '', - ]) - - return { - headers: [...TEAM_TABLE_HEADERS], - rows, - summary: `Updated: ${result.updated} Failed: ${result.failed}`, - } +export function formatUpdateTeamResult( + result: UpdateTeamResult, +): FormattedUpdateTeamTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.teamName, + item.teamUid, + String(item.nodeId), + item.message || "", + ]); + + return { + headers: [...TEAM_TABLE_HEADERS], + rows, + summary: `Updated: ${result.updated} Failed: ${result.failed}`, + }; } -export function renderUpdateTeamAsciiTable(table: FormattedUpdateTeamTable): string { - const { headers, rows } = table - const widths = headers.map((header, index) => - Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) - ) - const padCell = (cell: string, columnIndex: number): string => - cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) - const formatRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - const lines: string[] = [] - lines.push(formatRow(headers)) - lines.push(formatRow(widths.map((width) => '-'.repeat(width)))) - for (const row of rows) lines.push(formatRow(row)) - lines.push(table.summary) - return lines.join('\n') +export function renderUpdateTeamAsciiTable( + table: FormattedUpdateTeamTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + const lines: string[] = []; + lines.push(formatRow(headers)); + lines.push(formatRow(widths.map((width) => "-".repeat(width)))); + for (const row of rows) lines.push(formatRow(row)); + lines.push(table.summary); + return lines.join("\n"); } diff --git a/KeeperSdk/src/teams/viewTeam.ts b/KeeperSdk/src/teams/viewTeam.ts index bda5c4f2..f61669d9 100644 --- a/KeeperSdk/src/teams/viewTeam.ts +++ b/KeeperSdk/src/teams/viewTeam.ts @@ -1,248 +1,301 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' +import type { Auth } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseDisplayNames, - type EnterpriseNode, - type EnterpriseRoleTeamLink, - type EnterpriseTeamRecord, - type EnterpriseTeamUserLink, - type EnterpriseUser, -} from './enterpriseData' - -const NODE_PATH_SEPARATOR = '\\' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseDisplayNames, + type EnterpriseNode, + type EnterpriseRoleTeamLink, + type EnterpriseTeamRecord, + type EnterpriseTeamUserLink, + type EnterpriseUser, +} from "./enterpriseData"; + +const NODE_PATH_SEPARATOR = "\\"; const VIEW_TEAM_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Teams, - EnterpriseDataInclude.TeamUsers, - EnterpriseDataInclude.RoleTeams, - EnterpriseDataInclude.Roles, - EnterpriseDataInclude.Users, - EnterpriseDataInclude.Nodes, -] + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.RoleTeams, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Nodes, +]; export type TeamRoleInfo = { - role_id: number - role_name: string -} + role_id: number; + role_name: string; +}; export type TeamUserInfo = { - enterprise_user_id: number - username: string -} + enterprise_user_id: number; + username: string; +}; export type TeamView = { - team_uid: string - team_name: string - node_id: number - node_name: string - restrict_view: boolean - restrict_edit: boolean - restrict_share: boolean - team_roles?: TeamRoleInfo[] - team_users?: TeamUserInfo[] -} + team_uid: string; + team_name: string; + node_id: number; + node_name: string; + restrict_view: boolean; + restrict_edit: boolean; + restrict_share: boolean; + team_roles?: TeamRoleInfo[]; + team_users?: TeamUserInfo[]; +}; export type FormatTeamViewOptions = { - verbose?: boolean -} + verbose?: boolean; +}; export type TeamViewTableRow = { - label: string - value: string | string[] - id?: number | number[] -} + label: string; + value: string | string[]; + id?: number | number[]; +}; export type FormattedTeamViewTable = { - rows: TeamViewTableRow[] - hasIdColumn: boolean -} + rows: TeamViewTableRow[]; + hasIdColumn: boolean; +}; -export async function viewTeam(auth: Auth, identifier: string): Promise { - const enterpriseData = new EnterpriseDataManager(auth) - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(VIEW_TEAM_INCLUDES), - enterpriseData.getDisplayNames(), - ]) - - const team = resolveTeam(response.teams || [], identifier) - const nodePath = resolveNodePath(response.nodes || [], displayNames, team.node_id) - const teamUsers = buildTeamUsers(response.users || [], response.team_users || [], team.team_uid) - const teamRoles = buildTeamRoles(response.role_teams || [], displayNames.roles, team.team_uid) - - const view: TeamView = { - team_uid: team.team_uid, - team_name: team.name, - node_id: team.node_id, - node_name: nodePath, - restrict_view: team.restrict_view === true, - restrict_edit: team.restrict_edit === true, - restrict_share: team.restrict_share === true || team.restrict_sharing === true, - } - if (teamRoles.length > 0) view.team_roles = teamRoles - if (teamUsers.length > 0) view.team_users = teamUsers - return view +export async function viewTeam( + auth: Auth, + identifier: string, +): Promise { + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(VIEW_TEAM_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const team = resolveTeam(response.teams || [], identifier); + const nodePath = resolveNodePath( + response.nodes || [], + displayNames, + team.node_id, + ); + const teamUsers = buildTeamUsers( + response.users || [], + response.team_users || [], + team.team_uid, + ); + const teamRoles = buildTeamRoles( + response.role_teams || [], + displayNames.roles, + team.team_uid, + ); + + const view: TeamView = { + team_uid: team.team_uid, + team_name: team.name, + node_id: team.node_id, + node_name: nodePath, + restrict_view: team.restrict_view === true, + restrict_edit: team.restrict_edit === true, + restrict_share: + team.restrict_share === true || team.restrict_sharing === true, + }; + if (teamRoles.length > 0) view.team_roles = teamRoles; + if (teamUsers.length > 0) view.team_users = teamUsers; + return view; } -export function formatTeamView(view: TeamView, options: FormatTeamViewOptions = {}): FormattedTeamViewTable { - const verbose = options.verbose === true - const rows: TeamViewTableRow[] = [ - { label: 'Team UID', value: view.team_uid }, - { label: 'Team Name', value: view.team_name }, - { - label: 'Node Name', - value: view.node_name, - id: verbose ? view.node_id : undefined, - }, - { label: 'Restrict Edit', value: boolText(view.restrict_edit) }, - { label: 'Restrict Share', value: boolText(view.restrict_share) }, - { label: 'Restrict View', value: boolText(view.restrict_view) }, - ] - - if (view.team_roles && view.team_roles.length > 0) { - rows.push({ - label: 'Role(s)', - value: view.team_roles.map((role) => role.role_name), - id: verbose ? view.team_roles.map((role) => role.role_id) : undefined, - }) - } +export function formatTeamView( + view: TeamView, + options: FormatTeamViewOptions = {}, +): FormattedTeamViewTable { + const verbose = options.verbose === true; + const rows: TeamViewTableRow[] = [ + { label: "Team UID", value: view.team_uid }, + { label: "Team Name", value: view.team_name }, + { + label: "Node Name", + value: view.node_name, + id: verbose ? view.node_id : undefined, + }, + { label: "Restrict Edit", value: boolText(view.restrict_edit) }, + { label: "Restrict Share", value: boolText(view.restrict_share) }, + { label: "Restrict View", value: boolText(view.restrict_view) }, + ]; - if (view.team_users && view.team_users.length > 0) { - rows.push({ - label: 'User(s)', - value: view.team_users.map((user) => user.username), - id: verbose ? view.team_users.map((user) => user.enterprise_user_id) : undefined, - }) - } + if (view.team_roles && view.team_roles.length > 0) { + rows.push({ + label: "Role(s)", + value: view.team_roles.map((role) => role.role_name), + id: verbose ? view.team_roles.map((role) => role.role_id) : undefined, + }); + } - return { rows, hasIdColumn: verbose } + if (view.team_users && view.team_users.length > 0) { + rows.push({ + label: "User(s)", + value: view.team_users.map((user) => user.username), + id: verbose + ? view.team_users.map((user) => user.enterprise_user_id) + : undefined, + }); + } + + return { rows, hasIdColumn: verbose }; } export function teamViewTable(table: FormattedTeamViewTable): string { - const labelWidth = table.rows.reduce((max, row) => Math.max(max, row.label.length), 0) - const expandedRows = table.rows.map((row) => ({ - label: row.label, - valueLines: asLines(row.value), - idLines: table.hasIdColumn ? asIdLines(row.id) : [], - })) - - const valueWidth = expandedRows.reduce( - (max, row) => Math.max(max, ...row.valueLines.map((line) => line.length)), - 0 - ) - const idWidth = table.hasIdColumn - ? expandedRows.reduce((max, row) => Math.max(max, ...row.idLines.map((line) => line.length)), 0) - : 0 - - const padRight = (text: string, width: number): string => - text + ' '.repeat(Math.max(0, width - text.length)) - const padLeft = (text: string, width: number): string => - ' '.repeat(Math.max(0, width - text.length)) + text - - const lines: string[] = [] - for (const row of expandedRows) { - const lineCount = Math.max(row.valueLines.length, table.hasIdColumn ? row.idLines.length : 0, 1) - for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) { - const isFirstLine = lineIndex === 0 - const labelCell = padLeft(isFirstLine ? row.label : '', labelWidth) - const valueCell = padRight(row.valueLines[lineIndex] ?? '', valueWidth) - const cells: string[] = [labelCell, valueCell] - if (table.hasIdColumn) { - const idCell = padRight(row.idLines[lineIndex] ?? '', idWidth) - cells.push(idCell) - } - lines.push(cells.join(' ').trimEnd()) - } + const labelWidth = table.rows.reduce( + (max, row) => Math.max(max, row.label.length), + 0, + ); + const expandedRows = table.rows.map((row) => ({ + label: row.label, + valueLines: asLines(row.value), + idLines: table.hasIdColumn ? asIdLines(row.id) : [], + })); + + const valueWidth = expandedRows.reduce( + (max, row) => Math.max(max, ...row.valueLines.map((line) => line.length)), + 0, + ); + const idWidth = table.hasIdColumn + ? expandedRows.reduce( + (max, row) => Math.max(max, ...row.idLines.map((line) => line.length)), + 0, + ) + : 0; + + const padRight = (text: string, width: number): string => + text + " ".repeat(Math.max(0, width - text.length)); + const padLeft = (text: string, width: number): string => + " ".repeat(Math.max(0, width - text.length)) + text; + + const lines: string[] = []; + for (const row of expandedRows) { + const lineCount = Math.max( + row.valueLines.length, + table.hasIdColumn ? row.idLines.length : 0, + 1, + ); + for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) { + const isFirstLine = lineIndex === 0; + const labelCell = padLeft(isFirstLine ? row.label : "", labelWidth); + const valueCell = padRight(row.valueLines[lineIndex] ?? "", valueWidth); + const cells: string[] = [labelCell, valueCell]; + if (table.hasIdColumn) { + const idCell = padRight(row.idLines[lineIndex] ?? "", idWidth); + cells.push(idCell); + } + lines.push(cells.join(" ").trimEnd()); } - return lines.join('\n') + } + return lines.join("\n"); } -function resolveTeam(teams: EnterpriseTeamRecord[], identifier: string): EnterpriseTeamRecord { - const trimmed = (identifier ?? '').trim() - if (!trimmed) { - throw new KeeperSdkError('Team name or UID is required.', ResultCodes.TEAM_REQUIRED) - } +function resolveTeam( + teams: EnterpriseTeamRecord[], + identifier: string, +): EnterpriseTeamRecord { + const trimmed = (identifier ?? "").trim(); + if (!trimmed) { + throw new KeeperSdkError( + "Team name or UID is required.", + ResultCodes.TEAM_REQUIRED, + ); + } - const uidMatch = teams.find((team) => team.team_uid === trimmed) - if (uidMatch) return uidMatch - - const lowered = trimmed.toLowerCase() - const nameMatches = teams.filter((team) => (team.name || '').trim().toLowerCase() === lowered) - if (nameMatches.length === 1) return nameMatches[0] - if (nameMatches.length > 1) { - throw new KeeperSdkError( - `Multiple teams match name "${trimmed}". Specify the team UID instead.`, - ResultCodes.MULTIPLE_TEAM_MATCHES - ) - } - throw new KeeperSdkError(`Team "${trimmed}" does not exist.`, ResultCodes.TEAM_NOT_FOUND) + const uidMatch = teams.find((team) => team.team_uid === trimmed); + if (uidMatch) return uidMatch; + + const lowered = trimmed.toLowerCase(); + const nameMatches = teams.filter( + (team) => (team.name || "").trim().toLowerCase() === lowered, + ); + if (nameMatches.length === 1) return nameMatches[0]; + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Multiple teams match name "${trimmed}". Specify the team UID instead.`, + ResultCodes.MULTIPLE_TEAM_MATCHES, + ); + } + throw new KeeperSdkError( + `Team "${trimmed}" does not exist.`, + ResultCodes.TEAM_NOT_FOUND, + ); } function resolveNodePath( - nodes: EnterpriseNode[], - displayNames: EnterpriseDisplayNames, - nodeId: number + nodes: EnterpriseNode[], + displayNames: EnterpriseDisplayNames, + nodeId: number, ): string { - if (displayNames.nodes.size > 0) { - for (const node of nodes) { - const display = displayNames.nodes.get(node.node_id) - if (display) node.displayName = display - } + if (displayNames.nodes.size > 0) { + for (const node of nodes) { + const display = displayNames.nodes.get(node.node_id); + if (display) node.displayName = display; } - return EnterpriseDataManager.getNodePath(nodes, nodeId, { omitRoot: false, separator: NODE_PATH_SEPARATOR }) + } + return EnterpriseDataManager.getNodePath(nodes, nodeId, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }); } function buildTeamUsers( - users: EnterpriseUser[], - teamUserLinks: EnterpriseTeamUserLink[], - teamUid: string + users: EnterpriseUser[], + teamUserLinks: EnterpriseTeamUserLink[], + teamUid: string, ): TeamUserInfo[] { - const userById = new Map() - for (const user of users) userById.set(user.enterprise_user_id, user) + const userById = new Map(); + for (const user of users) userById.set(user.enterprise_user_id, user); - const memberIds = new Set() - for (const link of teamUserLinks) { - if (link.team_uid === teamUid) memberIds.add(link.enterprise_user_id) - } + const memberIds = new Set(); + for (const link of teamUserLinks) { + if (link.team_uid === teamUid) memberIds.add(link.enterprise_user_id); + } - const result: TeamUserInfo[] = [] - for (const id of memberIds) { - const user = userById.get(id) - if (user && user.username) result.push({ enterprise_user_id: id, username: user.username }) - } - result.sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' })) - return result + const result: TeamUserInfo[] = []; + for (const id of memberIds) { + const user = userById.get(id); + if (user && user.username) + result.push({ enterprise_user_id: id, username: user.username }); + } + result.sort((a, b) => + a.username.localeCompare(b.username, undefined, { sensitivity: "base" }), + ); + return result; } function buildTeamRoles( - roleTeamLinks: EnterpriseRoleTeamLink[], - decryptedRoleNames: Map, - teamUid: string + roleTeamLinks: EnterpriseRoleTeamLink[], + decryptedRoleNames: Map, + teamUid: string, ): TeamRoleInfo[] { - const roleIds = new Set() - for (const link of roleTeamLinks) { - if (link.team_uid === teamUid) roleIds.add(link.role_id) - } + const roleIds = new Set(); + for (const link of roleTeamLinks) { + if (link.team_uid === teamUid) roleIds.add(link.role_id); + } - const result: TeamRoleInfo[] = [] - for (const id of roleIds) { - result.push({ role_id: id, role_name: decryptedRoleNames.get(id) || String(id) }) - } - result.sort((a, b) => a.role_name.localeCompare(b.role_name, undefined, { sensitivity: 'base' })) - return result + const result: TeamRoleInfo[] = []; + for (const id of roleIds) { + result.push({ + role_id: id, + role_name: decryptedRoleNames.get(id) || String(id), + }); + } + result.sort((a, b) => + a.role_name.localeCompare(b.role_name, undefined, { sensitivity: "base" }), + ); + return result; } function boolText(value: boolean): string { - return value ? 'True' : 'False' + return value ? "True" : "False"; } function asLines(value: string | string[]): string[] { - if (Array.isArray(value)) return value.length > 0 ? value : [''] - return [value] + if (Array.isArray(value)) return value.length > 0 ? value : [""]; + return [value]; } function asIdLines(id: number | number[] | undefined): string[] { - if (id == null) return [] - if (Array.isArray(id)) return id.length > 0 ? id.map(String) : [''] - return [String(id)] + if (id == null) return []; + if (Array.isArray(id)) return id.length > 0 ? id.map(String) : [""]; + return [String(id)]; } diff --git a/KeeperSdk/src/users/UserManager.ts b/KeeperSdk/src/users/UserManager.ts index 6db91cc2..4af0d466 100644 --- a/KeeperSdk/src/users/UserManager.ts +++ b/KeeperSdk/src/users/UserManager.ts @@ -1,149 +1,229 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' -import { addUsers, formatAddUserResult, renderAddUserAsciiTable } from './addUser' -import { updateUsers, formatUpdateUserResult, renderUpdateUserAsciiTable } from './updateUser' -import { deleteUsers, formatDeleteUserResult, renderDeleteUserAsciiTable } from './deleteUser' -import { actionUsers, formatUserActionResult, renderUserActionAsciiTable } from './actionUser' -import { aliasUser } from './aliasUser' -import { addUsersToTeams, removeUsersFromTeams, formatTeamUserResult, renderTeamUserAsciiTable } from './teamUser' -import { listUsers, formatUsersTable, renderUsersAsciiTable } from './listUsers' -import { viewUser, formatUserView, userViewTable } from './viewUser' +import type { Auth } from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; +import { + addUsers, + formatAddUserResult, + renderAddUserAsciiTable, +} from "./addUser"; +import { + updateUsers, + formatUpdateUserResult, + renderUpdateUserAsciiTable, +} from "./updateUser"; +import { + deleteUsers, + formatDeleteUserResult, + renderDeleteUserAsciiTable, +} from "./deleteUser"; +import { + actionUsers, + formatUserActionResult, + renderUserActionAsciiTable, +} from "./actionUser"; +import { aliasUser } from "./aliasUser"; +import { + addUsersToTeams, + removeUsersFromTeams, + formatTeamUserResult, + renderTeamUserAsciiTable, +} from "./teamUser"; +import { + updateUsersOnTeams, + formatUpdateTeamUserResult, + renderUpdateTeamUserAsciiTable, +} from "./updateTeamUser"; +import { + listUsers, + formatUsersTable, + renderUsersAsciiTable, +} from "./listUsers"; +import { viewUser, formatUserView, userViewTable } from "./viewUser"; import type { - UserColumnInput, - ListUsersOptions, - ListUserRow, - FormattedUsersTable, - FormatUsersTableOptions, - UserView, - FormatUserViewOptions, - FormattedUserViewTable, - AddUserInput, - AddUserResult, - FormatAddUserResultOptions, - FormattedAddUserTable, - UpdateUserInput, - UpdateUserResult, - FormattedUpdateUserTable, - DeleteUserInput, - DeleteUserResult, - FormattedDeleteUserTable, - UserActionInput, - UserActionResult, - FormattedUserActionTable, - AliasUserInput, - AliasUserResult, - AddUsersToTeamsInput, - RemoveUsersFromTeamsInput, - TeamUserResult, - FormattedTeamUserTable, -} from './userTypes' - -export type AuthProvider = () => Auth - -export class UserManager { - private readonly authProvider: AuthProvider - - constructor(authProvider: AuthProvider) { - this.authProvider = authProvider - } - - public async listUsers(options: ListUsersOptions = {}): Promise { - return listUsers(this.requireAuth(), options) - } - - public formatUsersTable(rows: ListUserRow[], options: FormatUsersTableOptions = {}): FormattedUsersTable { - return formatUsersTable(rows, options) - } - - public renderUsersAsciiTable(table: FormattedUsersTable, options: { minColWidth?: number } = {}): string { - return renderUsersAsciiTable(table, options) - } - - public async viewUser(identifier: string): Promise { - return viewUser(this.requireAuth(), identifier) - } - - public formatUserView(view: UserView, options: FormatUserViewOptions = {}): FormattedUserViewTable { - return formatUserView(view, options) - } - - public userViewTable(table: FormattedUserViewTable): string { - return userViewTable(table) - } - - public async addUsers(input: AddUserInput): Promise { - return addUsers(this.requireAuth(), input) - } - - public formatAddUserResult(result: AddUserResult, options: FormatAddUserResultOptions = {}): FormattedAddUserTable { - return formatAddUserResult(result, options) - } - - public renderAddUserAsciiTable(table: FormattedAddUserTable): string { - return renderAddUserAsciiTable(table) - } - - public async updateUsers(input: UpdateUserInput): Promise { - return updateUsers(this.requireAuth(), input) - } - - public formatUpdateUserResult(result: UpdateUserResult): FormattedUpdateUserTable { - return formatUpdateUserResult(result) - } - - public renderUpdateUserAsciiTable(table: FormattedUpdateUserTable): string { - return renderUpdateUserAsciiTable(table) - } - - public async deleteUsers(input: DeleteUserInput): Promise { - return deleteUsers(this.requireAuth(), input) - } - - public formatDeleteUserResult(result: DeleteUserResult): FormattedDeleteUserTable { - return formatDeleteUserResult(result) - } - - public renderDeleteUserAsciiTable(table: FormattedDeleteUserTable): string { - return renderDeleteUserAsciiTable(table) - } - - public async actionUsers(input: UserActionInput): Promise { - return actionUsers(this.requireAuth(), input) - } - - public formatUserActionResult(result: UserActionResult): FormattedUserActionTable { - return formatUserActionResult(result) - } - - public renderUserActionAsciiTable(table: FormattedUserActionTable): string { - return renderUserActionAsciiTable(table) - } - - public async aliasUser(input: AliasUserInput): Promise { - return aliasUser(this.requireAuth(), input) - } - - public async addUsersToTeams(input: AddUsersToTeamsInput): Promise { - return addUsersToTeams(this.requireAuth(), input) - } - - public async removeUsersFromTeams(input: RemoveUsersFromTeamsInput): Promise { - return removeUsersFromTeams(this.requireAuth(), input) - } - - public formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { - return formatTeamUserResult(result) - } + UserColumnInput, + ListUsersOptions, + ListUserRow, + FormattedUsersTable, + FormatUsersTableOptions, + UserView, + FormatUserViewOptions, + FormattedUserViewTable, + AddUserInput, + AddUserResult, + FormatAddUserResultOptions, + FormattedAddUserTable, + UpdateUserInput, + UpdateUserResult, + FormattedUpdateUserTable, + DeleteUserInput, + DeleteUserResult, + FormattedDeleteUserTable, + UserActionInput, + UserActionResult, + FormattedUserActionTable, + AliasUserInput, + AliasUserResult, + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserResult, + FormattedTeamUserTable, +} from "./userTypes"; +import type { + UpdateUsersOnTeamsInput, + UpdateUsersOnTeamsResult, + FormattedUpdateTeamUserTable, +} from "./updateTeamUser"; - public renderTeamUserAsciiTable(table: FormattedTeamUserTable): string { - return renderTeamUserAsciiTable(table) - } +export type AuthProvider = () => Auth; - private requireAuth(): Auth { - const auth = this.authProvider() - if (!auth) { - throw new KeeperSdkError('You are not logged in. Please log in first.', ResultCodes.NOT_LOGGED_IN) - } - return auth - } -} \ No newline at end of file +export class UserManager { + private readonly authProvider: AuthProvider; + + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider; + } + + public async listUsers( + options: ListUsersOptions = {}, + ): Promise { + return listUsers(this.requireAuth(), options); + } + + public formatUsersTable( + rows: ListUserRow[], + options: FormatUsersTableOptions = {}, + ): FormattedUsersTable { + return formatUsersTable(rows, options); + } + + public renderUsersAsciiTable( + table: FormattedUsersTable, + options: { minColWidth?: number } = {}, + ): string { + return renderUsersAsciiTable(table, options); + } + + public async viewUser(identifier: string): Promise { + return viewUser(this.requireAuth(), identifier); + } + + public formatUserView( + view: UserView, + options: FormatUserViewOptions = {}, + ): FormattedUserViewTable { + return formatUserView(view, options); + } + + public userViewTable(table: FormattedUserViewTable): string { + return userViewTable(table); + } + + public async addUsers(input: AddUserInput): Promise { + return addUsers(this.requireAuth(), input); + } + + public formatAddUserResult( + result: AddUserResult, + options: FormatAddUserResultOptions = {}, + ): FormattedAddUserTable { + return formatAddUserResult(result, options); + } + + public renderAddUserAsciiTable(table: FormattedAddUserTable): string { + return renderAddUserAsciiTable(table); + } + + public async updateUsers(input: UpdateUserInput): Promise { + return updateUsers(this.requireAuth(), input); + } + + public formatUpdateUserResult( + result: UpdateUserResult, + ): FormattedUpdateUserTable { + return formatUpdateUserResult(result); + } + + public renderUpdateUserAsciiTable(table: FormattedUpdateUserTable): string { + return renderUpdateUserAsciiTable(table); + } + + public async deleteUsers(input: DeleteUserInput): Promise { + return deleteUsers(this.requireAuth(), input); + } + + public formatDeleteUserResult( + result: DeleteUserResult, + ): FormattedDeleteUserTable { + return formatDeleteUserResult(result); + } + + public renderDeleteUserAsciiTable(table: FormattedDeleteUserTable): string { + return renderDeleteUserAsciiTable(table); + } + + public async actionUsers(input: UserActionInput): Promise { + return actionUsers(this.requireAuth(), input); + } + + public formatUserActionResult( + result: UserActionResult, + ): FormattedUserActionTable { + return formatUserActionResult(result); + } + + public renderUserActionAsciiTable(table: FormattedUserActionTable): string { + return renderUserActionAsciiTable(table); + } + + public async aliasUser(input: AliasUserInput): Promise { + return aliasUser(this.requireAuth(), input); + } + + public async addUsersToTeams( + input: AddUsersToTeamsInput, + ): Promise { + return addUsersToTeams(this.requireAuth(), input); + } + + public async removeUsersFromTeams( + input: RemoveUsersFromTeamsInput, + ): Promise { + return removeUsersFromTeams(this.requireAuth(), input); + } + + public formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { + return formatTeamUserResult(result); + } + + public renderTeamUserAsciiTable(table: FormattedTeamUserTable): string { + return renderTeamUserAsciiTable(table); + } + + public async updateUsersOnTeams( + input: UpdateUsersOnTeamsInput, + ): Promise { + return updateUsersOnTeams(this.requireAuth(), input); + } + + public formatUpdateTeamUserResult( + result: UpdateUsersOnTeamsResult, + ): FormattedUpdateTeamUserTable { + return formatUpdateTeamUserResult(result); + } + + public renderUpdateTeamUserAsciiTable( + table: FormattedUpdateTeamUserTable, + ): string { + return renderUpdateTeamUserAsciiTable(table); + } + + private requireAuth(): Auth { + const auth = this.authProvider(); + if (!auth) { + throw new KeeperSdkError( + "You are not logged in. Please log in first.", + ResultCodes.NOT_LOGGED_IN, + ); + } + return auth; + } +} diff --git a/KeeperSdk/src/users/actionUser.ts b/KeeperSdk/src/users/actionUser.ts index bacc22ae..15818043 100644 --- a/KeeperSdk/src/users/actionUser.ts +++ b/KeeperSdk/src/users/actionUser.ts @@ -1,351 +1,404 @@ import { - Enterprise, - enterpriseUsersLockMessage, - type Auth, - type KeeperResponse, - type RestCommand, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, isNumber, KeeperSdkError, logger, ResultCodes } from '../utils' + Enterprise, + enterpriseUsersLockMessage, + type Auth, + type KeeperResponse, + type RestCommand, +} from "@keeper-security/keeperapi"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseUser, -} from '../teams/enterpriseData' + extractErrorMessage, + isNumber, + KeeperSdkError, + logger, + ResultCodes, +} from "../utils"; import { - EnterpriseUserStatus, - normalizeEmailInputs, - UserAction, - UserActionStatus, - UserActionSkipReason, - type UserActionInput, - type UserActionItemResult, - type UserActionResult, - type FormattedUserActionTable, -} from './userTypes' - -export { UserAction, UserActionStatus, UserActionSkipReason } -export type { UserActionInput, UserActionItemResult, UserActionResult, FormattedUserActionTable } - -const EXPIRE_PASSWORD_COMMAND = 'set_master_password_expire' -const ALL_USERS_SENTINEL = '@all' -const ACTIONS_NOT_SUPPORTING_ALL = new Set([UserAction.Lock, UserAction.ExpirePassword]) -const LOCK_UNLOCK_BATCH_SIZE = 1000 -const SELF_ACTION_MESSAGE = 'This operation cannot be done on yourself.' - -const ACTION_USER_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Users] - -const USER_ACTION_TABLE_HEADERS = ['#', 'Status', 'Email', 'User ID', 'Detail'] + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseUser, +} from "../teams/enterpriseData"; +import { + EnterpriseUserStatus, + normalizeEmailInputs, + UserAction, + UserActionStatus, + UserActionSkipReason, + type UserActionInput, + type UserActionItemResult, + type UserActionResult, + type FormattedUserActionTable, +} from "./userTypes"; + +export { UserAction, UserActionStatus, UserActionSkipReason }; +export type { + UserActionInput, + UserActionItemResult, + UserActionResult, + FormattedUserActionTable, +}; + +const EXPIRE_PASSWORD_COMMAND = "set_master_password_expire"; +const ALL_USERS_SENTINEL = "@all"; +const ACTIONS_NOT_SUPPORTING_ALL = new Set([ + UserAction.Lock, + UserAction.ExpirePassword, +]); +const LOCK_UNLOCK_BATCH_SIZE = 1000; +const SELF_ACTION_MESSAGE = "This operation cannot be done on yourself."; + +const ACTION_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Users, +]; + +const USER_ACTION_TABLE_HEADERS = ["#", "Status", "Email", "User ID", "Detail"]; type ExpirePasswordPayload = { - email: string -} + email: string; +}; type ActionUserTarget = - | { kind: 'user'; user: EnterpriseUser } - | { kind: 'not_found'; identifier: string } - -export async function actionUsers(auth: Auth, input: UserActionInput): Promise { - const identifiers = normalizeEmailInputs(input.emails) - - if (identifiers.length === 0) { - throw new KeeperSdkError('No users provided.', ResultCodes.NO_USERS_TO_ACTION) + | { kind: "user"; user: EnterpriseUser } + | { kind: "not_found"; identifier: string }; + +export async function actionUsers( + auth: Auth, + input: UserActionInput, +): Promise { + const identifiers = normalizeEmailInputs(input.emails); + + if (identifiers.length === 0) { + throw new KeeperSdkError( + "No users provided.", + ResultCodes.NO_USERS_TO_ACTION, + ); + } + + const isAll = identifiers.includes(ALL_USERS_SENTINEL); + + if (isAll && ACTIONS_NOT_SUPPORTING_ALL.has(input.action)) { + throw new KeeperSdkError( + `The '${input.action}' action does not support @all.`, + ResultCodes.USER_ACTION_ALL_NOT_SUPPORTED, + ); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(ACTION_USER_INCLUDES); + const allUsers = response.users || []; + + const targets: ActionUserTarget[] = isAll + ? allUsers.map((user) => ({ kind: "user" as const, user })) + : resolveActionUserTargets(allUsers, identifiers); + const callerEnterpriseUserId = resolveCallerEnterpriseUserId(auth, allUsers); + + const items: UserActionItemResult[] = []; + const batchTargets = new Map(); + + for (const target of targets) { + if (target.kind === "not_found") { + items.push({ + username: target.identifier, + status: UserActionStatus.Failed, + message: `User "${target.identifier}" does not exist.`, + }); + continue; } - const isAll = identifiers.includes(ALL_USERS_SENTINEL) - - if (isAll && ACTIONS_NOT_SUPPORTING_ALL.has(input.action)) { - throw new KeeperSdkError( - `The '${input.action}' action does not support @all.`, - ResultCodes.USER_ACTION_ALL_NOT_SUPPORTED - ) + const user = target.user; + const item: UserActionItemResult = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + status: UserActionStatus.Failed, + }; + + if (user.status !== EnterpriseUserStatus.Active) { + const masked = maskEmail(user.username); + logger.warn(`User "${masked}" is not active and will be skipped.`); + item.status = UserActionStatus.Skipped; + item.skipReason = UserActionSkipReason.Inactive; + items.push(item); + continue; } - const enterpriseData = new EnterpriseDataManager(auth) - const response = await enterpriseData.getData(ACTION_USER_INCLUDES) - const allUsers = response.users || [] - - const targets: ActionUserTarget[] = isAll - ? allUsers.map((user) => ({ kind: 'user' as const, user })) - : resolveActionUserTargets(allUsers, identifiers) - const callerEnterpriseUserId = resolveCallerEnterpriseUserId(auth, allUsers) - - const items: UserActionItemResult[] = [] - const batchTargets = new Map() - - for (const target of targets) { - if (target.kind === 'not_found') { - items.push({ - username: target.identifier, - status: UserActionStatus.Failed, - message: `User "${target.identifier}" does not exist.`, - }) - continue - } - - const user = target.user - const item: UserActionItemResult = { - username: user.username, - enterpriseUserId: user.enterprise_user_id, - status: UserActionStatus.Failed, - } - - if (user.status !== EnterpriseUserStatus.Active) { - const masked = maskEmail(user.username) - logger.warn(`User "${masked}" is not active and will be skipped.`) - item.status = UserActionStatus.Skipped - item.skipReason = UserActionSkipReason.Inactive - items.push(item) - continue - } - - items.push(item) - - if (input.action === UserAction.Lock || input.action === UserAction.Unlock) { - if (callerEnterpriseUserId !== null && user.enterprise_user_id === callerEnterpriseUserId) { - logger.warn(`User "${maskEmail(user.username)}" is the logged-in user and will be skipped for lock/unlock.`) - item.status = UserActionStatus.Failed - item.message = SELF_ACTION_MESSAGE - continue - } - batchTargets.set(user.enterprise_user_id, item) - continue - } - - try { - await sendExpirePassword(auth, user.username) - item.status = UserActionStatus.Success - } catch (err) { - item.message = extractErrorMessage(err) - } + items.push(item); + + if ( + input.action === UserAction.Lock || + input.action === UserAction.Unlock + ) { + if ( + callerEnterpriseUserId !== null && + user.enterprise_user_id === callerEnterpriseUserId + ) { + logger.warn( + `User "${maskEmail(user.username)}" is the logged-in user and will be skipped for lock/unlock.`, + ); + item.status = UserActionStatus.Failed; + item.message = SELF_ACTION_MESSAGE; + continue; + } + batchTargets.set(user.enterprise_user_id, item); + continue; } - if (input.action === UserAction.Lock || input.action === UserAction.Unlock) { - await sendBatchLockUnlock(auth, input.action, batchTargets) + try { + await sendExpirePassword(auth, user.username); + item.status = UserActionStatus.Success; + } catch (err) { + item.message = extractErrorMessage(err); } + } - return finalizeResult(items) + if (input.action === UserAction.Lock || input.action === UserAction.Unlock) { + await sendBatchLockUnlock(auth, input.action, batchTargets); + } + + return finalizeResult(items); } async function sendBatchLockUnlock( - auth: Auth, - action: UserAction.Lock | UserAction.Unlock, - batchTargets: Map + auth: Auth, + action: UserAction.Lock | UserAction.Unlock, + batchTargets: Map, ): Promise { - if (batchTargets.size === 0) { - return + if (batchTargets.size === 0) { + return; + } + + const enterpriseUserIds = [...batchTargets.keys()]; + + for (const chunk of chunkArray(enterpriseUserIds, LOCK_UNLOCK_BATCH_SIZE)) { + const response = await auth.executeRest( + enterpriseUsersLockMessage({ + lockEnterpriseUserIds: action === UserAction.Lock ? chunk : [], + disableEnterpriseUserIds: [], + unlockEnterpriseUserIds: action === UserAction.Unlock ? chunk : [], + deleteIfPending: false, + }), + ); + + const seen = new Set(); + for (const lockResponse of response.response || []) { + const enterpriseUserId = toEnterpriseUserId( + lockResponse.enterpriseUserId, + ); + if (enterpriseUserId === null) { + continue; + } + + seen.add(enterpriseUserId); + const item = batchTargets.get(enterpriseUserId); + if (!item) { + continue; + } + + applyLockUserResponse(item, lockResponse, action); } - const enterpriseUserIds = [...batchTargets.keys()] - - for (const chunk of chunkArray(enterpriseUserIds, LOCK_UNLOCK_BATCH_SIZE)) { - const response = await auth.executeRest( - enterpriseUsersLockMessage({ - lockEnterpriseUserIds: action === UserAction.Lock ? chunk : [], - disableEnterpriseUserIds: [], - unlockEnterpriseUserIds: action === UserAction.Unlock ? chunk : [], - deleteIfPending: false, - }) - ) - - const seen = new Set() - for (const lockResponse of response.response || []) { - const enterpriseUserId = toEnterpriseUserId(lockResponse.enterpriseUserId) - if (enterpriseUserId === null) { - continue - } - - seen.add(enterpriseUserId) - const item = batchTargets.get(enterpriseUserId) - if (!item) { - continue - } - - applyLockUserResponse(item, lockResponse, action) - } - - for (const enterpriseUserId of chunk) { - if (seen.has(enterpriseUserId)) { - continue - } - const item = batchTargets.get(enterpriseUserId) - if (!item) { - continue - } - item.status = UserActionStatus.Failed - item.message = 'No response received for user.' - } + for (const enterpriseUserId of chunk) { + if (seen.has(enterpriseUserId)) { + continue; + } + const item = batchTargets.get(enterpriseUserId); + if (!item) { + continue; + } + item.status = UserActionStatus.Failed; + item.message = "No response received for user."; } + } } function applyLockUserResponse( - item: UserActionItemResult, - lockResponse: Enterprise.ILockUserResponse, - action: UserAction.Lock | UserAction.Unlock + item: UserActionItemResult, + lockResponse: Enterprise.ILockUserResponse, + action: UserAction.Lock | UserAction.Unlock, ): void { - const status = lockResponse.status ?? Enterprise.UserLockStatus.UNKNOWN_LOCK_STATUS - const errorMessage = lockResponse.errorMessage || '' - - switch (status) { - case Enterprise.UserLockStatus.LOCKED: - if (action === UserAction.Lock) { - item.status = UserActionStatus.Success - } else { - item.status = UserActionStatus.Failed - item.message = errorMessage || 'User is locked.' - } - break - case Enterprise.UserLockStatus.UNLOCKED: - if (action === UserAction.Unlock) { - item.status = UserActionStatus.Success - } else { - item.status = UserActionStatus.Failed - item.message = errorMessage || 'User is unlocked.' - } - break - case Enterprise.UserLockStatus.CANT_BE_PENDING: - item.status = UserActionStatus.Skipped - item.skipReason = UserActionSkipReason.Pending - item.message = errorMessage || 'Pending user was not modified.' - break - case Enterprise.UserLockStatus.DELETED: - item.status = UserActionStatus.Success - item.message = errorMessage || 'Pending user was deleted.' - break - case Enterprise.UserLockStatus.DISABLED: - item.status = UserActionStatus.Failed - item.message = errorMessage || 'User was disabled.' - break - default: - item.status = UserActionStatus.Failed - item.message = errorMessage || 'Lock operation failed.' - } + const status = + lockResponse.status ?? Enterprise.UserLockStatus.UNKNOWN_LOCK_STATUS; + const errorMessage = lockResponse.errorMessage || ""; + + switch (status) { + case Enterprise.UserLockStatus.LOCKED: + if (action === UserAction.Lock) { + item.status = UserActionStatus.Success; + } else { + item.status = UserActionStatus.Failed; + item.message = errorMessage || "User is locked."; + } + break; + case Enterprise.UserLockStatus.UNLOCKED: + if (action === UserAction.Unlock) { + item.status = UserActionStatus.Success; + } else { + item.status = UserActionStatus.Failed; + item.message = errorMessage || "User is unlocked."; + } + break; + case Enterprise.UserLockStatus.CANT_BE_PENDING: + item.status = UserActionStatus.Skipped; + item.skipReason = UserActionSkipReason.Pending; + item.message = errorMessage || "Pending user was not modified."; + break; + case Enterprise.UserLockStatus.DELETED: + item.status = UserActionStatus.Success; + item.message = errorMessage || "Pending user was deleted."; + break; + case Enterprise.UserLockStatus.DISABLED: + item.status = UserActionStatus.Failed; + item.message = errorMessage || "User was disabled."; + break; + default: + item.status = UserActionStatus.Failed; + item.message = errorMessage || "Lock operation failed."; + } } async function sendExpirePassword(auth: Auth, email: string): Promise { - const command: RestCommand = { - baseRequest: { command: EXPIRE_PASSWORD_COMMAND }, - request: { email }, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || - response.result_code || - `${EXPIRE_PASSWORD_COMMAND} failed for "${maskEmail(email)}"`, - response.result_code || ResultCodes.USER_ACTION_FAILED - ) - } + const command: RestCommand = { + baseRequest: { command: EXPIRE_PASSWORD_COMMAND }, + request: { email }, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `${EXPIRE_PASSWORD_COMMAND} failed for "${maskEmail(email)}"`, + response.result_code || ResultCodes.USER_ACTION_FAILED, + ); + } } function chunkArray(values: T[], size: number): T[][] { - const chunks: T[][] = [] - for (let index = 0; index < values.length; index += size) { - chunks.push(values.slice(index, index + size)) - } - return chunks + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; } function toEnterpriseUserId(value: unknown): number | null { - if (value == null) { - return null - } - const numericId = - typeof value === 'object' && 'toNumber' in value - ? (value as { toNumber(): number }).toNumber() - : Number(value) - return isNumber(numericId) && numericId > 0 ? numericId : null + if (value == null) { + return null; + } + const numericId = + typeof value === "object" && "toNumber" in value + ? (value as { toNumber(): number }).toNumber() + : Number(value); + return isNumber(numericId) && numericId > 0 ? numericId : null; } function maskEmail(email: string): string { - return email.includes('@') ? `***@${email.split('@')[1]}` : '***' + return email.includes("@") ? `***@${email.split("@")[1]}` : "***"; } -function resolveActionUserTargets(allUsers: EnterpriseUser[], identifiers: string[]): ActionUserTarget[] { - const byEmail = new Map() - const byId = new Map() - for (const user of allUsers) { - if (user.username) byEmail.set(user.username.toLowerCase(), user) - byId.set(user.enterprise_user_id, user) +function resolveActionUserTargets( + allUsers: EnterpriseUser[], + identifiers: string[], +): ActionUserTarget[] { + const byEmail = new Map(); + const byId = new Map(); + for (const user of allUsers) { + if (user.username) byEmail.set(user.username.toLowerCase(), user); + byId.set(user.enterprise_user_id, user); + } + + const result: ActionUserTarget[] = []; + const seen = new Set(); + + for (const identifier of identifiers) { + const trimmed = identifier.trim(); + const numericId = Number(trimmed); + let user: EnterpriseUser | undefined; + + if (Number.isInteger(numericId)) { + user = byId.get(numericId); } - - const result: ActionUserTarget[] = [] - const seen = new Set() - - for (const identifier of identifiers) { - const trimmed = identifier.trim() - const numericId = Number(trimmed) - let user: EnterpriseUser | undefined - - if (Number.isInteger(numericId)) { - user = byId.get(numericId) - } - if (!user) { - user = byEmail.get(trimmed.toLowerCase()) - } - if (!user) { - result.push({ kind: 'not_found', identifier: trimmed }) - continue - } - if (!seen.has(user.enterprise_user_id)) { - seen.add(user.enterprise_user_id) - result.push({ kind: 'user', user }) - } + if (!user) { + user = byEmail.get(trimmed.toLowerCase()); + } + if (!user) { + result.push({ kind: "not_found", identifier: trimmed }); + continue; } + if (!seen.has(user.enterprise_user_id)) { + seen.add(user.enterprise_user_id); + result.push({ kind: "user", user }); + } + } - return result + return result; } -function resolveCallerEnterpriseUserId(auth: Auth, allUsers: EnterpriseUser[]): number | null { - const username = auth.username.trim().toLowerCase() - if (!username) { - return null - } - const match = allUsers.find((user) => user.username?.trim().toLowerCase() === username) - return match?.enterprise_user_id ?? null +function resolveCallerEnterpriseUserId( + auth: Auth, + allUsers: EnterpriseUser[], +): number | null { + const username = auth.username.trim().toLowerCase(); + if (!username) { + return null; + } + const match = allUsers.find( + (user) => user.username?.trim().toLowerCase() === username, + ); + return match?.enterprise_user_id ?? null; } function finalizeResult(items: UserActionItemResult[]): UserActionResult { - let succeeded = 0, skipped = 0, failed = 0 - for (const item of items) { - if (item.status === UserActionStatus.Success) succeeded++ - else if (item.status === UserActionStatus.Skipped) skipped++ - else failed++ - } - return { success: failed === 0 && succeeded > 0, items, succeeded, skipped, failed } + let succeeded = 0, + skipped = 0, + failed = 0; + for (const item of items) { + if (item.status === UserActionStatus.Success) succeeded++; + else if (item.status === UserActionStatus.Skipped) skipped++; + else failed++; + } + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + }; } -export function formatUserActionResult(result: UserActionResult): FormattedUserActionTable { - const rows = result.items.map((item, index) => [ - String(index + 1), - item.status, - item.username, - item.enterpriseUserId != null ? String(item.enterpriseUserId) : '', - item.message || item.skipReason || '', - ]) - return { - headers: [...USER_ACTION_TABLE_HEADERS], - rows, - summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, - } +export function formatUserActionResult( + result: UserActionResult, +): FormattedUserActionTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + item.enterpriseUserId != null ? String(item.enterpriseUserId) : "", + item.message || item.skipReason || "", + ]); + return { + headers: [...USER_ACTION_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + }; } -export function renderUserActionAsciiTable(table: FormattedUserActionTable): string { - const { headers, rows } = table - const widths = headers.map((header, index) => - Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) - ) - const padCell = (cell: string, columnIndex: number): string => - cell.padEnd(widths[columnIndex]) - const formatRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - const lines: string[] = [ - formatRow(headers), - formatRow(widths.map((w) => '-'.repeat(w))), - ...rows.map(formatRow), - table.summary, - ] - return lines.join('\n') +export function renderUserActionAsciiTable( + table: FormattedUserActionTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell.padEnd(widths[columnIndex]); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + const lines: string[] = [ + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ]; + return lines.join("\n"); } diff --git a/KeeperSdk/src/users/addUser.ts b/KeeperSdk/src/users/addUser.ts index f0816063..e9e1f33d 100644 --- a/KeeperSdk/src/users/addUser.ts +++ b/KeeperSdk/src/users/addUser.ts @@ -1,287 +1,349 @@ import { - encryptObjectForStorage, - type Auth, - type KeeperResponse, - type RestCommand, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, isNumber, isValidEmail, KeeperSdkError, ResultCodes } from '../utils' + encryptObjectForStorage, + type Auth, + type KeeperResponse, + type RestCommand, +} from "@keeper-security/keeperapi"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseUser, -} from '../teams/enterpriseData' + extractErrorMessage, + isNumber, + isValidEmail, + KeeperSdkError, + ResultCodes, +} from "../utils"; import { - applyDecryptedNodeNames, - applyEnterpriseNameToRoot, - parentNeedsNameLookup, - resolveParentNode, -} from '../teams/teamUtils' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseUser, +} from "../teams/enterpriseData"; import { - EnterpriseUserStatus, - normalizeEmailInputs, - validateUserProfileFields, - AddUserStatus, - AddUserSkipReason, - type AddUserInput, - type AddUserItemResult, - type AddUserResult, - type FormatAddUserResultOptions, - type FormattedAddUserTable, -} from './userTypes' - -export { AddUserStatus, AddUserSkipReason } -export type { AddUserInput, AddUserItemResult, AddUserResult, FormatAddUserResultOptions, FormattedAddUserTable } - -const USER_ADD_COMMAND = 'enterprise_user_add' -const ALLOCATE_IDS_COMMAND = 'enterprise_allocate_ids' -const REINVITE_COMMAND = 'resend_enterprise_invite' + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from "../teams/teamUtils"; +import { + EnterpriseUserStatus, + normalizeEmailInputs, + validateUserProfileFields, + AddUserStatus, + AddUserSkipReason, + type AddUserInput, + type AddUserItemResult, + type AddUserResult, + type FormatAddUserResultOptions, + type FormattedAddUserTable, +} from "./userTypes"; + +export { AddUserStatus, AddUserSkipReason }; +export type { + AddUserInput, + AddUserItemResult, + AddUserResult, + FormatAddUserResultOptions, + FormattedAddUserTable, +}; + +const USER_ADD_COMMAND = "enterprise_user_add"; +const ALLOCATE_IDS_COMMAND = "enterprise_allocate_ids"; +const REINVITE_COMMAND = "resend_enterprise_invite"; const ADD_USER_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Nodes, - EnterpriseDataInclude.Users, -] - -const USER_TABLE_HEADERS = ['#', 'Status', 'Email', 'User ID', 'Node ID', 'Detail'] + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Users, +]; + +const USER_TABLE_HEADERS = [ + "#", + "Status", + "Email", + "User ID", + "Node ID", + "Detail", +]; type UserAddPayload = { - enterprise_user_id: number - enterprise_user_username: string - node_id: number - encrypted_data: string - full_name?: string - job_title?: string -} + enterprise_user_id: number; + enterprise_user_username: string; + node_id: number; + encrypted_data: string; + full_name?: string; + job_title?: string; +}; type AllocateIdsPayload = { - number_requested: number -} + number_requested: number; +}; type ReinvitePayload = { - enterprise_user_id: number -} + enterprise_user_id: number; +}; type AllocateIdsResponse = KeeperResponse & { - base_id: number -} - -export async function addUsers(auth: Auth, input: AddUserInput): Promise { - const rawEmails = [ - ...new Map( - normalizeEmailInputs(input.emails) - .map((e) => [e.toLowerCase(), e] as const) - ).values(), - ] - - if (rawEmails.length === 0) { - throw new KeeperSdkError('No emails provided.', ResultCodes.NO_USERS_TO_ADD) + base_id: number; +}; + +export async function addUsers( + auth: Auth, + input: AddUserInput, +): Promise { + const rawEmails = [ + ...new Map( + normalizeEmailInputs(input.emails).map( + (e) => [e.toLowerCase(), e] as const, + ), + ).values(), + ]; + + if (rawEmails.length === 0) { + throw new KeeperSdkError( + "No emails provided.", + ResultCodes.NO_USERS_TO_ADD, + ); + } + + const parentIdentifier = input.parent ?? null; + const needsNameLookup = parentNeedsNameLookup(parentIdentifier); + + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(ADD_USER_INCLUDES); + const nodes = response.nodes || []; + const existingUsers = response.users || []; + + applyEnterpriseNameToRoot(nodes, response.enterprise_name); + + if (needsNameLookup) { + const displayNames = await enterpriseData.getDisplayNames(); + applyDecryptedNodeNames(nodes, displayNames.nodes); + await enterpriseData.decryptNodeNames(nodes); + } + + const parentNode = resolveParentNode(nodes, parentIdentifier); + const parentNodeId = parentNode.node_id; + const parentNodeName = + EnterpriseDataManager.getNodePath(nodes, parentNode.node_id, { + omitRoot: false, + }) || + (parentNode.displayName || "").trim() || + String(parentNode.node_id); + + const treeKey = await enterpriseData.getTreeKey(); + if (!treeKey) { + throw new KeeperSdkError( + "Enterprise tree key is unavailable.", + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + + const existingByEmail = buildExistingByEmail(existingUsers); + const fullName = (input.fullName || "").trim() || undefined; + const jobTitle = (input.jobTitle || "").trim() || undefined; + validateUserProfileFields(fullName, jobTitle); + + const items: AddUserItemResult[] = []; + + for (const raw of rawEmails) { + const email = raw.toLowerCase(); + const item: AddUserItemResult = { + username: raw, + status: AddUserStatus.Failed, + }; + + if (!isValidEmail(email)) { + item.status = AddUserStatus.Skipped; + item.skipReason = AddUserSkipReason.InvalidEmail; + item.message = `"${raw}" is not a valid email address.`; + items.push(item); + continue; } - const parentIdentifier = input.parent ?? null - const needsNameLookup = parentNeedsNameLookup(parentIdentifier) - - const enterpriseData = new EnterpriseDataManager(auth) - const response = await enterpriseData.getData(ADD_USER_INCLUDES) - const nodes = response.nodes || [] - const existingUsers = response.users || [] - - applyEnterpriseNameToRoot(nodes, response.enterprise_name) - - if (needsNameLookup) { - const displayNames = await enterpriseData.getDisplayNames() - applyDecryptedNodeNames(nodes, displayNames.nodes) - await enterpriseData.decryptNodeNames(nodes) - } - - const parentNode = resolveParentNode(nodes, parentIdentifier) - const parentNodeId = parentNode.node_id - const parentNodeName = - EnterpriseDataManager.getNodePath(nodes, parentNode.node_id, { omitRoot: false }) || - (parentNode.displayName || '').trim() || - String(parentNode.node_id) - - const treeKey = await enterpriseData.getTreeKey() - if (!treeKey) { - throw new KeeperSdkError( - 'Enterprise tree key is unavailable.', - ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE - ) - } - - const existingByEmail = buildExistingByEmail(existingUsers) - const fullName = (input.fullName || '').trim() || undefined - const jobTitle = (input.jobTitle || '').trim() || undefined - validateUserProfileFields(fullName, jobTitle) - - const items: AddUserItemResult[] = [] + const existing = existingByEmail.get(email); + if (existing) { + item.enterpriseUserId = existing.enterprise_user_id; + item.nodeId = existing.node_id; - for (const raw of rawEmails) { - const email = raw.toLowerCase() - const item: AddUserItemResult = { - username: raw, - status: AddUserStatus.Failed, - } - - if (!isValidEmail(email)) { - item.status = AddUserStatus.Skipped - item.skipReason = AddUserSkipReason.InvalidEmail - item.message = `"${raw}" is not a valid email address.` - items.push(item) - continue - } - - const existing = existingByEmail.get(email) - if (existing) { - item.enterpriseUserId = existing.enterprise_user_id - item.nodeId = existing.node_id - - if (existing.status === EnterpriseUserStatus.Invited) { - try { - await sendReinvite(auth, existing.enterprise_user_id) - item.status = AddUserStatus.Reinvited - item.message = 'Invitation resent.' - } catch (err) { - item.message = extractErrorMessage(err) - } - } else { - item.status = AddUserStatus.Skipped - item.skipReason = AddUserSkipReason.AlreadyExists - item.message = `User "${raw}" has already accepted the invitation.` - } - items.push(item) - continue - } - - item.username = email + if (existing.status === EnterpriseUserStatus.Invited) { try { - const enterpriseUserId = await allocateEnterpriseId(auth) - const encryptedData = await encryptObjectForStorage({ displayname: fullName || '' }, treeKey) - await sendUserAdd(auth, { - enterprise_user_id: enterpriseUserId, - enterprise_user_username: email, - node_id: parentNodeId, - encrypted_data: encryptedData, - full_name: fullName, - job_title: jobTitle, - }) - item.enterpriseUserId = enterpriseUserId - item.nodeId = parentNodeId - item.status = AddUserStatus.Added + await sendReinvite(auth, existing.enterprise_user_id); + item.status = AddUserStatus.Reinvited; + item.message = "Invitation resent."; } catch (err) { - item.message = extractErrorMessage(err) + item.message = extractErrorMessage(err); } - items.push(item) + } else { + item.status = AddUserStatus.Skipped; + item.skipReason = AddUserSkipReason.AlreadyExists; + item.message = `User "${raw}" has already accepted the invitation.`; + } + items.push(item); + continue; + } + + item.username = email; + try { + const enterpriseUserId = await allocateEnterpriseId(auth); + const encryptedData = await encryptObjectForStorage( + { displayname: fullName || "" }, + treeKey, + ); + await sendUserAdd(auth, { + enterprise_user_id: enterpriseUserId, + enterprise_user_username: email, + node_id: parentNodeId, + encrypted_data: encryptedData, + full_name: fullName, + job_title: jobTitle, + }); + item.enterpriseUserId = enterpriseUserId; + item.nodeId = parentNodeId; + item.status = AddUserStatus.Added; + } catch (err) { + item.message = extractErrorMessage(err); } + items.push(item); + } - return finalizeResult(items, parentNodeId, parentNodeName) + return finalizeResult(items, parentNodeId, parentNodeName); } async function allocateEnterpriseId(auth: Auth): Promise { - const command: RestCommand = { - baseRequest: { command: ALLOCATE_IDS_COMMAND }, - request: { number_requested: 1 }, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - assertCommandSuccess(response, 'enterprise_allocate_ids failed') - if (!isNumber(response.base_id) || response.base_id === 0) { - throw new KeeperSdkError('Failed to allocate enterprise user ID.', ResultCodes.USER_ADD_FAILED) - } - return response.base_id + const command: RestCommand = { + baseRequest: { command: ALLOCATE_IDS_COMMAND }, + request: { number_requested: 1 }, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + assertCommandSuccess(response, "enterprise_allocate_ids failed"); + if (!isNumber(response.base_id) || response.base_id === 0) { + throw new KeeperSdkError( + "Failed to allocate enterprise user ID.", + ResultCodes.USER_ADD_FAILED, + ); + } + return response.base_id; } async function sendUserAdd(auth: Auth, payload: UserAddPayload): Promise { - const command: RestCommand = { - baseRequest: { command: USER_ADD_COMMAND }, - request: payload, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - assertCommandSuccess(response, `${USER_ADD_COMMAND} failed for "${payload.enterprise_user_username}"`) + const command: RestCommand = { + baseRequest: { command: USER_ADD_COMMAND }, + request: payload, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + assertCommandSuccess( + response, + `${USER_ADD_COMMAND} failed for "${payload.enterprise_user_username}"`, + ); } -async function sendReinvite(auth: Auth, enterpriseUserId: number): Promise { - const command: RestCommand = { - baseRequest: { command: REINVITE_COMMAND }, - request: { enterprise_user_id: enterpriseUserId }, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - assertCommandSuccess(response, `${REINVITE_COMMAND} failed for user_id=${enterpriseUserId}`) +async function sendReinvite( + auth: Auth, + enterpriseUserId: number, +): Promise { + const command: RestCommand = { + baseRequest: { command: REINVITE_COMMAND }, + request: { enterprise_user_id: enterpriseUserId }, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + assertCommandSuccess( + response, + `${REINVITE_COMMAND} failed for user_id=${enterpriseUserId}`, + ); } -function assertCommandSuccess(response: KeeperResponse, fallbackMessage: string): void { - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || response.result_code || fallbackMessage, - response.result_code || ResultCodes.USER_ADD_FAILED - ) - } +function assertCommandSuccess( + response: KeeperResponse, + fallbackMessage: string, +): void { + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || response.result_code || fallbackMessage, + response.result_code || ResultCodes.USER_ADD_FAILED, + ); + } } -function buildExistingByEmail(users: EnterpriseUser[]): Map { - const map = new Map() - for (const u of users) { - if (u.username) map.set(u.username.toLowerCase(), u) - } - return map +function buildExistingByEmail( + users: EnterpriseUser[], +): Map { + const map = new Map(); + for (const u of users) { + if (u.username) map.set(u.username.toLowerCase(), u); + } + return map; } function finalizeResult( - items: AddUserItemResult[], - parentNodeId: number, - parentNodeName: string + items: AddUserItemResult[], + parentNodeId: number, + parentNodeName: string, ): AddUserResult { - let added = 0, reinvited = 0, skipped = 0, failed = 0 - for (const item of items) { - if (item.status === AddUserStatus.Added) added++ - else if (item.status === AddUserStatus.Reinvited) reinvited++ - else if (item.status === AddUserStatus.Skipped) skipped++ - else failed++ - } - return { success: failed === 0 && (added > 0 || reinvited > 0), parentNodeId, parentNodeName, items, added, reinvited, skipped, failed } + let added = 0, + reinvited = 0, + skipped = 0, + failed = 0; + for (const item of items) { + if (item.status === AddUserStatus.Added) added++; + else if (item.status === AddUserStatus.Reinvited) reinvited++; + else if (item.status === AddUserStatus.Skipped) skipped++; + else failed++; + } + return { + success: failed === 0 && (added > 0 || reinvited > 0), + parentNodeId, + parentNodeName, + items, + added, + reinvited, + skipped, + failed, + }; } export function formatAddUserResult( - result: AddUserResult, - options: FormatAddUserResultOptions = {} + result: AddUserResult, + options: FormatAddUserResultOptions = {}, ): FormattedAddUserTable { - const showSkipped = options.showSkipped !== false - const visible = result.items.filter((item) => showSkipped || item.status !== AddUserStatus.Skipped) - - const rows = visible.map((item, index) => [ - String(index + 1), - item.status, - item.username, - item.enterpriseUserId != null ? String(item.enterpriseUserId) : '', - item.nodeId != null ? String(item.nodeId) : '', - item.message || item.skipReason || '', - ]) - - return { - headers: [...USER_TABLE_HEADERS], - rows, - parentNodeName: result.parentNodeName, - summary: `Added: ${result.added} Reinvited: ${result.reinvited} Skipped: ${result.skipped} Failed: ${result.failed}`, - } + const showSkipped = options.showSkipped !== false; + const visible = result.items.filter( + (item) => showSkipped || item.status !== AddUserStatus.Skipped, + ); + + const rows = visible.map((item, index) => [ + String(index + 1), + item.status, + item.username, + item.enterpriseUserId != null ? String(item.enterpriseUserId) : "", + item.nodeId != null ? String(item.nodeId) : "", + item.message || item.skipReason || "", + ]); + + return { + headers: [...USER_TABLE_HEADERS], + rows, + parentNodeName: result.parentNodeName, + summary: `Added: ${result.added} Reinvited: ${result.reinvited} Skipped: ${result.skipped} Failed: ${result.failed}`, + }; } export function renderAddUserAsciiTable(table: FormattedAddUserTable): string { - const { headers, rows } = table - const widths = headers.map((header, index) => - Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) - ) - const padCell = (cell: string, columnIndex: number): string => - cell.padEnd(widths[columnIndex]) - const formatRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - const lines: string[] = [ - `Parent: ${table.parentNodeName}`, - formatRow(headers), - formatRow(widths.map((w) => '-'.repeat(w))), - ...rows.map(formatRow), - table.summary, - ] - return lines.join('\n') + const { headers, rows } = table; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell.padEnd(widths[columnIndex]); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + const lines: string[] = [ + `Parent: ${table.parentNodeName}`, + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ]; + return lines.join("\n"); } diff --git a/KeeperSdk/src/users/aliasUser.ts b/KeeperSdk/src/users/aliasUser.ts index 90af0aa0..839c3b74 100644 --- a/KeeperSdk/src/users/aliasUser.ts +++ b/KeeperSdk/src/users/aliasUser.ts @@ -1,122 +1,177 @@ import { - enterpriseUserAddAliasMessage, - enterpriseUserDeleteAliasMessage, - enterpriseUserSetPrimaryAliasMessage, - type Auth, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, logger, ResultCodes } from '../utils' + enterpriseUserAddAliasMessage, + enterpriseUserDeleteAliasMessage, + enterpriseUserSetPrimaryAliasMessage, + type Auth, +} from "@keeper-security/keeperapi"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseUser, - type EnterpriseUserAliasLink, -} from '../teams/enterpriseData' + extractErrorMessage, + KeeperSdkError, + logger, + ResultCodes, +} from "../utils"; import { - resolveExistingUsers, - AliasOperation, - type AliasUserInput, - type AliasUserResult, -} from './userTypes' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseUser, + type EnterpriseUserAliasLink, +} from "../teams/enterpriseData"; +import { + resolveExistingUsers, + AliasOperation, + type AliasUserInput, + type AliasUserResult, +} from "./userTypes"; -export { AliasOperation } -export type { AliasUserInput, AliasUserResult } +export { AliasOperation }; +export type { AliasUserInput, AliasUserResult }; const ALIAS_USER_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Users, - EnterpriseDataInclude.UserAliases, -] - -export async function aliasUser(auth: Auth, input: AliasUserInput): Promise { - const alias = input.alias.trim().toLowerCase() + EnterpriseDataInclude.Users, + EnterpriseDataInclude.UserAliases, +]; - if (!alias) { - throw new KeeperSdkError('Alias is required.', ResultCodes.USER_ALIAS_FAILED) - } - - const identifier = input.email.trim() - if (!identifier) { - throw new KeeperSdkError('User email or ID is required.', ResultCodes.USER_NOT_FOUND) - } - - const enterpriseData = new EnterpriseDataManager(auth) - const response = await enterpriseData.getData(ALIAS_USER_INCLUDES) - - const [user] = resolveExistingUsers(response.users || [], [identifier]) - const existingAliases = (response.user_aliases || []).filter( - (a) => a.enterprise_user_id === user.enterprise_user_id - ) - - if (input.operation === AliasOperation.Add) { - return addAlias(auth, user, existingAliases, alias) - } - return removeAlias(auth, user, existingAliases, alias) +export async function aliasUser( + auth: Auth, + input: AliasUserInput, +): Promise { + const alias = input.alias.trim().toLowerCase(); + + if (!alias) { + throw new KeeperSdkError( + "Alias is required.", + ResultCodes.USER_ALIAS_FAILED, + ); + } + + const identifier = input.email.trim(); + if (!identifier) { + throw new KeeperSdkError( + "User email or ID is required.", + ResultCodes.USER_NOT_FOUND, + ); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(ALIAS_USER_INCLUDES); + + const [user] = resolveExistingUsers(response.users || [], [identifier]); + const existingAliases = (response.user_aliases || []).filter( + (a) => a.enterprise_user_id === user.enterprise_user_id, + ); + + if (input.operation === AliasOperation.Add) { + return addAlias(auth, user, existingAliases, alias); + } + return removeAlias(auth, user, existingAliases, alias); } async function addAlias( - auth: Auth, - user: EnterpriseUser, - existingAliases: EnterpriseUserAliasLink[], - alias: string + auth: Auth, + user: EnterpriseUser, + existingAliases: EnterpriseUserAliasLink[], + alias: string, ): Promise { - const base = { username: user.username, enterpriseUserId: user.enterprise_user_id, alias, operation: AliasOperation.Add } - - if (user.username === alias) { - logger.info(`Alias "${alias}" is already the primary email for this user.`) - return { ...base, success: true, detail: 'Alias is already the primary email.' } + const base = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + alias, + operation: AliasOperation.Add, + }; + + if (user.username === alias) { + logger.info(`Alias "${alias}" is already the primary email for this user.`); + return { + ...base, + success: true, + detail: "Alias is already the primary email.", + }; + } + + const alreadyExists = existingAliases.some((a) => a.username === alias); + + try { + if (alreadyExists) { + await sendSetPrimaryAlias(auth, user.enterprise_user_id, alias); + return { ...base, success: true, detail: "Alias promoted to primary." }; } - const alreadyExists = existingAliases.some((a) => a.username === alias) - - try { - if (alreadyExists) { - await sendSetPrimaryAlias(auth, user.enterprise_user_id, alias) - return { ...base, success: true, detail: 'Alias promoted to primary.' } - } - - await sendAddAlias(auth, user.enterprise_user_id, alias) - return { ...base, success: true, detail: 'Alias added.' } - } catch (err) { - throw new KeeperSdkError(extractErrorMessage(err), ResultCodes.USER_ALIAS_FAILED) - } + await sendAddAlias(auth, user.enterprise_user_id, alias); + return { ...base, success: true, detail: "Alias added." }; + } catch (err) { + throw new KeeperSdkError( + extractErrorMessage(err), + ResultCodes.USER_ALIAS_FAILED, + ); + } } async function removeAlias( - auth: Auth, - user: EnterpriseUser, - existingAliases: EnterpriseUserAliasLink[], - alias: string + auth: Auth, + user: EnterpriseUser, + existingAliases: EnterpriseUserAliasLink[], + alias: string, ): Promise { - const base = { username: user.username, enterpriseUserId: user.enterprise_user_id, alias, operation: AliasOperation.Remove } - - const exists = alias === user.username || existingAliases.some((a) => a.username === alias) - - if (!exists) { - logger.info(`Alias "${alias}" does not exist for user.`) - return { ...base, success: false, detail: 'Alias does not exist.' } - } - try { - await sendDeleteAlias(auth, user.enterprise_user_id, alias) - return { ...base, success: true, detail: 'Alias removed.' } - } catch (err) { - throw new KeeperSdkError(extractErrorMessage(err), ResultCodes.USER_ALIAS_FAILED) - } + const base = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + alias, + operation: AliasOperation.Remove, + }; + + const exists = + alias === user.username || + existingAliases.some((a) => a.username === alias); + + if (!exists) { + logger.info(`Alias "${alias}" does not exist for user.`); + return { ...base, success: false, detail: "Alias does not exist." }; + } + try { + await sendDeleteAlias(auth, user.enterprise_user_id, alias); + return { ...base, success: true, detail: "Alias removed." }; + } catch (err) { + throw new KeeperSdkError( + extractErrorMessage(err), + ResultCodes.USER_ALIAS_FAILED, + ); + } } -async function sendAddAlias(auth: Auth, enterpriseUserId: number, alias: string): Promise { - const response = await auth.executeRest( - enterpriseUserAddAliasMessage({ enterpriseUserId, alias, primary: true }) - ) - for (const status of response.status || []) { - if (status.status && status.status !== 'success') { - throw new KeeperSdkError(`Add alias failed: ${status.status}`, ResultCodes.USER_ALIAS_FAILED) - } +async function sendAddAlias( + auth: Auth, + enterpriseUserId: number, + alias: string, +): Promise { + const response = await auth.executeRest( + enterpriseUserAddAliasMessage({ enterpriseUserId, alias, primary: true }), + ); + for (const status of response.status || []) { + if (status.status && status.status !== "success") { + throw new KeeperSdkError( + `Add alias failed: ${status.status}`, + ResultCodes.USER_ALIAS_FAILED, + ); } + } } -async function sendSetPrimaryAlias(auth: Auth, enterpriseUserId: number, alias: string): Promise { - await auth.executeRestAction(enterpriseUserSetPrimaryAliasMessage({ enterpriseUserId, alias })) +async function sendSetPrimaryAlias( + auth: Auth, + enterpriseUserId: number, + alias: string, +): Promise { + await auth.executeRestAction( + enterpriseUserSetPrimaryAliasMessage({ enterpriseUserId, alias }), + ); } -async function sendDeleteAlias(auth: Auth, enterpriseUserId: number, alias: string): Promise { - await auth.executeRestAction(enterpriseUserDeleteAliasMessage({ enterpriseUserId, alias })) +async function sendDeleteAlias( + auth: Auth, + enterpriseUserId: number, + alias: string, +): Promise { + await auth.executeRestAction( + enterpriseUserDeleteAliasMessage({ enterpriseUserId, alias }), + ); } diff --git a/KeeperSdk/src/users/deleteUser.ts b/KeeperSdk/src/users/deleteUser.ts index 327dd94e..f536dbdb 100644 --- a/KeeperSdk/src/users/deleteUser.ts +++ b/KeeperSdk/src/users/deleteUser.ts @@ -1,127 +1,148 @@ import { - type Auth, - type KeeperResponse, - type RestCommand, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' + type Auth, + type KeeperResponse, + type RestCommand, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; import { - EnterpriseDataInclude, - EnterpriseDataManager, -} from '../teams/enterpriseData' + EnterpriseDataInclude, + EnterpriseDataManager, +} from "../teams/enterpriseData"; import { - normalizeEmailInputs, - resolveExistingUsers, - DeleteUserStatus, - type DeleteUserInput, - type DeleteUserItemResult, - type DeleteUserResult, - type FormattedDeleteUserTable, -} from './userTypes' - -export { DeleteUserStatus } -export type { DeleteUserInput, DeleteUserItemResult, DeleteUserResult, FormattedDeleteUserTable } - -const USER_DELETE_COMMAND = 'enterprise_user_delete' - -const DELETE_USER_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Users] + normalizeEmailInputs, + resolveExistingUsers, + DeleteUserStatus, + type DeleteUserInput, + type DeleteUserItemResult, + type DeleteUserResult, + type FormattedDeleteUserTable, +} from "./userTypes"; + +export { DeleteUserStatus }; +export type { + DeleteUserInput, + DeleteUserItemResult, + DeleteUserResult, + FormattedDeleteUserTable, +}; + +const USER_DELETE_COMMAND = "enterprise_user_delete"; + +const DELETE_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Users, +]; type UserDeletePayload = { - enterprise_user_id: number -} - -export async function deleteUsers(auth: Auth, input: DeleteUserInput): Promise { - const rawEmails = normalizeEmailInputs(input.emails) - - if (rawEmails.length === 0) { - throw new KeeperSdkError('No users provided for deletion.', ResultCodes.NO_USERS_TO_DELETE) + enterprise_user_id: number; +}; + +export async function deleteUsers( + auth: Auth, + input: DeleteUserInput, +): Promise { + const rawEmails = normalizeEmailInputs(input.emails); + + if (rawEmails.length === 0) { + throw new KeeperSdkError( + "No users provided for deletion.", + ResultCodes.NO_USERS_TO_DELETE, + ); + } + + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(DELETE_USER_INCLUDES); + const allUsers = response.users || []; + const resolvedUsers = resolveExistingUsers(allUsers, rawEmails); + + const items: DeleteUserItemResult[] = []; + + for (const user of resolvedUsers) { + const item: DeleteUserItemResult = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + status: DeleteUserStatus.Failed, + }; + + try { + await sendUserDelete(auth, user.enterprise_user_id); + item.status = DeleteUserStatus.Deleted; + } catch (err) { + item.message = extractErrorMessage(err); } + items.push(item); + } - const enterpriseData = new EnterpriseDataManager(auth) - const response = await enterpriseData.getData(DELETE_USER_INCLUDES) - const allUsers = response.users || [] - const resolvedUsers = resolveExistingUsers(allUsers, rawEmails) - - const items: DeleteUserItemResult[] = [] - - for (const user of resolvedUsers) { - const item: DeleteUserItemResult = { - username: user.username, - enterpriseUserId: user.enterprise_user_id, - status: DeleteUserStatus.Failed, - } - - try { - await sendUserDelete(auth, user.enterprise_user_id) - item.status = DeleteUserStatus.Deleted - } catch (err) { - item.message = extractErrorMessage(err) - } - items.push(item) - } - - return finalizeResult(items) + return finalizeResult(items); } -async function sendUserDelete(auth: Auth, enterpriseUserId: number): Promise { - const command: RestCommand = { - baseRequest: { command: USER_DELETE_COMMAND }, - request: { enterprise_user_id: enterpriseUserId }, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || - response.result_code || - `${USER_DELETE_COMMAND} failed for user_id=${enterpriseUserId}`, - response.result_code || ResultCodes.USER_DELETE_FAILED - ) - } +async function sendUserDelete( + auth: Auth, + enterpriseUserId: number, +): Promise { + const command: RestCommand = { + baseRequest: { command: USER_DELETE_COMMAND }, + request: { enterprise_user_id: enterpriseUserId }, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `${USER_DELETE_COMMAND} failed for user_id=${enterpriseUserId}`, + response.result_code || ResultCodes.USER_DELETE_FAILED, + ); + } } function finalizeResult(items: DeleteUserItemResult[]): DeleteUserResult { - let deleted = 0, failed = 0 - for (const item of items) { - if (item.status === DeleteUserStatus.Deleted) deleted++ - else failed++ - } - return { success: failed === 0 && deleted > 0, items, deleted, failed } + let deleted = 0, + failed = 0; + for (const item of items) { + if (item.status === DeleteUserStatus.Deleted) deleted++; + else failed++; + } + return { success: failed === 0 && deleted > 0, items, deleted, failed }; } -const USER_DELETE_TABLE_HEADERS = ['#', 'Status', 'Email', 'User ID', 'Detail'] - -export function formatDeleteUserResult(result: DeleteUserResult): FormattedDeleteUserTable { - const rows = result.items.map((item, index) => [ - String(index + 1), - item.status, - item.username, - String(item.enterpriseUserId), - item.message || '', - ]) - - return { - headers: [...USER_DELETE_TABLE_HEADERS], - rows, - summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, - } +const USER_DELETE_TABLE_HEADERS = ["#", "Status", "Email", "User ID", "Detail"]; + +export function formatDeleteUserResult( + result: DeleteUserResult, +): FormattedDeleteUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.message || "", + ]); + + return { + headers: [...USER_DELETE_TABLE_HEADERS], + rows, + summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, + }; } -export function renderDeleteUserAsciiTable(table: FormattedDeleteUserTable): string { - const { headers, rows } = table - const widths = headers.map((header, index) => - Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) - ) - const padCell = (cell: string, columnIndex: number): string => - cell.padEnd(widths[columnIndex]) - const formatRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - const lines: string[] = [ - formatRow(headers), - formatRow(widths.map((w) => '-'.repeat(w))), - ...rows.map(formatRow), - table.summary, - ] - return lines.join('\n') +export function renderDeleteUserAsciiTable( + table: FormattedDeleteUserTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell.padEnd(widths[columnIndex]); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + const lines: string[] = [ + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ]; + return lines.join("\n"); } diff --git a/KeeperSdk/src/users/listUsers.ts b/KeeperSdk/src/users/listUsers.ts index 6ec295cf..b8e81bda 100644 --- a/KeeperSdk/src/users/listUsers.ts +++ b/KeeperSdk/src/users/listUsers.ts @@ -1,459 +1,548 @@ -import type { Auth } from '@keeper-security/keeperapi' -import { isNumber, TOKEN_SEPARATOR_PATTERN } from '../utils' +import type { Auth } from "@keeper-security/keeperapi"; +import { isNumber, TOKEN_SEPARATOR_PATTERN } from "../utils"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseDisplayNames, - type EnterpriseNode, - type EnterpriseQueuedTeamRecord, - type EnterpriseQueuedTeamUserLink, - type EnterpriseRole, - type EnterpriseRoleTeamLink, - type EnterpriseRoleUserLink, - type EnterpriseTeamRecord, - type EnterpriseTeamUserLink, - type EnterpriseUser, - type EnterpriseUserAliasLink, -} from '../teams/enterpriseData' -import { applyDecryptedNodeNames } from '../teams/teamUtils' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseDisplayNames, + type EnterpriseNode, + type EnterpriseQueuedTeamRecord, + type EnterpriseQueuedTeamUserLink, + type EnterpriseRole, + type EnterpriseRoleTeamLink, + type EnterpriseRoleUserLink, + type EnterpriseTeamRecord, + type EnterpriseTeamUserLink, + type EnterpriseUser, + type EnterpriseUserAliasLink, +} from "../teams/enterpriseData"; +import { applyDecryptedNodeNames } from "../teams/teamUtils"; import { - formatUserStatus, - formatTransferStatus, - UserColumn, - type UserColumnInput, - type ListUsersOptions, - type ListUserRow, - type FormattedUsersTable, - type FormatUsersTableOptions, -} from './userTypes' - -export { UserColumn } -export type { UserColumnInput, ListUsersOptions, ListUserRow, FormattedUsersTable, FormatUsersTableOptions } - -export const SUPPORTED_USER_COLUMNS: readonly UserColumn[] = Object.values(UserColumn) + formatUserStatus, + formatTransferStatus, + UserColumn, + type UserColumnInput, + type ListUsersOptions, + type ListUserRow, + type FormattedUsersTable, + type FormatUsersTableOptions, +} from "./userTypes"; + +export { UserColumn }; +export type { + UserColumnInput, + ListUsersOptions, + ListUserRow, + FormattedUsersTable, + FormatUsersTableOptions, +}; + +export const SUPPORTED_USER_COLUMNS: readonly UserColumn[] = + Object.values(UserColumn); export const DEFAULT_USER_COLUMNS: readonly UserColumn[] = [ - UserColumn.Name, - UserColumn.Status, - UserColumn.TransferStatus, - UserColumn.Node, -] + UserColumn.Name, + UserColumn.Status, + UserColumn.TransferStatus, + UserColumn.Node, +]; -const NODE_PATH_SEPARATOR = '\\' -const MIN_ASCII_COL_WIDTH = 2 -const ALL_COLUMNS_WILDCARD = '*' +const NODE_PATH_SEPARATOR = "\\"; +const MIN_ASCII_COL_WIDTH = 2; +const ALL_COLUMNS_WILDCARD = "*"; const HEADER_BY_COLUMN: Record = { - [UserColumn.Name]: 'Name', - [UserColumn.Status]: 'Status', - [UserColumn.TransferStatus]: 'Transfer Status', - [UserColumn.Node]: 'Node', - [UserColumn.TeamCount]: 'Team Count', - [UserColumn.Teams]: 'Teams', - [UserColumn.QueuedTeamCount]: 'Queued Team Count', - [UserColumn.QueuedTeams]: 'Queued Teams', - [UserColumn.RoleCount]: 'Role Count', - [UserColumn.Roles]: 'Roles', - [UserColumn.Alias]: 'Alias', - [UserColumn.TwoFaEnabled]: '2FA Enabled', -} + [UserColumn.Name]: "Name", + [UserColumn.Status]: "Status", + [UserColumn.TransferStatus]: "Transfer Status", + [UserColumn.Node]: "Node", + [UserColumn.TeamCount]: "Team Count", + [UserColumn.Teams]: "Teams", + [UserColumn.QueuedTeamCount]: "Queued Team Count", + [UserColumn.QueuedTeams]: "Queued Teams", + [UserColumn.RoleCount]: "Role Count", + [UserColumn.Roles]: "Roles", + [UserColumn.Alias]: "Alias", + [UserColumn.TwoFaEnabled]: "2FA Enabled", +}; type DecorateContext = { - nodePaths: Map - userTeams: Map> - userQueuedTeams: Map> - userRoles: Map> - teamNameByUid: Map - queuedTeamNameByUid: Map - roleNameById: Map - userAliases: Map -} - -export async function listUsers(auth: Auth, options: ListUsersOptions = {}): Promise { - const columns = resolveColumns(options.columns) - const includes = includesForColumns(columns) - const wantsDisplayNames = columns.includes(UserColumn.Node) - - const enterpriseData = new EnterpriseDataManager(auth) - const emptyDisplayNames: EnterpriseDisplayNames = { nodes: new Map(), roles: new Map() } - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(includes), - wantsDisplayNames ? enterpriseData.getDisplayNames() : Promise.resolve(emptyDisplayNames), - ]) - - const nodes = response.nodes || [] - applyDecryptedNodeNames(nodes, displayNames.nodes) - - const context: DecorateContext = { - nodePaths: buildNodePathLookup(nodes), - userTeams: buildUserTeamMap(response.team_users || []), - userQueuedTeams: buildUserQueuedTeamMap(response.queued_team_users || []), - userRoles: buildUserRolesMap( - response.role_users || [], - response.role_teams || [], - response.team_users || [] - ), - teamNameByUid: buildTeamNameMap(response.teams || []), - queuedTeamNameByUid: buildTeamNameMap(response.queued_teams || []), - roleNameById: buildRoleNameMap(response.roles || [], displayNames.roles), - userAliases: buildUserAliasMap(response.user_aliases || []), - } - - const pattern = options.pattern?.trim() || null - const rows: ListUserRow[] = [] - for (const user of response.users || []) { - const row: ListUserRow = { - enterprise_user_id: user.enterprise_user_id, - username: user.username, - } - decorateRow(row, user, columns, context) - if (pattern && !rowMatchesPattern(row, pattern)) continue - rows.push(row) - } - - rows.sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' })) - return rows + nodePaths: Map; + userTeams: Map>; + userQueuedTeams: Map>; + userRoles: Map>; + teamNameByUid: Map; + queuedTeamNameByUid: Map; + roleNameById: Map; + userAliases: Map; +}; + +export async function listUsers( + auth: Auth, + options: ListUsersOptions = {}, +): Promise { + const columns = resolveColumns(options.columns); + const includes = includesForColumns(columns); + const wantsDisplayNames = columns.includes(UserColumn.Node); + + const enterpriseData = new EnterpriseDataManager(auth); + const emptyDisplayNames: EnterpriseDisplayNames = { + nodes: new Map(), + roles: new Map(), + }; + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(includes), + wantsDisplayNames + ? enterpriseData.getDisplayNames() + : Promise.resolve(emptyDisplayNames), + ]); + + const nodes = response.nodes || []; + applyDecryptedNodeNames(nodes, displayNames.nodes); + + const context: DecorateContext = { + nodePaths: buildNodePathLookup(nodes), + userTeams: buildUserTeamMap(response.team_users || []), + userQueuedTeams: buildUserQueuedTeamMap(response.queued_team_users || []), + userRoles: buildUserRolesMap( + response.role_users || [], + response.role_teams || [], + response.team_users || [], + ), + teamNameByUid: buildTeamNameMap(response.teams || []), + queuedTeamNameByUid: buildTeamNameMap(response.queued_teams || []), + roleNameById: buildRoleNameMap(response.roles || [], displayNames.roles), + userAliases: buildUserAliasMap(response.user_aliases || []), + }; + + const pattern = options.pattern?.trim() || null; + const rows: ListUserRow[] = []; + for (const user of response.users || []) { + const row: ListUserRow = { + enterprise_user_id: user.enterprise_user_id, + username: user.username, + }; + decorateRow(row, user, columns, context); + if (pattern && !rowMatchesPattern(row, pattern)) continue; + rows.push(row); + } + + rows.sort((a, b) => + a.username.localeCompare(b.username, undefined, { sensitivity: "base" }), + ); + return rows; } export function formatUsersTable( - rows: ListUserRow[], - options: FormatUsersTableOptions = {} + rows: ListUserRow[], + options: FormatUsersTableOptions = {}, ): FormattedUsersTable { - const columns = resolveColumns(options.columns) - const headers: string[] = ['#', 'User ID', 'Email', ...columns.map((col) => HEADER_BY_COLUMN[col])] - - const outRows: string[][] = rows.map((row, idx) => { - const cells: string[] = [String(idx + 1), String(row.enterprise_user_id), row.username] - for (const col of columns) cells.push(formatCell(row, col)) - return cells - }) - - return { headers, rows: outRows } + const columns = resolveColumns(options.columns); + const headers: string[] = [ + "#", + "User ID", + "Email", + ...columns.map((col) => HEADER_BY_COLUMN[col]), + ]; + + const outRows: string[][] = rows.map((row, idx) => { + const cells: string[] = [ + String(idx + 1), + String(row.enterprise_user_id), + row.username, + ]; + for (const col of columns) cells.push(formatCell(row, col)); + return cells; + }); + + return { headers, rows: outRows }; } export function renderUsersAsciiTable( - table: FormattedUsersTable, - options: { minColWidth?: number } = {} + table: FormattedUsersTable, + options: { minColWidth?: number } = {}, ): string { - const { minColWidth = MIN_ASCII_COL_WIDTH } = options - const { headers, rows } = table - const columnCount = headers.length - - const expandedRows: string[][][] = rows.map((row) => - row.map((cell) => (cell.includes('\n') ? cell.split('\n') : [cell])) - ) - - const columnWidths = new Array(columnCount).fill(0) + const { minColWidth = MIN_ASCII_COL_WIDTH } = options; + const { headers, rows } = table; + const columnCount = headers.length; + + const expandedRows: string[][][] = rows.map((row) => + row.map((cell) => (cell.includes("\n") ? cell.split("\n") : [cell])), + ); + + const columnWidths = new Array(columnCount).fill(0); + for (let ci = 0; ci < columnCount; ci += 1) { + columnWidths[ci] = Math.max(headers[ci].length, minColWidth); + } + for (const row of expandedRows) { for (let ci = 0; ci < columnCount; ci += 1) { - columnWidths[ci] = Math.max(headers[ci].length, minColWidth) - } - for (const row of expandedRows) { - for (let ci = 0; ci < columnCount; ci += 1) { - for (const line of row[ci]) { - columnWidths[ci] = Math.max(columnWidths[ci], line.length, minColWidth) - } - } + for (const line of row[ci]) { + columnWidths[ci] = Math.max(columnWidths[ci], line.length, minColWidth); + } } + } - const padCell = (cell: string, ci: number): string => cell.padEnd(columnWidths[ci]) - const formatPhysicalRow = (cells: string[]): string => - cells.map((cell, ci) => padCell(cell, ci)).join(' ') + const padCell = (cell: string, ci: number): string => + cell.padEnd(columnWidths[ci]); + const formatPhysicalRow = (cells: string[]): string => + cells.map((cell, ci) => padCell(cell, ci)).join(" "); - const ruleRow = formatPhysicalRow(columnWidths.map((w) => '-'.repeat(w))) - const lines: string[] = [formatPhysicalRow(headers), ruleRow] + const ruleRow = formatPhysicalRow(columnWidths.map((w) => "-".repeat(w))); + const lines: string[] = [formatPhysicalRow(headers), ruleRow]; - for (const row of expandedRows) { - const physicalLineCount = Math.max(...row.map((cell) => cell.length)) - for (let li = 0; li < physicalLineCount; li += 1) { - lines.push(formatPhysicalRow(row.map((cell) => cell[li] ?? ''))) - } + for (const row of expandedRows) { + const physicalLineCount = Math.max(...row.map((cell) => cell.length)); + for (let li = 0; li < physicalLineCount; li += 1) { + lines.push(formatPhysicalRow(row.map((cell) => cell[li] ?? ""))); } - return lines.join('\n') + } + return lines.join("\n"); } -function includesForColumns(columns: readonly UserColumn[]): EnterpriseDataInclude[] { - const set = new Set([EnterpriseDataInclude.Users]) - for (const col of columns) { - switch (col) { - case UserColumn.Node: - set.add(EnterpriseDataInclude.Nodes) - break - case UserColumn.TeamCount: - set.add(EnterpriseDataInclude.TeamUsers) - break - case UserColumn.Teams: - set.add(EnterpriseDataInclude.TeamUsers) - set.add(EnterpriseDataInclude.Teams) - break - case UserColumn.QueuedTeamCount: - set.add(EnterpriseDataInclude.QueuedTeamUsers) - break - case UserColumn.QueuedTeams: - set.add(EnterpriseDataInclude.QueuedTeamUsers) - set.add(EnterpriseDataInclude.QueuedTeams) - set.add(EnterpriseDataInclude.Teams) - break - case UserColumn.RoleCount: - case UserColumn.Roles: - set.add(EnterpriseDataInclude.RoleUsers) - set.add(EnterpriseDataInclude.RoleTeams) - set.add(EnterpriseDataInclude.TeamUsers) - if (col === UserColumn.Roles) set.add(EnterpriseDataInclude.Roles) - break - case UserColumn.Alias: - set.add(EnterpriseDataInclude.UserAliases) - break - } +function includesForColumns( + columns: readonly UserColumn[], +): EnterpriseDataInclude[] { + const set = new Set([EnterpriseDataInclude.Users]); + for (const col of columns) { + switch (col) { + case UserColumn.Node: + set.add(EnterpriseDataInclude.Nodes); + break; + case UserColumn.TeamCount: + set.add(EnterpriseDataInclude.TeamUsers); + break; + case UserColumn.Teams: + set.add(EnterpriseDataInclude.TeamUsers); + set.add(EnterpriseDataInclude.Teams); + break; + case UserColumn.QueuedTeamCount: + set.add(EnterpriseDataInclude.QueuedTeamUsers); + break; + case UserColumn.QueuedTeams: + set.add(EnterpriseDataInclude.QueuedTeamUsers); + set.add(EnterpriseDataInclude.QueuedTeams); + set.add(EnterpriseDataInclude.Teams); + break; + case UserColumn.RoleCount: + case UserColumn.Roles: + set.add(EnterpriseDataInclude.RoleUsers); + set.add(EnterpriseDataInclude.RoleTeams); + set.add(EnterpriseDataInclude.TeamUsers); + if (col === UserColumn.Roles) set.add(EnterpriseDataInclude.Roles); + break; + case UserColumn.Alias: + set.add(EnterpriseDataInclude.UserAliases); + break; } - return Array.from(set) + } + return Array.from(set); } -function resolveColumns(input: ListUsersOptions['columns']): UserColumn[] { - if (input == null) return [...DEFAULT_USER_COLUMNS] - if (input === ALL_COLUMNS_WILDCARD) return [...SUPPORTED_USER_COLUMNS] - - const requested = Array.isArray(input) ? input : input.split(',').map((p) => p.trim()) - const allowed = new Set(SUPPORTED_USER_COLUMNS) - const seen = new Set() - for (const col of requested) { - if (col && allowed.has(col)) seen.add(col as UserColumn) - } - if (seen.size === 0) return [...DEFAULT_USER_COLUMNS] - return SUPPORTED_USER_COLUMNS.filter((col) => seen.has(col)) +function resolveColumns(input: ListUsersOptions["columns"]): UserColumn[] { + if (input == null) return [...DEFAULT_USER_COLUMNS]; + if (input === ALL_COLUMNS_WILDCARD) return [...SUPPORTED_USER_COLUMNS]; + + const requested = Array.isArray(input) + ? input + : input.split(",").map((p) => p.trim()); + const allowed = new Set(SUPPORTED_USER_COLUMNS); + const seen = new Set(); + for (const col of requested) { + if (col && allowed.has(col)) seen.add(col as UserColumn); + } + if (seen.size === 0) return [...DEFAULT_USER_COLUMNS]; + return SUPPORTED_USER_COLUMNS.filter((col) => seen.has(col)); } function rowMatchesPattern(row: ListUserRow, pattern: string): boolean { - const lowered = pattern.toLowerCase() - const tokens: string[] = [String(row.enterprise_user_id)] - - const addText = (value: string | undefined): void => { - if (value) tokens.push(...value.toLowerCase().split(TOKEN_SEPARATOR_PATTERN).filter(Boolean)) - } - const addList = (values: string[] | undefined): void => { - values?.forEach((v) => addText(v)) - } - - addText(row.username) - addText(row.name) - addText(row.status) - addText(row.transfer_status) - addText(row.node) - if (row.team_count != null) tokens.push(String(row.team_count)) - if (row.queued_team_count != null) tokens.push(String(row.queued_team_count)) - if (row.role_count != null) tokens.push(String(row.role_count)) - if (row.tfa_enabled != null) tokens.push(String(row.tfa_enabled)) - addList(row.teams) - addList(row.queued_teams) - addList(row.roles) - addList(row.alias) - - return tokens.some((t) => t.includes(lowered)) + const lowered = pattern.toLowerCase(); + const tokens: string[] = [String(row.enterprise_user_id)]; + + const addText = (value: string | undefined): void => { + if (value) + tokens.push( + ...value.toLowerCase().split(TOKEN_SEPARATOR_PATTERN).filter(Boolean), + ); + }; + const addList = (values: string[] | undefined): void => { + values?.forEach((v) => addText(v)); + }; + + addText(row.username); + addText(row.name); + addText(row.status); + addText(row.transfer_status); + addText(row.node); + if (row.team_count != null) tokens.push(String(row.team_count)); + if (row.queued_team_count != null) tokens.push(String(row.queued_team_count)); + if (row.role_count != null) tokens.push(String(row.role_count)); + if (row.tfa_enabled != null) tokens.push(String(row.tfa_enabled)); + addList(row.teams); + addList(row.queued_teams); + addList(row.roles); + addList(row.alias); + + return tokens.some((t) => t.includes(lowered)); } function buildNodePathLookup(nodes: EnterpriseNode[]): Map { - return new Map( - nodes.map((node) => [ - node.node_id, - EnterpriseDataManager.getNodePath(nodes, node.node_id, { - omitRoot: true, - separator: NODE_PATH_SEPARATOR, - }), - ]) - ) + return new Map( + nodes.map((node) => [ + node.node_id, + EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: true, + separator: NODE_PATH_SEPARATOR, + }), + ]), + ); } -function buildUserTeamMap(links: EnterpriseTeamUserLink[]): Map> { - const map = new Map>() - for (const link of links) { - if (!link.team_uid) continue - const set = map.get(link.enterprise_user_id) - if (set) set.add(link.team_uid) - else map.set(link.enterprise_user_id, new Set([link.team_uid])) - } - return map +function buildUserTeamMap( + links: EnterpriseTeamUserLink[], +): Map> { + const map = new Map>(); + for (const link of links) { + if (!link.team_uid) continue; + const set = map.get(link.enterprise_user_id); + if (set) set.add(link.team_uid); + else map.set(link.enterprise_user_id, new Set([link.team_uid])); + } + return map; } -function buildUserQueuedTeamMap(links: EnterpriseQueuedTeamUserLink[]): Map> { - const map = new Map>() - for (const link of links) { - if (!link.team_uid) continue - const set = map.get(link.enterprise_user_id) - if (set) set.add(link.team_uid) - else map.set(link.enterprise_user_id, new Set([link.team_uid])) - } - return map +function buildUserQueuedTeamMap( + links: EnterpriseQueuedTeamUserLink[], +): Map> { + const map = new Map>(); + for (const link of links) { + if (!link.team_uid) continue; + const set = map.get(link.enterprise_user_id); + if (set) set.add(link.team_uid); + else map.set(link.enterprise_user_id, new Set([link.team_uid])); + } + return map; } function buildUserRolesMap( - roleUsers: EnterpriseRoleUserLink[], - roleTeams: EnterpriseRoleTeamLink[], - teamUsers: EnterpriseTeamUserLink[] + roleUsers: EnterpriseRoleUserLink[], + roleTeams: EnterpriseRoleTeamLink[], + teamUsers: EnterpriseTeamUserLink[], ): Map> { - const map = new Map>() - - for (const link of roleUsers) { - const set = map.get(link.enterprise_user_id) - if (set) set.add(link.role_id) - else map.set(link.enterprise_user_id, new Set([link.role_id])) + const map = new Map>(); + + for (const link of roleUsers) { + const set = map.get(link.enterprise_user_id); + if (set) set.add(link.role_id); + else map.set(link.enterprise_user_id, new Set([link.role_id])); + } + + const rolesForTeam = new Map>(); + for (const link of roleTeams) { + if (!link.team_uid) continue; + const set = rolesForTeam.get(link.team_uid); + if (set) set.add(link.role_id); + else rolesForTeam.set(link.team_uid, new Set([link.role_id])); + } + + for (const link of teamUsers) { + if (!link.team_uid) continue; + const teamRoles = rolesForTeam.get(link.team_uid); + if (!teamRoles) continue; + const userRoles = map.get(link.enterprise_user_id); + if (userRoles) { + for (const roleId of teamRoles) userRoles.add(roleId); + } else { + map.set(link.enterprise_user_id, new Set(teamRoles)); } + } - const rolesForTeam = new Map>() - for (const link of roleTeams) { - if (!link.team_uid) continue - const set = rolesForTeam.get(link.team_uid) - if (set) set.add(link.role_id) - else rolesForTeam.set(link.team_uid, new Set([link.role_id])) - } - - for (const link of teamUsers) { - if (!link.team_uid) continue - const teamRoles = rolesForTeam.get(link.team_uid) - if (!teamRoles) continue - const userRoles = map.get(link.enterprise_user_id) - if (userRoles) { - for (const roleId of teamRoles) userRoles.add(roleId) - } else { - map.set(link.enterprise_user_id, new Set(teamRoles)) - } - } - - return map + return map; } -function buildTeamNameMap(teams: Array): Map { - const map = new Map() - for (const team of teams) { - if (team.team_uid) map.set(team.team_uid, (team.name || team.team_uid).trim() || team.team_uid) - } - return map +function buildTeamNameMap( + teams: Array, +): Map { + const map = new Map(); + for (const team of teams) { + if (team.team_uid) + map.set( + team.team_uid, + (team.name || team.team_uid).trim() || team.team_uid, + ); + } + return map; } -function buildRoleNameMap(roles: EnterpriseRole[], decrypted: Map): Map { - const map = new Map() - for (const role of roles) { - if (!isNumber(role.role_id)) continue - const display = (role.displayName || decrypted.get(role.role_id) || '').trim() - map.set(role.role_id, display || String(role.role_id)) - } - return map +function buildRoleNameMap( + roles: EnterpriseRole[], + decrypted: Map, +): Map { + const map = new Map(); + for (const role of roles) { + if (!isNumber(role.role_id)) continue; + const display = ( + role.displayName || + decrypted.get(role.role_id) || + "" + ).trim(); + map.set(role.role_id, display || String(role.role_id)); + } + return map; } -function buildUserAliasMap(links: EnterpriseUserAliasLink[]): Map { - const map = new Map>() - for (const link of links) { - if (!link.username) continue - const set = map.get(link.enterprise_user_id) - if (set) set.add(link.username) - else map.set(link.enterprise_user_id, new Set([link.username])) - } - const result = new Map() - for (const [userId, aliases] of map) { - result.set(userId, [...aliases].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))) - } - return result +function buildUserAliasMap( + links: EnterpriseUserAliasLink[], +): Map { + const map = new Map>(); + for (const link of links) { + if (!link.username) continue; + const set = map.get(link.enterprise_user_id); + if (set) set.add(link.username); + else map.set(link.enterprise_user_id, new Set([link.username])); + } + const result = new Map(); + for (const [userId, aliases] of map) { + result.set( + userId, + [...aliases].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ), + ); + } + return result; } -function resolveSortedNames(keys: Set, nameByKey: Map): string[] { - const names: string[] = [] - for (const key of keys) { - const name = nameByKey.get(key) - if (name) names.push(name) - } - return names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) +function resolveSortedNames( + keys: Set, + nameByKey: Map, +): string[] { + const names: string[] = []; + for (const key of keys) { + const name = nameByKey.get(key); + if (name) names.push(name); + } + return names.sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); } function resolveQueuedTeamNames( - teamUids: Set, - teamNameByUid: Map, - queuedTeamNameByUid: Map + teamUids: Set, + teamNameByUid: Map, + queuedTeamNameByUid: Map, ): string[] { - const names = new Set() - for (const uid of teamUids) { - const fromTeams = teamNameByUid.get(uid) - if (fromTeams) names.add(fromTeams) - const fromQueued = queuedTeamNameByUid.get(uid) - if (fromQueued) names.add(fromQueued) - } - return [...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + const names = new Set(); + for (const uid of teamUids) { + const fromTeams = teamNameByUid.get(uid); + if (fromTeams) names.add(fromTeams); + const fromQueued = queuedTeamNameByUid.get(uid); + if (fromQueued) names.add(fromQueued); + } + return [...names].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); } function decorateRow( - row: ListUserRow, - user: EnterpriseUser, - columns: readonly UserColumn[], - ctx: DecorateContext + row: ListUserRow, + user: EnterpriseUser, + columns: readonly UserColumn[], + ctx: DecorateContext, ): void { - for (const col of columns) { - switch (col) { - case UserColumn.Name: - row.name = (user.full_name || '').trim() - break - case UserColumn.Status: - row.status = formatUserStatus(user) - break - case UserColumn.TransferStatus: - row.transfer_status = formatTransferStatus(user) - break - case UserColumn.Node: - row.node = user.node_id != null ? (ctx.nodePaths.get(user.node_id) ?? '') : '' - break - case UserColumn.TeamCount: - row.team_count = ctx.userTeams.get(user.enterprise_user_id)?.size ?? 0 - break - case UserColumn.Teams: { - const teamUids = ctx.userTeams.get(user.enterprise_user_id) - row.teams = teamUids ? resolveSortedNames(teamUids, ctx.teamNameByUid) : [] - break - } - case UserColumn.QueuedTeamCount: - row.queued_team_count = ctx.userQueuedTeams.get(user.enterprise_user_id)?.size ?? 0 - break - case UserColumn.QueuedTeams: { - const queuedUids = ctx.userQueuedTeams.get(user.enterprise_user_id) - row.queued_teams = queuedUids - ? resolveQueuedTeamNames(queuedUids, ctx.teamNameByUid, ctx.queuedTeamNameByUid) - : [] - break - } - case UserColumn.RoleCount: - row.role_count = ctx.userRoles.get(user.enterprise_user_id)?.size ?? 0 - break - case UserColumn.Roles: { - const roleIds = ctx.userRoles.get(user.enterprise_user_id) - row.roles = roleIds ? resolveSortedNames(roleIds, ctx.roleNameById) : [] - break - } - case UserColumn.Alias: { - const aliases = ctx.userAliases.get(user.enterprise_user_id) - row.alias = aliases - ? aliases.filter((a) => a.toLowerCase() !== user.username.toLowerCase()) - : [] - break - } - case UserColumn.TwoFaEnabled: - row.tfa_enabled = user.tfa_enabled ?? false - break - } + for (const col of columns) { + switch (col) { + case UserColumn.Name: + row.name = (user.full_name || "").trim(); + break; + case UserColumn.Status: + row.status = formatUserStatus(user); + break; + case UserColumn.TransferStatus: + row.transfer_status = formatTransferStatus(user); + break; + case UserColumn.Node: + row.node = + user.node_id != null ? (ctx.nodePaths.get(user.node_id) ?? "") : ""; + break; + case UserColumn.TeamCount: + row.team_count = ctx.userTeams.get(user.enterprise_user_id)?.size ?? 0; + break; + case UserColumn.Teams: { + const teamUids = ctx.userTeams.get(user.enterprise_user_id); + row.teams = teamUids + ? resolveSortedNames(teamUids, ctx.teamNameByUid) + : []; + break; + } + case UserColumn.QueuedTeamCount: + row.queued_team_count = + ctx.userQueuedTeams.get(user.enterprise_user_id)?.size ?? 0; + break; + case UserColumn.QueuedTeams: { + const queuedUids = ctx.userQueuedTeams.get(user.enterprise_user_id); + row.queued_teams = queuedUids + ? resolveQueuedTeamNames( + queuedUids, + ctx.teamNameByUid, + ctx.queuedTeamNameByUid, + ) + : []; + break; + } + case UserColumn.RoleCount: + row.role_count = ctx.userRoles.get(user.enterprise_user_id)?.size ?? 0; + break; + case UserColumn.Roles: { + const roleIds = ctx.userRoles.get(user.enterprise_user_id); + row.roles = roleIds + ? resolveSortedNames(roleIds, ctx.roleNameById) + : []; + break; + } + case UserColumn.Alias: { + const aliases = ctx.userAliases.get(user.enterprise_user_id); + row.alias = aliases + ? aliases.filter( + (a) => a.toLowerCase() !== user.username.toLowerCase(), + ) + : []; + break; + } + case UserColumn.TwoFaEnabled: + row.tfa_enabled = user.tfa_enabled ?? false; + break; } + } } function formatListCell(values: string[] | undefined): string { - return values && values.length > 0 ? values.join('\n') : '' + return values && values.length > 0 ? values.join("\n") : ""; } function formatCell(row: ListUserRow, col: UserColumn): string { - switch (col) { - case UserColumn.Name: return row.name ?? '' - case UserColumn.Status: return row.status ?? '' - case UserColumn.TransferStatus: return row.transfer_status ?? '' - case UserColumn.Node: return row.node ?? '' - case UserColumn.TeamCount: return row.team_count == null ? '' : String(row.team_count) - case UserColumn.Teams: return formatListCell(row.teams) - case UserColumn.QueuedTeamCount: return row.queued_team_count == null ? '' : String(row.queued_team_count) - case UserColumn.QueuedTeams: return formatListCell(row.queued_teams) - case UserColumn.RoleCount: return row.role_count == null ? '' : String(row.role_count) - case UserColumn.Roles: return formatListCell(row.roles) - case UserColumn.Alias: return formatListCell(row.alias) - case UserColumn.TwoFaEnabled: return row.tfa_enabled == null ? '' : String(row.tfa_enabled) - } + switch (col) { + case UserColumn.Name: + return row.name ?? ""; + case UserColumn.Status: + return row.status ?? ""; + case UserColumn.TransferStatus: + return row.transfer_status ?? ""; + case UserColumn.Node: + return row.node ?? ""; + case UserColumn.TeamCount: + return row.team_count == null ? "" : String(row.team_count); + case UserColumn.Teams: + return formatListCell(row.teams); + case UserColumn.QueuedTeamCount: + return row.queued_team_count == null ? "" : String(row.queued_team_count); + case UserColumn.QueuedTeams: + return formatListCell(row.queued_teams); + case UserColumn.RoleCount: + return row.role_count == null ? "" : String(row.role_count); + case UserColumn.Roles: + return formatListCell(row.roles); + case UserColumn.Alias: + return formatListCell(row.alias); + case UserColumn.TwoFaEnabled: + return row.tfa_enabled == null ? "" : String(row.tfa_enabled); + } } diff --git a/KeeperSdk/src/users/teamUser.ts b/KeeperSdk/src/users/teamUser.ts index 93d7838f..4e8f6e23 100644 --- a/KeeperSdk/src/users/teamUser.ts +++ b/KeeperSdk/src/users/teamUser.ts @@ -1,699 +1,876 @@ import { - type Auth, - type Authentication, - Enterprise, - type KeeperResponse, - type RestCommand, - decryptKey, - getPublicKeysMessage, - normal64Bytes, - platform, - teamQueueUserCommand, - teamsEnterpriseUsersAdd, - teamsEnterpriseUsersRemove, - webSafe64FromBytes, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, isNumber, KeeperSdkError, logger, ResultCodes } from '../utils' + type Auth, + type Authentication, + Enterprise, + type KeeperResponse, + type RestCommand, + decryptKey, + getPublicKeysMessage, + normal64Bytes, + platform, + teamQueueUserCommand, + teamsEnterpriseUsersAdd, + teamsEnterpriseUsersRemove, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseQueuedTeamRecord, - type EnterpriseTeamRecord, - type EnterpriseUser, - type EnterpriseTeamUserLink, -} from '../teams/enterpriseData' -import { resolveExistingTeams } from '../teams/teamUtils' + extractErrorMessage, + isNumber, + KeeperSdkError, + logger, + ResultCodes, +} from "../utils"; import { - normalizeEmailInputs, - resolveExistingUsers, - EnterpriseUserStatus, - TeamUserStatus, - TeamUserSkipReason, - type AddUsersToTeamsInput, - type RemoveUsersFromTeamsInput, - type TeamUserItemResult, - type TeamUserResult, - type FormattedTeamUserTable, -} from './userTypes' - -export { TeamUserStatus, TeamUserSkipReason } + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseQueuedTeamRecord, + type EnterpriseTeamRecord, + type EnterpriseUser, + type EnterpriseTeamUserLink, +} from "../teams/enterpriseData"; +import { resolveExistingTeams } from "../teams/teamUtils"; +import { + normalizeEmailInputs, + resolveExistingUsers, + EnterpriseUserStatus, + TeamUserStatus, + TeamUserSkipReason, + type AddUsersToTeamsInput, + type RemoveUsersFromTeamsInput, + type TeamUserItemResult, + type TeamUserResult, + type FormattedTeamUserTable, +} from "./userTypes"; + +export { TeamUserStatus, TeamUserSkipReason }; export type { - AddUsersToTeamsInput, - RemoveUsersFromTeamsInput, - TeamUserItemResult, - TeamUserResult, - FormattedTeamUserTable, -} - -const SUCCESS_RESULT = 'success' -const MAX_TEAM_USERS_PER_REQUEST = 1000 -const MAX_USERS_PER_OPERATION = MAX_TEAM_USERS_PER_REQUEST -const MAX_TEAMS_PER_OPERATION = 100 -const MAX_RESPONSE_MESSAGE_LENGTH = 500 -const UNEXPECTED_TEAM_RESPONSE_MESSAGE = 'Unexpected API response: no team results returned' -const MISSING_TEAM_RESPONSE_MESSAGE = 'Team add response missing for this team' -const UNEXPECTED_REMOVE_RESPONSE_MESSAGE = 'Unexpected API response: no remove results returned' -const MISSING_REMOVE_RESPONSE_MESSAGE = 'Team remove response missing for this user' + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserItemResult, + TeamUserResult, + FormattedTeamUserTable, +}; + +const SUCCESS_RESULT = "success"; +const MAX_TEAM_USERS_PER_REQUEST = 1000; +const MAX_USERS_PER_OPERATION = MAX_TEAM_USERS_PER_REQUEST; +const MAX_TEAMS_PER_OPERATION = 100; +const MAX_RESPONSE_MESSAGE_LENGTH = 500; +const UNEXPECTED_TEAM_RESPONSE_MESSAGE = + "Unexpected API response: no team results returned"; +const MISSING_TEAM_RESPONSE_MESSAGE = "Team add response missing for this team"; +const UNEXPECTED_REMOVE_RESPONSE_MESSAGE = + "Unexpected API response: no remove results returned"; +const MISSING_REMOVE_RESPONSE_MESSAGE = + "Team remove response missing for this user"; const TEAM_USER_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Users, - EnterpriseDataInclude.Teams, - EnterpriseDataInclude.QueuedTeams, - EnterpriseDataInclude.TeamUsers, - EnterpriseDataInclude.QueuedTeamUsers, -] - -const TEAM_USER_TABLE_HEADERS = ['#', 'Status', 'User Email', 'User ID', 'Team Name', 'Team UID', 'Detail'] - -type ResolvedTeam = Pick + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.QueuedTeamUsers, +]; + +const TEAM_USER_TABLE_HEADERS = [ + "#", + "Status", + "User Email", + "User ID", + "Team Name", + "Team UID", + "Detail", +]; + +type ResolvedTeam = Pick< + EnterpriseTeamRecord, + "team_uid" | "name" | "encrypted_team_key" +>; type UserPublicKeys = { - rsaPublicKey: Uint8Array | null - eccPublicKey: Uint8Array | null - errorCode?: string - message?: string -} + rsaPublicKey: Uint8Array | null; + eccPublicKey: Uint8Array | null; + errorCode?: string; + message?: string; +}; type EncryptedTeamKey = { - key: Uint8Array - keyType: Enterprise.EncryptedKeyType -} + key: Uint8Array; + keyType: Enterprise.EncryptedKeyType; +}; type PreparedBatchUser = { - user: EnterpriseUser - encryptedKey: EncryptedTeamKey -} + user: EnterpriseUser; + encryptedKey: EncryptedTeamKey; +}; type PreparedBatchTeam = { - team: ResolvedTeam - prepared: PreparedBatchUser[] -} + team: ResolvedTeam; + prepared: PreparedBatchUser[]; +}; type TeamUserContext = { - enterpriseData: EnterpriseDataManager - teams: ResolvedTeam[] - users: EnterpriseUser[] - membership: Set -} + enterpriseData: EnterpriseDataManager; + teams: ResolvedTeam[]; + users: EnterpriseUser[]; + membership: Set; +}; -type InvitedQueueEntry = { user: EnterpriseUser; team: ResolvedTeam } +type InvitedQueueEntry = { user: EnterpriseUser; team: ResolvedTeam }; type AddBatchPreparation = { - items: TeamUserItemResult[] - batchTeams: PreparedBatchTeam[] - invitedQueue: InvitedQueueEntry[] -} + items: TeamUserItemResult[]; + batchTeams: PreparedBatchTeam[]; + invitedQueue: InvitedQueueEntry[]; +}; -type PreparedRemoveEntry = { user: EnterpriseUser; team: ResolvedTeam } +type PreparedRemoveEntry = { user: EnterpriseUser; team: ResolvedTeam }; type RemoveBatchPreparation = { - items: TeamUserItemResult[] - toRemove: PreparedRemoveEntry[] -} + items: TeamUserItemResult[]; + toRemove: PreparedRemoveEntry[]; +}; -type TeamUserItemBase = Omit +type TeamUserItemBase = Omit; -const membershipKey = (userId: number, teamUid: string): string => `${userId}:${teamUid}` +const membershipKey = (userId: number, teamUid: string): string => + `${userId}:${teamUid}`; -const buildItemBase = (user: EnterpriseUser, team: ResolvedTeam): TeamUserItemBase => ({ - username: user.username, - enterpriseUserId: user.enterprise_user_id, - teamUid: team.team_uid, - teamName: team.name, -}) +const buildItemBase = ( + user: EnterpriseUser, + team: ResolvedTeam, +): TeamUserItemBase => ({ + username: user.username, + enterpriseUserId: user.enterprise_user_id, + teamUid: team.team_uid, + teamName: team.name, +}); const toResolvedTeam = (team: EnterpriseTeamRecord): ResolvedTeam => ({ - team_uid: team.team_uid, - name: team.name, - encrypted_team_key: team.encrypted_team_key, -}) - -const toQueuedTeamRecord = (team: EnterpriseQueuedTeamRecord): EnterpriseTeamRecord => ({ - team_uid: team.team_uid, - name: team.name, - node_id: team.node_id, -}) - -const toPublicKeyBytes = (value: Uint8Array | null | undefined): Uint8Array | null => - value && value.length > 0 ? value : null + team_uid: team.team_uid, + name: team.name, + encrypted_team_key: team.encrypted_team_key, +}); + +const toQueuedTeamRecord = ( + team: EnterpriseQueuedTeamRecord, +): EnterpriseTeamRecord => ({ + team_uid: team.team_uid, + name: team.name, + node_id: team.node_id, +}); + +const toPublicKeyBytes = ( + value: Uint8Array | null | undefined, +): Uint8Array | null => (value && value.length > 0 ? value : null); const normalizeTeamIdentifiers = (teams: string[] | undefined): string[] => - (teams || []).map((t) => t.trim()).filter((t) => t.length > 0) + (teams || []).map((t) => t.trim()).filter((t) => t.length > 0); function buildMembershipSet( - teamUsers: EnterpriseTeamUserLink[] | undefined, - queuedTeamUsers: EnterpriseTeamUserLink[] | undefined + teamUsers: EnterpriseTeamUserLink[] | undefined, + queuedTeamUsers: EnterpriseTeamUserLink[] | undefined, ): Set { - const membership = new Set() - for (const link of [...(teamUsers || []), ...(queuedTeamUsers || [])]) { - membership.add(membershipKey(link.enterprise_user_id, link.team_uid)) - } - return membership -} - -async function fetchUserPublicKeys(auth: Auth, emails: string[]): Promise> { - const result = new Map() - if (emails.length === 0) return result - - let response: Authentication.IGetPublicKeysResponse - try { - response = await auth.executeRest(getPublicKeysMessage({ usernames: emails })) - } catch (err) { - throw new KeeperSdkError( - `Failed to fetch public keys: ${extractErrorMessage(err)}`, - ResultCodes.TEAM_USER_ADD_FAILED - ) - } - - for (const entry of response.keyResponses || []) { - const username = (entry.username || '').toLowerCase() - if (!username) continue - result.set(username, { - rsaPublicKey: toPublicKeyBytes(entry.publicKey ?? undefined), - eccPublicKey: toPublicKeyBytes(entry.publicEccKey ?? undefined), - errorCode: entry.errorCode || undefined, - message: entry.message || undefined, - }) - } - return result + const membership = new Set(); + for (const link of [...(teamUsers || []), ...(queuedTeamUsers || [])]) { + membership.add(membershipKey(link.enterprise_user_id, link.team_uid)); + } + return membership; +} + +async function fetchUserPublicKeys( + auth: Auth, + emails: string[], +): Promise> { + const result = new Map(); + if (emails.length === 0) return result; + + let response: Authentication.IGetPublicKeysResponse; + try { + response = await auth.executeRest( + getPublicKeysMessage({ usernames: emails }), + ); + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch public keys: ${extractErrorMessage(err)}`, + ResultCodes.TEAM_USER_ADD_FAILED, + ); + } + + for (const entry of response.keyResponses || []) { + const username = (entry.username || "").toLowerCase(); + if (!username) continue; + result.set(username, { + rsaPublicKey: toPublicKeyBytes(entry.publicKey ?? undefined), + eccPublicKey: toPublicKeyBytes(entry.publicEccKey ?? undefined), + errorCode: entry.errorCode || undefined, + message: entry.message || undefined, + }); + } + return result; } async function encryptTeamKeyForUser( - teamKey: Uint8Array, - publicKeys: UserPublicKeys + teamKey: Uint8Array, + publicKeys: UserPublicKeys, ): Promise { - if (publicKeys.rsaPublicKey) { - return { - key: platform.publicEncrypt(teamKey, platform.bytesToBase64(publicKeys.rsaPublicKey)), - keyType: Enterprise.EncryptedKeyType.KT_ENCRYPTED_BY_PUBLIC_KEY, - } - } + if (publicKeys.rsaPublicKey) { return { - key: await platform.publicEncryptEC(teamKey, publicKeys.eccPublicKey as Uint8Array), - keyType: Enterprise.EncryptedKeyType.KT_ENCRYPTED_BY_PUBLIC_KEY_ECC, - } + key: platform.publicEncrypt( + teamKey, + platform.bytesToBase64(publicKeys.rsaPublicKey), + ), + keyType: Enterprise.EncryptedKeyType.KT_ENCRYPTED_BY_PUBLIC_KEY, + }; + } + return { + key: await platform.publicEncryptEC( + teamKey, + publicKeys.eccPublicKey as Uint8Array, + ), + keyType: Enterprise.EncryptedKeyType.KT_ENCRYPTED_BY_PUBLIC_KEY_ECC, + }; } async function getDecryptedTeamKey( - team: ResolvedTeam, - treeKey: Uint8Array, - cache: Map + team: ResolvedTeam, + treeKey: Uint8Array, + cache: Map, ): Promise { - if (cache.has(team.team_uid)) return cache.get(team.team_uid) ?? null - if (!team.encrypted_team_key) { - cache.set(team.team_uid, null) - return null - } - try { - const key = await decryptKey(team.encrypted_team_key, treeKey) - cache.set(team.team_uid, key) - return key - } catch (err) { - logger.warn(`Could not decrypt key for team "${team.name}": ${extractErrorMessage(err)}`) - cache.set(team.team_uid, null) - return null - } + if (cache.has(team.team_uid)) return cache.get(team.team_uid) ?? null; + if (!team.encrypted_team_key) { + cache.set(team.team_uid, null); + return null; + } + try { + const key = await decryptKey(team.encrypted_team_key, treeKey); + cache.set(team.team_uid, key); + return key; + } catch (err) { + logger.warn( + `Could not decrypt key for team "${team.name}": ${extractErrorMessage(err)}`, + ); + cache.set(team.team_uid, null); + return null; + } } async function loadTeamUserContext( - auth: Auth, - rawUsers: string[], - rawTeams: string[] + auth: Auth, + rawUsers: string[], + rawTeams: string[], ): Promise { - const emails = normalizeEmailInputs(rawUsers) - if (emails.length === 0) { - throw new KeeperSdkError('No users provided.', ResultCodes.NO_USERS_TO_UPDATE) - } - const teamIdentifiers = normalizeTeamIdentifiers(rawTeams) - if (teamIdentifiers.length === 0) { - throw new KeeperSdkError('No teams provided.', ResultCodes.NO_TEAMS_FOR_USER_OP) - } - validateOperationLimits(emails.length, teamIdentifiers.length) - - const enterpriseData = new EnterpriseDataManager(auth) - const response = await enterpriseData.getData(TEAM_USER_INCLUDES) - const queuedTeams = (response.queued_teams || []).map(toQueuedTeamRecord) - - return { - enterpriseData, - teams: resolveExistingTeams(response.teams || [], teamIdentifiers, queuedTeams).map(toResolvedTeam), - users: resolveExistingUsers(response.users || [], emails), - membership: buildMembershipSet(response.team_users, response.queued_team_users), - } -} - -function determineUserType(hideSharedFolders: boolean | undefined): Enterprise.TeamUserType | undefined { - if (hideSharedFolders === true) return Enterprise.TeamUserType.ADMIN_ONLY - if (hideSharedFolders === false) return Enterprise.TeamUserType.USER - return undefined -} - -function pickPublicKeyError(publicKeys: UserPublicKeys | undefined, username: string): string { - return publicKeys?.message || publicKeys?.errorCode || `No public key for "${username}"` -} - -function hasUsablePublicKey(publicKeys: UserPublicKeys | undefined): publicKeys is UserPublicKeys { - return !!publicKeys && !publicKeys.errorCode && !!(publicKeys.eccPublicKey || publicKeys.rsaPublicKey) + const emails = normalizeEmailInputs(rawUsers); + if (emails.length === 0) { + throw new KeeperSdkError( + "No users provided.", + ResultCodes.NO_USERS_TO_UPDATE, + ); + } + const teamIdentifiers = normalizeTeamIdentifiers(rawTeams); + if (teamIdentifiers.length === 0) { + throw new KeeperSdkError( + "No teams provided.", + ResultCodes.NO_TEAMS_FOR_USER_OP, + ); + } + validateOperationLimits(emails.length, teamIdentifiers.length); + + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(TEAM_USER_INCLUDES); + const queuedTeams = (response.queued_teams || []).map(toQueuedTeamRecord); + + return { + enterpriseData, + teams: resolveExistingTeams( + response.teams || [], + teamIdentifiers, + queuedTeams, + ).map(toResolvedTeam), + users: resolveExistingUsers(response.users || [], emails), + membership: buildMembershipSet( + response.team_users, + response.queued_team_users, + ), + }; +} + +function determineUserType( + hideSharedFolders: boolean | undefined, +): Enterprise.TeamUserType | undefined { + if (hideSharedFolders === true) return Enterprise.TeamUserType.ADMIN_ONLY; + if (hideSharedFolders === false) return Enterprise.TeamUserType.USER; + return undefined; +} + +function pickPublicKeyError( + publicKeys: UserPublicKeys | undefined, + username: string, +): string { + return ( + publicKeys?.message || + publicKeys?.errorCode || + `No public key for "${username}"` + ); +} + +function hasUsablePublicKey( + publicKeys: UserPublicKeys | undefined, +): publicKeys is UserPublicKeys { + return ( + !!publicKeys && + !publicKeys.errorCode && + !!(publicKeys.eccPublicKey || publicKeys.rsaPublicKey) + ); } function sanitizeResponseMessage(value: string): string { - return value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').slice(0, MAX_RESPONSE_MESSAGE_LENGTH) + return value + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") + .slice(0, MAX_RESPONSE_MESSAGE_LENGTH); } function pickResponseError( - message?: string | null, - resultCode?: string | null, - additionalInfo?: string | null, - fallback?: string + message?: string | null, + resultCode?: string | null, + additionalInfo?: string | null, + fallback?: string, ): string | undefined { - for (const candidate of [message, resultCode, additionalInfo, fallback]) { - if (typeof candidate === 'string' && candidate.trim().length > 0) { - return sanitizeResponseMessage(candidate.trim()) - } + for (const candidate of [message, resultCode, additionalInfo, fallback]) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return sanitizeResponseMessage(candidate.trim()); } - return undefined + } + return undefined; } function toEnterpriseUserId(value: unknown): number | null { - const id = typeof value === 'number' ? value : Number(value) - return isNumber(id) && id > 0 ? Math.trunc(id) : null + const id = typeof value === "number" ? value : Number(value); + return isNumber(id) && id > 0 ? Math.trunc(id) : null; } function validateOperationLimits(userCount: number, teamCount: number): void { - if (userCount > MAX_USERS_PER_OPERATION) { - throw new KeeperSdkError( - `Cannot process more than ${MAX_USERS_PER_OPERATION} users at once.`, - ResultCodes.NO_USERS_TO_UPDATE - ) - } - if (teamCount > MAX_TEAMS_PER_OPERATION) { - throw new KeeperSdkError( - `Cannot process more than ${MAX_TEAMS_PER_OPERATION} teams at once.`, - ResultCodes.NO_TEAMS_FOR_USER_OP - ) - } -} - -function validateBatchEntryCount(count: number, operation: 'add' | 'remove'): void { - if (count > MAX_TEAM_USERS_PER_REQUEST) { - const code = operation === 'add' ? ResultCodes.TEAM_USER_ADD_FAILED : ResultCodes.TEAM_USER_REMOVE_FAILED - throw new KeeperSdkError( - `Cannot ${operation} more than ${MAX_TEAM_USERS_PER_REQUEST} user-team pairs in one batch request.`, - code - ) - } + if (userCount > MAX_USERS_PER_OPERATION) { + throw new KeeperSdkError( + `Cannot process more than ${MAX_USERS_PER_OPERATION} users at once.`, + ResultCodes.NO_USERS_TO_UPDATE, + ); + } + if (teamCount > MAX_TEAMS_PER_OPERATION) { + throw new KeeperSdkError( + `Cannot process more than ${MAX_TEAMS_PER_OPERATION} teams at once.`, + ResultCodes.NO_TEAMS_FOR_USER_OP, + ); + } +} + +function validateBatchEntryCount( + count: number, + operation: "add" | "remove", +): void { + if (count > MAX_TEAM_USERS_PER_REQUEST) { + const code = + operation === "add" + ? ResultCodes.TEAM_USER_ADD_FAILED + : ResultCodes.TEAM_USER_REMOVE_FAILED; + throw new KeeperSdkError( + `Cannot ${operation} more than ${MAX_TEAM_USERS_PER_REQUEST} user-team pairs in one batch request.`, + code, + ); + } } async function prepareAddBatches( - teams: ResolvedTeam[], - users: EnterpriseUser[], - membership: Set, - publicKeyMap: Map, - treeKey: Uint8Array + teams: ResolvedTeam[], + users: EnterpriseUser[], + membership: Set, + publicKeyMap: Map, + treeKey: Uint8Array, ): Promise { - const items: TeamUserItemResult[] = [] - const batchTeams: PreparedBatchTeam[] = [] - const invitedQueue: InvitedQueueEntry[] = [] - const teamKeyCache = new Map() - - for (const team of teams) { - const prepared: PreparedBatchUser[] = [] - - for (const user of users) { - const base = buildItemBase(user, team) - - if (membership.has(membershipKey(user.enterprise_user_id, team.team_uid))) { - items.push({ ...base, status: TeamUserStatus.Skipped, skipReason: TeamUserSkipReason.AlreadyMember }) - continue - } - - if (user.status !== EnterpriseUserStatus.Active) { - invitedQueue.push({ user, team }) - continue - } - - const publicKeys = publicKeyMap.get(user.username.toLowerCase()) - if (!hasUsablePublicKey(publicKeys)) { - items.push({ - ...base, - status: TeamUserStatus.MissingPublicKey, - message: pickPublicKeyError(publicKeys, user.username), - }) - continue - } - - const teamKey = await getDecryptedTeamKey(team, treeKey, teamKeyCache) - if (!teamKey) { - items.push({ - ...base, - status: TeamUserStatus.Failed, - message: `Team key for "${team.name}" is unavailable.`, - }) - continue - } - - try { - prepared.push({ user, encryptedKey: await encryptTeamKeyForUser(teamKey, publicKeys) }) - } catch (err) { - items.push({ ...base, status: TeamUserStatus.Failed, message: extractErrorMessage(err) }) - } - } - - if (prepared.length > 0) batchTeams.push({ team, prepared }) + const items: TeamUserItemResult[] = []; + const batchTeams: PreparedBatchTeam[] = []; + const invitedQueue: InvitedQueueEntry[] = []; + const teamKeyCache = new Map(); + + for (const team of teams) { + const prepared: PreparedBatchUser[] = []; + + for (const user of users) { + const base = buildItemBase(user, team); + + if ( + membership.has(membershipKey(user.enterprise_user_id, team.team_uid)) + ) { + items.push({ + ...base, + status: TeamUserStatus.Skipped, + skipReason: TeamUserSkipReason.AlreadyMember, + }); + continue; + } + + if (user.status !== EnterpriseUserStatus.Active) { + invitedQueue.push({ user, team }); + continue; + } + + const publicKeys = publicKeyMap.get(user.username.toLowerCase()); + if (!hasUsablePublicKey(publicKeys)) { + items.push({ + ...base, + status: TeamUserStatus.MissingPublicKey, + message: pickPublicKeyError(publicKeys, user.username), + }); + continue; + } + + const teamKey = await getDecryptedTeamKey(team, treeKey, teamKeyCache); + if (!teamKey) { + items.push({ + ...base, + status: TeamUserStatus.Failed, + message: `Team key for "${team.name}" is unavailable.`, + }); + continue; + } + + try { + prepared.push({ + user, + encryptedKey: await encryptTeamKeyForUser(teamKey, publicKeys), + }); + } catch (err) { + items.push({ + ...base, + status: TeamUserStatus.Failed, + message: extractErrorMessage(err), + }); + } } - return { items, batchTeams, invitedQueue } + if (prepared.length > 0) batchTeams.push({ team, prepared }); + } + + return { items, batchTeams, invitedQueue }; } function buildAddTeamRequests( - batchTeams: PreparedBatchTeam[], - userType: Enterprise.TeamUserType | undefined + batchTeams: PreparedBatchTeam[], + userType: Enterprise.TeamUserType | undefined, ): Enterprise.ITeamsEnterpriseUsersAddTeamRequest[] { - return batchTeams.map(({ team, prepared }) => ({ - teamUid: normal64Bytes(team.team_uid), - users: prepared.map(({ user, encryptedKey }) => { - const userReq: Enterprise.ITeamsEnterpriseUsersAddUserRequest = { - enterpriseUserId: user.enterprise_user_id, - typedTeamKey: { key: encryptedKey.key, keyType: encryptedKey.keyType }, - } - if (userType !== undefined) userReq.userType = userType - return userReq - }), - })) -} - -function markAllBatchUsersFailed(batchTeams: PreparedBatchTeam[], message: string): TeamUserItemResult[] { - return batchTeams.flatMap(({ team, prepared }) => - prepared.map(({ user }) => ({ ...buildItemBase(user, team), status: TeamUserStatus.Failed, message })) - ) + return batchTeams.map(({ team, prepared }) => ({ + teamUid: normal64Bytes(team.team_uid), + users: prepared.map(({ user, encryptedKey }) => { + const userReq: Enterprise.ITeamsEnterpriseUsersAddUserRequest = { + enterpriseUserId: user.enterprise_user_id, + typedTeamKey: { key: encryptedKey.key, keyType: encryptedKey.keyType }, + }; + if (userType !== undefined) userReq.userType = userType; + return userReq; + }), + })); +} + +function markAllBatchUsersFailed( + batchTeams: PreparedBatchTeam[], + message: string, +): TeamUserItemResult[] { + return batchTeams.flatMap(({ team, prepared }) => + prepared.map(({ user }) => ({ + ...buildItemBase(user, team), + status: TeamUserStatus.Failed, + message, + })), + ); } function mergeTeamResponse( - prepTeam: PreparedBatchTeam, - teamResp: Enterprise.ITeamsEnterpriseUsersAddTeamResponse + prepTeam: PreparedBatchTeam, + teamResp: Enterprise.ITeamsEnterpriseUsersAddTeamResponse, ): TeamUserItemResult[] { - const userMap = new Map(prepTeam.prepared.map((p) => [p.user.enterprise_user_id, p.user])) - const teamFailureMessage = - teamResp.success === false - ? pickResponseError(teamResp.message, teamResp.resultCode, teamResp.additionalInfo, 'Team add failed') - : undefined - - const items: TeamUserItemResult[] = [] - for (const userResp of teamResp.users || []) { - const enterpriseUserId = toEnterpriseUserId(userResp.enterpriseUserId) - if (enterpriseUserId === null) continue - const user = userMap.get(enterpriseUserId) - if (!user) continue - const success = userResp.success === true - items.push({ - ...buildItemBase(user, prepTeam.team), - status: success ? TeamUserStatus.Added : TeamUserStatus.Failed, - message: success - ? undefined - : pickResponseError( - userResp.message, - userResp.resultCode, - userResp.additionalInfo, - teamFailureMessage - ), - }) - userMap.delete(enterpriseUserId) - } - - for (const user of userMap.values()) { - items.push({ - ...buildItemBase(user, prepTeam.team), - status: teamFailureMessage ? TeamUserStatus.Failed : TeamUserStatus.Added, - message: teamFailureMessage, - }) - } - return items + const userMap = new Map( + prepTeam.prepared.map((p) => [p.user.enterprise_user_id, p.user]), + ); + const teamFailureMessage = + teamResp.success === false + ? pickResponseError( + teamResp.message, + teamResp.resultCode, + teamResp.additionalInfo, + "Team add failed", + ) + : undefined; + + const items: TeamUserItemResult[] = []; + for (const userResp of teamResp.users || []) { + const enterpriseUserId = toEnterpriseUserId(userResp.enterpriseUserId); + if (enterpriseUserId === null) continue; + const user = userMap.get(enterpriseUserId); + if (!user) continue; + const success = userResp.success === true; + items.push({ + ...buildItemBase(user, prepTeam.team), + status: success ? TeamUserStatus.Added : TeamUserStatus.Failed, + message: success + ? undefined + : pickResponseError( + userResp.message, + userResp.resultCode, + userResp.additionalInfo, + teamFailureMessage, + ), + }); + userMap.delete(enterpriseUserId); + } + + for (const user of userMap.values()) { + items.push({ + ...buildItemBase(user, prepTeam.team), + status: teamFailureMessage ? TeamUserStatus.Failed : TeamUserStatus.Added, + message: teamFailureMessage, + }); + } + return items; } async function sendTeamsEnterpriseUsersAdd( - auth: Auth, - batchTeams: PreparedBatchTeam[], - userType: Enterprise.TeamUserType | undefined + auth: Auth, + batchTeams: PreparedBatchTeam[], + userType: Enterprise.TeamUserType | undefined, ): Promise { - if (batchTeams.length === 0) return [] - - validateBatchEntryCount( - batchTeams.reduce((count, batch) => count + batch.prepared.length, 0), - 'add' - ) - - let response: Enterprise.ITeamsEnterpriseUsersAddResponse - try { - response = await auth.executeRest( - teamsEnterpriseUsersAdd({ teams: buildAddTeamRequests(batchTeams, userType) }) - ) - } catch (err) { - return markAllBatchUsersFailed(batchTeams, extractErrorMessage(err)) - } - - const teamResponses = response.teams ?? [] - if (teamResponses.length === 0) { - return markAllBatchUsersFailed(batchTeams, UNEXPECTED_TEAM_RESPONSE_MESSAGE) - } - - const items: TeamUserItemResult[] = [] - const teamMap = new Map(batchTeams.map((p) => [p.team.team_uid, p])) - - for (const teamResp of teamResponses) { - const teamUid = teamResp.teamUid ? webSafe64FromBytes(teamResp.teamUid as Uint8Array) : '' - const prepTeam = teamMap.get(teamUid) - if (!prepTeam) continue - items.push(...mergeTeamResponse(prepTeam, teamResp)) - teamMap.delete(teamUid) - } - - for (const prepTeam of teamMap.values()) { - for (const { user } of prepTeam.prepared) { - items.push({ - ...buildItemBase(user, prepTeam.team), - status: TeamUserStatus.Failed, - message: MISSING_TEAM_RESPONSE_MESSAGE, - }) - } + if (batchTeams.length === 0) return []; + + validateBatchEntryCount( + batchTeams.reduce((count, batch) => count + batch.prepared.length, 0), + "add", + ); + + let response: Enterprise.ITeamsEnterpriseUsersAddResponse; + try { + response = await auth.executeRest( + teamsEnterpriseUsersAdd({ + teams: buildAddTeamRequests(batchTeams, userType), + }), + ); + } catch (err) { + return markAllBatchUsersFailed(batchTeams, extractErrorMessage(err)); + } + + const teamResponses = response.teams ?? []; + if (teamResponses.length === 0) { + return markAllBatchUsersFailed( + batchTeams, + UNEXPECTED_TEAM_RESPONSE_MESSAGE, + ); + } + + const items: TeamUserItemResult[] = []; + const teamMap = new Map(batchTeams.map((p) => [p.team.team_uid, p])); + + for (const teamResp of teamResponses) { + const teamUid = teamResp.teamUid + ? webSafe64FromBytes(teamResp.teamUid as Uint8Array) + : ""; + const prepTeam = teamMap.get(teamUid); + if (!prepTeam) continue; + items.push(...mergeTeamResponse(prepTeam, teamResp)); + teamMap.delete(teamUid); + } + + for (const prepTeam of teamMap.values()) { + for (const { user } of prepTeam.prepared) { + items.push({ + ...buildItemBase(user, prepTeam.team), + status: TeamUserStatus.Failed, + message: MISSING_TEAM_RESPONSE_MESSAGE, + }); } - return items + } + return items; } async function executeTeamUserCommand( - auth: Auth, - command: RestCommand<{ enterprise_user_id: number; team_uid: string }, KeeperResponse>, - fallbackErrorCode: string + auth: Auth, + command: RestCommand< + { enterprise_user_id: number; team_uid: string }, + KeeperResponse + >, + fallbackErrorCode: string, ): Promise { - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== SUCCESS_RESULT) { - const { enterprise_user_id: userId, team_uid: teamUid } = command.request - throw new KeeperSdkError( - response.message || - response.result_code || - `${command.baseRequest.command} failed for user=${userId}, team=${teamUid}`, - response.result_code || fallbackErrorCode - ) - } + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== SUCCESS_RESULT) { + const { enterprise_user_id: userId, team_uid: teamUid } = command.request; + throw new KeeperSdkError( + response.message || + response.result_code || + `${command.baseRequest.command} failed for user=${userId}, team=${teamUid}`, + response.result_code || fallbackErrorCode, + ); + } } function prepareRemoveEntries( - teams: ResolvedTeam[], - users: EnterpriseUser[], - membership: Set + teams: ResolvedTeam[], + users: EnterpriseUser[], + membership: Set, ): RemoveBatchPreparation { - const items: TeamUserItemResult[] = [] - const toRemove: PreparedRemoveEntry[] = [] - - for (const team of teams) { - for (const user of users) { - const base = buildItemBase(user, team) - if (!membership.has(membershipKey(user.enterprise_user_id, team.team_uid))) { - items.push({ ...base, status: TeamUserStatus.Skipped, skipReason: TeamUserSkipReason.NotMember }) - continue - } - toRemove.push({ user, team }) - } + const items: TeamUserItemResult[] = []; + const toRemove: PreparedRemoveEntry[] = []; + + for (const team of teams) { + for (const user of users) { + const base = buildItemBase(user, team); + if ( + !membership.has(membershipKey(user.enterprise_user_id, team.team_uid)) + ) { + items.push({ + ...base, + status: TeamUserStatus.Skipped, + skipReason: TeamUserSkipReason.NotMember, + }); + continue; + } + toRemove.push({ user, team }); } + } - return { items, toRemove } + return { items, toRemove }; } -function markAllRemoveFailed(entries: PreparedRemoveEntry[], message: string): TeamUserItemResult[] { - return entries.map(({ user, team }) => ({ - ...buildItemBase(user, team), - status: TeamUserStatus.Failed, - message, - })) +function markAllRemoveFailed( + entries: PreparedRemoveEntry[], + message: string, +): TeamUserItemResult[] { + return entries.map(({ user, team }) => ({ + ...buildItemBase(user, team), + status: TeamUserStatus.Failed, + message, + })); } async function sendTeamsEnterpriseUsersRemove( - auth: Auth, - entries: PreparedRemoveEntry[] + auth: Auth, + entries: PreparedRemoveEntry[], ): Promise { - if (entries.length === 0) return [] - - validateBatchEntryCount(entries.length, 'remove') - - const request: Enterprise.ITeamEnterpriseUserRemovesRequest = { - teamEnterpriseUserRemove: entries.map(({ user, team }) => ({ - teamUid: normal64Bytes(team.team_uid), - enterpriseUserId: user.enterprise_user_id, - })), - } - - let response: Enterprise.ITeamEnterpriseUserRemovesResponse + if (entries.length === 0) return []; + + validateBatchEntryCount(entries.length, "remove"); + + const request: Enterprise.ITeamEnterpriseUserRemovesRequest = { + teamEnterpriseUserRemove: entries.map(({ user, team }) => ({ + teamUid: normal64Bytes(team.team_uid), + enterpriseUserId: user.enterprise_user_id, + })), + }; + + let response: Enterprise.ITeamEnterpriseUserRemovesResponse; + try { + response = await auth.executeRest(teamsEnterpriseUsersRemove(request)); + } catch (err) { + return markAllRemoveFailed(entries, extractErrorMessage(err)); + } + + const removeResponses = response.teamEnterpriseUserRemoveResponse ?? []; + if (removeResponses.length === 0) { + return markAllRemoveFailed(entries, UNEXPECTED_REMOVE_RESPONSE_MESSAGE); + } + + const entryMap = new Map( + entries.map((entry) => [ + membershipKey(entry.user.enterprise_user_id, entry.team.team_uid), + entry, + ]), + ); + const items: TeamUserItemResult[] = []; + + for (const userResp of removeResponses) { + const remove = userResp.teamEnterpriseUserRemove; + const teamUid = remove?.teamUid + ? webSafe64FromBytes(remove.teamUid as Uint8Array) + : ""; + const enterpriseUserId = toEnterpriseUserId(remove?.enterpriseUserId); + if (!teamUid || enterpriseUserId === null) continue; + + const entry = entryMap.get(membershipKey(enterpriseUserId, teamUid)); + if (!entry) continue; + + const success = userResp.success === true; + items.push({ + ...buildItemBase(entry.user, entry.team), + status: success ? TeamUserStatus.Removed : TeamUserStatus.Failed, + message: success + ? undefined + : pickResponseError( + userResp.message, + userResp.resultCode, + userResp.additionalInfo, + "Team remove failed", + ), + }); + entryMap.delete(membershipKey(enterpriseUserId, teamUid)); + } + + for (const entry of entryMap.values()) { + items.push({ + ...buildItemBase(entry.user, entry.team), + status: TeamUserStatus.Failed, + message: MISSING_REMOVE_RESPONSE_MESSAGE, + }); + } + + return items; +} + +async function processInvitedQueue( + auth: Auth, + invitedQueue: InvitedQueueEntry[], +): Promise { + const items: TeamUserItemResult[] = []; + for (const { user, team } of invitedQueue) { + const base = buildItemBase(user, team); try { - response = await auth.executeRest(teamsEnterpriseUsersRemove(request)) + await executeTeamUserCommand( + auth, + teamQueueUserCommand({ + enterprise_user_id: user.enterprise_user_id, + team_uid: team.team_uid, + }), + ResultCodes.TEAM_USER_ADD_FAILED, + ); + items.push({ ...base, status: TeamUserStatus.Added }); } catch (err) { - return markAllRemoveFailed(entries, extractErrorMessage(err)) - } - - const removeResponses = response.teamEnterpriseUserRemoveResponse ?? [] - if (removeResponses.length === 0) { - return markAllRemoveFailed(entries, UNEXPECTED_REMOVE_RESPONSE_MESSAGE) - } - - const entryMap = new Map( - entries.map((entry) => [membershipKey(entry.user.enterprise_user_id, entry.team.team_uid), entry]) - ) - const items: TeamUserItemResult[] = [] - - for (const userResp of removeResponses) { - const remove = userResp.teamEnterpriseUserRemove - const teamUid = remove?.teamUid ? webSafe64FromBytes(remove.teamUid as Uint8Array) : '' - const enterpriseUserId = toEnterpriseUserId(remove?.enterpriseUserId) - if (!teamUid || enterpriseUserId === null) continue - - const entry = entryMap.get(membershipKey(enterpriseUserId, teamUid)) - if (!entry) continue - - const success = userResp.success === true - items.push({ - ...buildItemBase(entry.user, entry.team), - status: success ? TeamUserStatus.Removed : TeamUserStatus.Failed, - message: success - ? undefined - : pickResponseError(userResp.message, userResp.resultCode, userResp.additionalInfo, 'Team remove failed'), - }) - entryMap.delete(membershipKey(enterpriseUserId, teamUid)) - } - - for (const entry of entryMap.values()) { - items.push({ - ...buildItemBase(entry.user, entry.team), - status: TeamUserStatus.Failed, - message: MISSING_REMOVE_RESPONSE_MESSAGE, - }) - } - - return items -} - -async function processInvitedQueue(auth: Auth, invitedQueue: InvitedQueueEntry[]): Promise { - const items: TeamUserItemResult[] = [] - for (const { user, team } of invitedQueue) { - const base = buildItemBase(user, team) - try { - await executeTeamUserCommand( - auth, - teamQueueUserCommand({ enterprise_user_id: user.enterprise_user_id, team_uid: team.team_uid }), - ResultCodes.TEAM_USER_ADD_FAILED - ) - items.push({ ...base, status: TeamUserStatus.Added }) - } catch (err) { - items.push({ ...base, status: TeamUserStatus.Failed, message: extractErrorMessage(err) }) - } + items.push({ + ...base, + status: TeamUserStatus.Failed, + message: extractErrorMessage(err), + }); } - return items + } + return items; } -export async function addUsersToTeams(auth: Auth, input: AddUsersToTeamsInput): Promise { - const ctx = await loadTeamUserContext(auth, input.users, input.teams || []) - - const treeKey = await ctx.enterpriseData.getTreeKey() - if (!treeKey) { - throw new KeeperSdkError('Enterprise tree key is unavailable.', ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE) - } - - const publicKeyMap = await fetchUserPublicKeys( +export async function addUsersToTeams( + auth: Auth, + input: AddUsersToTeamsInput, +): Promise { + const ctx = await loadTeamUserContext(auth, input.users, input.teams || []); + + const treeKey = await ctx.enterpriseData.getTreeKey(); + if (!treeKey) { + throw new KeeperSdkError( + "Enterprise tree key is unavailable.", + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + + const publicKeyMap = await fetchUserPublicKeys( + auth, + ctx.users + .filter((u) => u.status === EnterpriseUserStatus.Active) + .map((u) => u.username), + ); + + const { items, batchTeams, invitedQueue } = await prepareAddBatches( + ctx.teams, + ctx.users, + ctx.membership, + publicKeyMap, + treeKey, + ); + + if (batchTeams.length > 0) { + items.push( + ...(await sendTeamsEnterpriseUsersAdd( auth, - ctx.users.filter((u) => u.status === EnterpriseUserStatus.Active).map((u) => u.username) - ) - - const { items, batchTeams, invitedQueue } = await prepareAddBatches( - ctx.teams, - ctx.users, - ctx.membership, - publicKeyMap, - treeKey - ) - - if (batchTeams.length > 0) { - items.push(...(await sendTeamsEnterpriseUsersAdd(auth, batchTeams, determineUserType(input.hideSharedFolders)))) - } - items.push(...(await processInvitedQueue(auth, invitedQueue))) + batchTeams, + determineUserType(input.hideSharedFolders), + )), + ); + } + items.push(...(await processInvitedQueue(auth, invitedQueue))); - return finalizeResult(items) + return finalizeResult(items); } export async function removeUsersFromTeams( - auth: Auth, - input: RemoveUsersFromTeamsInput + auth: Auth, + input: RemoveUsersFromTeamsInput, ): Promise { - const ctx = await loadTeamUserContext(auth, input.users, input.teams || []) - const { items, toRemove } = prepareRemoveEntries(ctx.teams, ctx.users, ctx.membership) + const ctx = await loadTeamUserContext(auth, input.users, input.teams || []); + const { items, toRemove } = prepareRemoveEntries( + ctx.teams, + ctx.users, + ctx.membership, + ); - if (toRemove.length > 0) { - items.push(...(await sendTeamsEnterpriseUsersRemove(auth, toRemove))) - } + if (toRemove.length > 0) { + items.push(...(await sendTeamsEnterpriseUsersRemove(auth, toRemove))); + } - return finalizeResult(items) + return finalizeResult(items); } function finalizeResult(items: TeamUserItemResult[]): TeamUserResult { - let succeeded = 0 - let skipped = 0 - let failed = 0 - for (const item of items) { - if (item.status === TeamUserStatus.Added || item.status === TeamUserStatus.Removed) succeeded++ - else if (item.status === TeamUserStatus.Skipped) skipped++ - else failed++ - } - return { success: failed === 0 && succeeded > 0, items, succeeded, skipped, failed } -} - -export function formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { - const rows = result.items.map((item, index) => [ - String(index + 1), - item.status, - item.username, - String(item.enterpriseUserId), - item.teamName, - item.teamUid, - item.message || item.skipReason || '', - ]) - return { - headers: [...TEAM_USER_TABLE_HEADERS], - rows, - summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, - } -} - -export function renderTeamUserAsciiTable(table: FormattedTeamUserTable): string { - const { headers, rows } = table - const widths = headers.map((header, columnIndex) => - Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + let succeeded = 0; + let skipped = 0; + let failed = 0; + for (const item of items) { + if ( + item.status === TeamUserStatus.Added || + item.status === TeamUserStatus.Removed ) - const padCell = (cell: string, columnIndex: number): string => - cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) - const formatRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - return [ - formatRow(headers), - formatRow(widths.map((w) => '-'.repeat(w))), - ...rows.map(formatRow), - table.summary, - ].join('\n') + succeeded++; + else if (item.status === TeamUserStatus.Skipped) skipped++; + else failed++; + } + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + }; +} + +export function formatTeamUserResult( + result: TeamUserResult, +): FormattedTeamUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.teamName, + item.teamUid, + item.message || item.skipReason || "", + ]); + return { + headers: [...TEAM_USER_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + }; +} + +export function renderTeamUserAsciiTable( + table: FormattedTeamUserTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, columnIndex) => + Math.max( + header.length, + ...rows.map((row) => (row[columnIndex] || "").length), + ), + ); + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + return [ + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join("\n"); } diff --git a/KeeperSdk/src/users/updateTeamUser.ts b/KeeperSdk/src/users/updateTeamUser.ts new file mode 100644 index 00000000..6ed62dbe --- /dev/null +++ b/KeeperSdk/src/users/updateTeamUser.ts @@ -0,0 +1,242 @@ +import { + Enterprise, + teamEnterpriseUserUpdateCommand, + type Auth, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError, ResultCodes } from "../utils"; +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseTeamRecord, + type EnterpriseTeamUserLink, + type EnterpriseUser, +} from "../teams/enterpriseData"; +import { resolveExistingTeams } from "../teams/teamUtils"; +import { normalizeEmailInputs, resolveExistingUsers } from "./userTypes"; + +const UPDATE_TEAM_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.TeamUsers, +]; + +const UPDATE_TEAM_USER_TABLE_HEADERS = [ + "#", + "Status", + "User Email", + "User ID", + "Team Name", + "Team UID", + "Detail", +]; + +export enum UpdateTeamUserStatus { + Updated = "updated", + Skipped = "skipped", + Failed = "failed", +} + +export enum UpdateTeamUserSkipReason { + NotMember = "not_member", +} + +export type UpdateUsersOnTeamsInput = { + users: string[]; + teams: string[]; + hideSharedFolders: boolean; +}; + +export type UpdateTeamUserItemResult = { + username: string; + enterpriseUserId: number; + teamUid: string; + teamName: string; + status: UpdateTeamUserStatus; + skipReason?: UpdateTeamUserSkipReason; + message?: string; +}; + +export type UpdateUsersOnTeamsResult = { + success: boolean; + items: UpdateTeamUserItemResult[]; + updated: number; + skipped: number; + failed: number; +}; + +export type FormattedUpdateTeamUserTable = { + headers: string[]; + rows: string[][]; + summary: string; +}; + +type ResolvedTeam = Pick; + +const membershipKey = (userId: number, teamUid: string): string => + `${userId}:${teamUid}`; + +function buildMembershipSet( + teamUsers: EnterpriseTeamUserLink[] | undefined, +): Set { + const membership = new Set(); + for (const link of teamUsers || []) { + membership.add(membershipKey(link.enterprise_user_id, link.team_uid)); + } + return membership; +} + +function normalizeTeamIdentifiers(teams: string[] | undefined): string[] { + return (teams || []).map((t) => t.trim()).filter((t) => t.length > 0); +} + +export async function updateUsersOnTeams( + auth: Auth, + input: UpdateUsersOnTeamsInput, +): Promise { + const emails = normalizeEmailInputs(input.users); + if (emails.length === 0) { + throw new KeeperSdkError( + "No users provided.", + ResultCodes.NO_USERS_TO_UPDATE, + ); + } + const teamIdentifiers = normalizeTeamIdentifiers(input.teams); + if (teamIdentifiers.length === 0) { + throw new KeeperSdkError( + "No teams provided.", + ResultCodes.NO_TEAMS_FOR_USER_OP, + ); + } + if (input.hideSharedFolders === undefined || input.hideSharedFolders === null) { + throw new KeeperSdkError( + "hideSharedFolders (on/off) is required.", + ResultCodes.HIDE_SHARED_FOLDERS_REQUIRED, + ); + } + + const userType = input.hideSharedFolders + ? Enterprise.TeamUserType.ADMIN_ONLY + : Enterprise.TeamUserType.USER; + + const enterpriseData = new EnterpriseDataManager(auth); + const response = await enterpriseData.getData(UPDATE_TEAM_USER_INCLUDES); + + const teams: ResolvedTeam[] = resolveExistingTeams( + response.teams || [], + teamIdentifiers, + ).map((team) => ({ team_uid: team.team_uid, name: team.name })); + const users: EnterpriseUser[] = resolveExistingUsers( + response.users || [], + emails, + ); + const membership = buildMembershipSet(response.team_users); + + const items: UpdateTeamUserItemResult[] = []; + for (const team of teams) { + for (const user of users) { + const base = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + teamUid: team.team_uid, + teamName: team.name, + }; + + if (!membership.has(membershipKey(user.enterprise_user_id, team.team_uid))) { + items.push({ + ...base, + status: UpdateTeamUserStatus.Skipped, + skipReason: UpdateTeamUserSkipReason.NotMember, + }); + continue; + } + + try { + const response = await auth.executeRestCommand( + teamEnterpriseUserUpdateCommand({ + team_uid: team.team_uid, + enterprise_user_id: user.enterprise_user_id, + user_type: userType, + }), + ); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_enterprise_user_update failed for user=${user.username}, team=${team.team_uid}`, + response.result_code || ResultCodes.TEAM_ENTERPRISE_USER_UPDATE_FAILED, + ); + } + items.push({ ...base, status: UpdateTeamUserStatus.Updated }); + } catch (err) { + items.push({ + ...base, + status: UpdateTeamUserStatus.Failed, + message: extractErrorMessage(err), + }); + } + } + } + + return finalizeResult(items); +} + +function finalizeResult( + items: UpdateTeamUserItemResult[], +): UpdateUsersOnTeamsResult { + const updated = items.filter( + (item) => item.status === UpdateTeamUserStatus.Updated, + ).length; + const skipped = items.filter( + (item) => item.status === UpdateTeamUserStatus.Skipped, + ).length; + const failed = items.filter( + (item) => item.status === UpdateTeamUserStatus.Failed, + ).length; + return { + success: failed === 0 && updated > 0, + items, + updated, + skipped, + failed, + }; +} + +export function formatUpdateTeamUserResult( + result: UpdateUsersOnTeamsResult, +): FormattedUpdateTeamUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.teamName, + item.teamUid, + item.message || item.skipReason || "", + ]); + return { + headers: [...UPDATE_TEAM_USER_TABLE_HEADERS], + rows, + summary: `Updated: ${result.updated} Skipped: ${result.skipped} Failed: ${result.failed}`, + }; +} + +export function renderUpdateTeamUserAsciiTable( + table: FormattedUpdateTeamUserTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell + " ".repeat(Math.max(0, widths[columnIndex] - cell.length)); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + return [ + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join("\n"); +} diff --git a/KeeperSdk/src/users/updateUser.ts b/KeeperSdk/src/users/updateUser.ts index feb1932a..6ec7cbc9 100644 --- a/KeeperSdk/src/users/updateUser.ts +++ b/KeeperSdk/src/users/updateUser.ts @@ -1,330 +1,390 @@ import { - encryptObjectForStorage, - type Auth, - type KeeperResponse, - type RestCommand, - teamEnterpriseUserRemoveCommand, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, isNumber, KeeperSdkError, ResultCodes } from '../utils' + encryptObjectForStorage, + type Auth, + type KeeperResponse, + type RestCommand, + teamEnterpriseUserRemoveCommand, +} from "@keeper-security/keeperapi"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseRole, - type EnterpriseTeamRecord, -} from '../teams/enterpriseData' + extractErrorMessage, + isNumber, + KeeperSdkError, + ResultCodes, +} from "../utils"; import { - applyDecryptedNodeNames, - applyEnterpriseNameToRoot, - parentNeedsNameLookup, - resolveParentNode, -} from '../teams/teamUtils' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRole, + type EnterpriseTeamRecord, +} from "../teams/enterpriseData"; import { - normalizeEmailInputs, - validateUserProfileFields, - resolveExistingUsers, - UpdateUserStatus, - type UpdateUserInput, - type UpdateUserItemResult, - type UpdateUserResult, - type FormattedUpdateUserTable, -} from './userTypes' - -export { UpdateUserStatus } -export type { UpdateUserInput, UpdateUserItemResult, UpdateUserResult, FormattedUpdateUserTable } - -const USER_UPDATE_COMMAND = 'enterprise_user_update' -const ROLE_USER_REMOVE_COMMAND = 'role_user_remove' + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from "../teams/teamUtils"; +import { + normalizeEmailInputs, + validateUserProfileFields, + resolveExistingUsers, + UpdateUserStatus, + type UpdateUserInput, + type UpdateUserItemResult, + type UpdateUserResult, + type FormattedUpdateUserTable, +} from "./userTypes"; + +export { UpdateUserStatus }; +export type { + UpdateUserInput, + UpdateUserItemResult, + UpdateUserResult, + FormattedUpdateUserTable, +}; + +const USER_UPDATE_COMMAND = "enterprise_user_update"; +const ROLE_USER_REMOVE_COMMAND = "role_user_remove"; const UPDATE_USER_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Nodes, - EnterpriseDataInclude.Users, - EnterpriseDataInclude.Teams, - EnterpriseDataInclude.QueuedTeams, - EnterpriseDataInclude.TeamUsers, - EnterpriseDataInclude.QueuedTeamUsers, - EnterpriseDataInclude.Roles, - EnterpriseDataInclude.RoleUsers, -] - -const USER_UPDATE_TABLE_HEADERS = ['#', 'Status', 'Email', 'User ID', 'Node ID', 'Detail'] + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.QueuedTeamUsers, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleUsers, +]; + +const USER_UPDATE_TABLE_HEADERS = [ + "#", + "Status", + "Email", + "User ID", + "Node ID", + "Detail", +]; type UserUpdatePayload = { - enterprise_user_id: number - enterprise_user_username: string - node_id: number - encrypted_data?: string - full_name?: string - job_title?: string -} + enterprise_user_id: number; + enterprise_user_username: string; + node_id: number; + encrypted_data?: string; + full_name?: string; + job_title?: string; +}; type RoleUserRemovePayload = { - role_id: number - enterprise_user_id: number -} - -export async function updateUsers(auth: Auth, input: UpdateUserInput): Promise { - const rawEmails = normalizeEmailInputs(input.emails) - - if (rawEmails.length === 0) { - throw new KeeperSdkError('No users provided for update.', ResultCodes.NO_USERS_TO_UPDATE) - } - - const parentIdentifier = input.parent ?? null - const needsNameLookup = parentNeedsNameLookup(parentIdentifier) - const hasProfileChange = parentIdentifier !== null || !!input.fullName || !!input.jobTitle - - const enterpriseData = new EnterpriseDataManager(auth) - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(UPDATE_USER_INCLUDES), - enterpriseData.getDisplayNames(), - ]) - - const nodes = response.nodes || [] - applyEnterpriseNameToRoot(nodes, response.enterprise_name) - - if (needsNameLookup) { - applyDecryptedNodeNames(nodes, displayNames.nodes) - await enterpriseData.decryptNodeNames(nodes) - } - - const resolvedUsers = resolveExistingUsers(response.users || [], rawEmails) - - const overrideNodeId: number | null = - parentIdentifier !== null && parentIdentifier !== '' - ? resolveParentNode(nodes, parentIdentifier).node_id - : null - - const treeKey = hasProfileChange ? await enterpriseData.getTreeKey() : null - if (hasProfileChange && !treeKey) { - throw new KeeperSdkError( - 'Enterprise tree key is unavailable.', - ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE - ) + role_id: number; + enterprise_user_id: number; +}; + +export async function updateUsers( + auth: Auth, + input: UpdateUserInput, +): Promise { + const rawEmails = normalizeEmailInputs(input.emails); + + if (rawEmails.length === 0) { + throw new KeeperSdkError( + "No users provided for update.", + ResultCodes.NO_USERS_TO_UPDATE, + ); + } + + const parentIdentifier = input.parent ?? null; + const needsNameLookup = parentNeedsNameLookup(parentIdentifier); + const hasProfileChange = + parentIdentifier !== null || !!input.fullName || !!input.jobTitle; + + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(UPDATE_USER_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const nodes = response.nodes || []; + applyEnterpriseNameToRoot(nodes, response.enterprise_name); + + if (needsNameLookup) { + applyDecryptedNodeNames(nodes, displayNames.nodes); + await enterpriseData.decryptNodeNames(nodes); + } + + const resolvedUsers = resolveExistingUsers(response.users || [], rawEmails); + + const overrideNodeId: number | null = + parentIdentifier !== null && parentIdentifier !== "" + ? resolveParentNode(nodes, parentIdentifier).node_id + : null; + + const treeKey = hasProfileChange ? await enterpriseData.getTreeKey() : null; + if (hasProfileChange && !treeKey) { + throw new KeeperSdkError( + "Enterprise tree key is unavailable.", + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE, + ); + } + + const removeTeamUids = resolveTeamUids( + input.removeTeam || [], + response.teams || [], + response.queued_teams || [], + ); + const removeRoleIds = resolveRoleIds( + input.removeRole || [], + response.roles || [], + displayNames.roles, + ); + + const fullName = (input.fullName || "").trim() || undefined; + const jobTitle = (input.jobTitle || "").trim() || undefined; + validateUserProfileFields(fullName, jobTitle); + + const items: UpdateUserItemResult[] = []; + + for (const user of resolvedUsers) { + const targetNodeId = overrideNodeId ?? user.node_id ?? 0; + const item: UpdateUserItemResult = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + nodeId: targetNodeId, + status: UpdateUserStatus.Failed, + }; + + try { + if (hasProfileChange && treeKey !== null) { + const encryptedData = + fullName !== undefined + ? await encryptObjectForStorage({ displayname: fullName }, treeKey) + : undefined; + await sendUserUpdate(auth, { + enterprise_user_id: user.enterprise_user_id, + enterprise_user_username: user.username, + node_id: targetNodeId, + encrypted_data: encryptedData, + full_name: fullName, + job_title: jobTitle, + }); + } + + for (const teamUid of removeTeamUids) { + await sendTeamUserRemove(auth, user.enterprise_user_id, teamUid); + } + + for (const roleId of removeRoleIds) { + await sendRoleUserRemove(auth, roleId, user.enterprise_user_id); + } + + item.status = UpdateUserStatus.Updated; + } catch (err) { + item.nodeId = user.node_id ?? 0; + item.message = extractErrorMessage(err); } + items.push(item); + } - const removeTeamUids = resolveTeamUids(input.removeTeam || [], response.teams || [], response.queued_teams || []) - const removeRoleIds = resolveRoleIds(input.removeRole || [], response.roles || [], displayNames.roles) - - const fullName = (input.fullName || '').trim() || undefined - const jobTitle = (input.jobTitle || '').trim() || undefined - validateUserProfileFields(fullName, jobTitle) - - const items: UpdateUserItemResult[] = [] - - for (const user of resolvedUsers) { - const targetNodeId = overrideNodeId ?? (user.node_id ?? 0) - const item: UpdateUserItemResult = { - username: user.username, - enterpriseUserId: user.enterprise_user_id, - nodeId: targetNodeId, - status: UpdateUserStatus.Failed, - } - - try { - if (hasProfileChange && treeKey !== null) { - const encryptedData = fullName !== undefined - ? await encryptObjectForStorage({ displayname: fullName }, treeKey) - : undefined - await sendUserUpdate(auth, { - enterprise_user_id: user.enterprise_user_id, - enterprise_user_username: user.username, - node_id: targetNodeId, - encrypted_data: encryptedData, - full_name: fullName, - job_title: jobTitle, - }) - } - - for (const teamUid of removeTeamUids) { - await sendTeamUserRemove(auth, user.enterprise_user_id, teamUid) - } - - for (const roleId of removeRoleIds) { - await sendRoleUserRemove(auth, roleId, user.enterprise_user_id) - } - - item.status = UpdateUserStatus.Updated - } catch (err) { - item.nodeId = user.node_id ?? 0 - item.message = extractErrorMessage(err) - } - items.push(item) - } - - return finalizeResult(items) + return finalizeResult(items); } -async function sendUserUpdate(auth: Auth, payload: UserUpdatePayload): Promise { - const command: RestCommand = { - baseRequest: { command: USER_UPDATE_COMMAND }, - request: payload, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || - response.result_code || - `${USER_UPDATE_COMMAND} failed for "${payload.enterprise_user_username}"`, - response.result_code || ResultCodes.USER_UPDATE_FAILED - ) - } +async function sendUserUpdate( + auth: Auth, + payload: UserUpdatePayload, +): Promise { + const command: RestCommand = { + baseRequest: { command: USER_UPDATE_COMMAND }, + request: payload, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `${USER_UPDATE_COMMAND} failed for "${payload.enterprise_user_username}"`, + response.result_code || ResultCodes.USER_UPDATE_FAILED, + ); + } } -async function sendTeamUserRemove(auth: Auth, enterpriseUserId: number, teamUid: string): Promise { - const command = teamEnterpriseUserRemoveCommand({ - enterprise_user_id: enterpriseUserId, - team_uid: teamUid, - }) - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || - response.result_code || - `team_enterprise_user_remove failed for user=${enterpriseUserId}, team=${teamUid}`, - response.result_code || ResultCodes.USER_UPDATE_FAILED - ) - } +async function sendTeamUserRemove( + auth: Auth, + enterpriseUserId: number, + teamUid: string, +): Promise { + const command = teamEnterpriseUserRemoveCommand({ + enterprise_user_id: enterpriseUserId, + team_uid: teamUid, + }); + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_enterprise_user_remove failed for user=${enterpriseUserId}, team=${teamUid}`, + response.result_code || ResultCodes.USER_UPDATE_FAILED, + ); + } } -async function sendRoleUserRemove(auth: Auth, roleId: number, enterpriseUserId: number): Promise { - const command: RestCommand = { - baseRequest: { command: ROLE_USER_REMOVE_COMMAND }, - request: { role_id: roleId, enterprise_user_id: enterpriseUserId }, - authorization: {}, - } - const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || - response.result_code || - `${ROLE_USER_REMOVE_COMMAND} failed for user=${enterpriseUserId}, role=${roleId}`, - response.result_code || ResultCodes.USER_UPDATE_FAILED - ) - } +async function sendRoleUserRemove( + auth: Auth, + roleId: number, + enterpriseUserId: number, +): Promise { + const command: RestCommand = { + baseRequest: { command: ROLE_USER_REMOVE_COMMAND }, + request: { role_id: roleId, enterprise_user_id: enterpriseUserId }, + authorization: {}, + }; + const response = await auth.executeRestCommand(command); + const result = (response.result || "").toLowerCase(); + if (result && result !== "success") { + throw new KeeperSdkError( + response.message || + response.result_code || + `${ROLE_USER_REMOVE_COMMAND} failed for user=${enterpriseUserId}, role=${roleId}`, + response.result_code || ResultCodes.USER_UPDATE_FAILED, + ); + } } function resolveTeamUids( - identifiers: string[], - teams: EnterpriseTeamRecord[], - queuedTeams: EnterpriseTeamRecord[] + identifiers: string[], + teams: EnterpriseTeamRecord[], + queuedTeams: EnterpriseTeamRecord[], ): string[] { - if (identifiers.length === 0) return [] + if (identifiers.length === 0) return []; - const uidSet = new Set() - const byLowerName = new Map() + const uidSet = new Set(); + const byLowerName = new Map(); - for (const t of teams) { - if (t.team_uid) { - uidSet.add(t.team_uid) - const lower = (t.name || '').trim().toLowerCase() - if (lower && !byLowerName.has(lower)) byLowerName.set(lower, t.team_uid) - } + for (const t of teams) { + if (t.team_uid) { + uidSet.add(t.team_uid); + const lower = (t.name || "").trim().toLowerCase(); + if (lower && !byLowerName.has(lower)) byLowerName.set(lower, t.team_uid); } - for (const t of queuedTeams) { - if (t.team_uid) { - uidSet.add(t.team_uid) - const lower = (t.name || '').trim().toLowerCase() - if (lower && !byLowerName.has(lower)) byLowerName.set(lower, t.team_uid) - } + } + for (const t of queuedTeams) { + if (t.team_uid) { + uidSet.add(t.team_uid); + const lower = (t.name || "").trim().toLowerCase(); + if (lower && !byLowerName.has(lower)) byLowerName.set(lower, t.team_uid); } - - const seen = new Set() - const result: string[] = [] - for (const id of identifiers) { - const trimmed = id.trim() - const uid = (uidSet.has(trimmed) ? trimmed : undefined) ?? byLowerName.get(trimmed.toLowerCase()) - if (!uid) { - throw new KeeperSdkError(`Team "${trimmed}" does not exist.`, ResultCodes.TEAM_NOT_FOUND) - } - if (!seen.has(uid)) { - seen.add(uid) - result.push(uid) - } + } + + const seen = new Set(); + const result: string[] = []; + for (const id of identifiers) { + const trimmed = id.trim(); + const uid = + (uidSet.has(trimmed) ? trimmed : undefined) ?? + byLowerName.get(trimmed.toLowerCase()); + if (!uid) { + throw new KeeperSdkError( + `Team "${trimmed}" does not exist.`, + ResultCodes.TEAM_NOT_FOUND, + ); + } + if (!seen.has(uid)) { + seen.add(uid); + result.push(uid); } - return result + } + return result; } function resolveRoleIds( - identifiers: string[], - roles: EnterpriseRole[], - decryptedRoleNames: Map + identifiers: string[], + roles: EnterpriseRole[], + decryptedRoleNames: Map, ): number[] { - if (identifiers.length === 0) return [] - - const idSet = new Set() - const byLowerName = new Map() - - for (const r of roles) { - if (!isNumber(r.role_id)) continue - idSet.add(r.role_id) - const lower = (decryptedRoleNames.get(r.role_id) || '').trim().toLowerCase() - if (lower && !byLowerName.has(lower)) byLowerName.set(lower, r.role_id) + if (identifiers.length === 0) return []; + + const idSet = new Set(); + const byLowerName = new Map(); + + for (const r of roles) { + if (!isNumber(r.role_id)) continue; + idSet.add(r.role_id); + const lower = (decryptedRoleNames.get(r.role_id) || "") + .trim() + .toLowerCase(); + if (lower && !byLowerName.has(lower)) byLowerName.set(lower, r.role_id); + } + + const seen = new Set(); + const result: number[] = []; + for (const id of identifiers) { + const trimmed = id.trim(); + const numericId = Number(trimmed); + let roleId: number | undefined; + + if (Number.isInteger(numericId)) + roleId = idSet.has(numericId) ? numericId : undefined; + if (roleId === undefined) roleId = byLowerName.get(trimmed.toLowerCase()); + if (roleId === undefined) { + throw new KeeperSdkError( + `Role "${trimmed}" does not exist.`, + ResultCodes.ROLE_NOT_FOUND, + ); } - - const seen = new Set() - const result: number[] = [] - for (const id of identifiers) { - const trimmed = id.trim() - const numericId = Number(trimmed) - let roleId: number | undefined - - if (Number.isInteger(numericId)) roleId = idSet.has(numericId) ? numericId : undefined - if (roleId === undefined) roleId = byLowerName.get(trimmed.toLowerCase()) - if (roleId === undefined) { - throw new KeeperSdkError(`Role "${trimmed}" does not exist.`, ResultCodes.ROLE_NOT_FOUND) - } - if (!seen.has(roleId)) { - seen.add(roleId) - result.push(roleId) - } + if (!seen.has(roleId)) { + seen.add(roleId); + result.push(roleId); } - return result + } + return result; } function finalizeResult(items: UpdateUserItemResult[]): UpdateUserResult { - let updated = 0, failed = 0 - for (const item of items) { - if (item.status === UpdateUserStatus.Updated) updated++ - else failed++ - } - return { success: failed === 0 && updated > 0, items, updated, failed } + let updated = 0, + failed = 0; + for (const item of items) { + if (item.status === UpdateUserStatus.Updated) updated++; + else failed++; + } + return { success: failed === 0 && updated > 0, items, updated, failed }; } -export function formatUpdateUserResult(result: UpdateUserResult): FormattedUpdateUserTable { - const rows = result.items.map((item, index) => [ - String(index + 1), - item.status, - item.username, - String(item.enterpriseUserId), - String(item.nodeId), - item.message || '', - ]) - return { - headers: [...USER_UPDATE_TABLE_HEADERS], - rows, - summary: `Updated: ${result.updated} Failed: ${result.failed}`, - } +export function formatUpdateUserResult( + result: UpdateUserResult, +): FormattedUpdateUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + String(item.nodeId), + item.message || "", + ]); + return { + headers: [...USER_UPDATE_TABLE_HEADERS], + rows, + summary: `Updated: ${result.updated} Failed: ${result.failed}`, + }; } -export function renderUpdateUserAsciiTable(table: FormattedUpdateUserTable): string { - const { headers, rows } = table - const widths = headers.map((header, index) => - Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) - ) - const padCell = (cell: string, columnIndex: number): string => - cell.padEnd(widths[columnIndex]) - const formatRow = (cells: string[]): string => - cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - - const lines: string[] = [ - formatRow(headers), - formatRow(widths.map((w) => '-'.repeat(w))), - ...rows.map(formatRow), - table.summary, - ] - return lines.join('\n') +export function renderUpdateUserAsciiTable( + table: FormattedUpdateUserTable, +): string { + const { headers, rows } = table; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || "").length)), + ); + const padCell = (cell: string, columnIndex: number): string => + cell.padEnd(widths[columnIndex]); + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(" "); + + const lines: string[] = [ + formatRow(headers), + formatRow(widths.map((w) => "-".repeat(w))), + ...rows.map(formatRow), + table.summary, + ]; + return lines.join("\n"); } diff --git a/KeeperSdk/src/users/userTypes.ts b/KeeperSdk/src/users/userTypes.ts index 760486f9..47823c0d 100644 --- a/KeeperSdk/src/users/userTypes.ts +++ b/KeeperSdk/src/users/userTypes.ts @@ -1,395 +1,404 @@ -import { KeeperSdkError, ResultCodes } from '../utils' -import type { EnterpriseUser } from '../teams/enterpriseData' +import { KeeperSdkError, ResultCodes } from "../utils"; +import type { EnterpriseUser } from "../teams/enterpriseData"; -export const MAX_FULL_NAME_LENGTH = 255 -export const MAX_JOB_TITLE_LENGTH = 255 +export const MAX_FULL_NAME_LENGTH = 255; +export const MAX_JOB_TITLE_LENGTH = 255; export enum EnterpriseUserStatus { - Active = 'active', - Invited = 'invited', + Active = "active", + Invited = "invited", } export enum UserColumn { - Name = 'name', - Status = 'status', - TransferStatus = 'transfer_status', - Node = 'node', - TeamCount = 'team_count', - Teams = 'teams', - QueuedTeamCount = 'queued_team_count', - QueuedTeams = 'queued_teams', - RoleCount = 'role_count', - Roles = 'roles', - Alias = 'alias', - TwoFaEnabled = '2fa_enabled', + Name = "name", + Status = "status", + TransferStatus = "transfer_status", + Node = "node", + TeamCount = "team_count", + Teams = "teams", + QueuedTeamCount = "queued_team_count", + QueuedTeams = "queued_teams", + RoleCount = "role_count", + Roles = "roles", + Alias = "alias", + TwoFaEnabled = "2fa_enabled", } export enum AddUserStatus { - Added = 'added', - Reinvited = 'reinvited', - Skipped = 'skipped', - Failed = 'failed', + Added = "added", + Reinvited = "reinvited", + Skipped = "skipped", + Failed = "failed", } export enum AddUserSkipReason { - AlreadyExists = 'already_exists', - InvalidEmail = 'invalid_email', + AlreadyExists = "already_exists", + InvalidEmail = "invalid_email", } export enum UpdateUserStatus { - Updated = 'updated', - Failed = 'failed', + Updated = "updated", + Failed = "failed", } export enum DeleteUserStatus { - Deleted = 'deleted', - Failed = 'failed', + Deleted = "deleted", + Failed = "failed", } export enum UserAction { - Lock = 'lock', - Unlock = 'unlock', - ExpirePassword = 'expire_password', + Lock = "lock", + Unlock = "unlock", + ExpirePassword = "expire_password", } export enum UserActionStatus { - Success = 'success', - Skipped = 'skipped', - Failed = 'failed', + Success = "success", + Skipped = "skipped", + Failed = "failed", } export enum UserActionSkipReason { - Inactive = 'inactive', - Pending = 'pending', + Inactive = "inactive", + Pending = "pending", } -export type UserColumnInput = UserColumn | `${UserColumn}` +export type UserColumnInput = UserColumn | `${UserColumn}`; export type UserTeamInfo = { - team_uid: string - name: string -} + team_uid: string; + name: string; +}; export type UserRoleInfo = { - role_id: number - role_name: string -} + role_id: number; + role_name: string; +}; export type ListUsersOptions = { - pattern?: string | null - columns?: UserColumnInput[] | '*' | string | null -} + pattern?: string | null; + columns?: UserColumnInput[] | "*" | string | null; +}; export type ListUserRow = { - enterprise_user_id: number - username: string - name?: string - status?: string - transfer_status?: string - node?: string - team_count?: number - teams?: string[] - queued_team_count?: number - queued_teams?: string[] - role_count?: number - roles?: string[] - alias?: string[] - tfa_enabled?: boolean -} + enterprise_user_id: number; + username: string; + name?: string; + status?: string; + transfer_status?: string; + node?: string; + team_count?: number; + teams?: string[]; + queued_team_count?: number; + queued_teams?: string[]; + role_count?: number; + roles?: string[]; + alias?: string[]; + tfa_enabled?: boolean; +}; export type FormattedUsersTable = { - headers: string[] - rows: string[][] -} + headers: string[]; + rows: string[][]; +}; export type FormatUsersTableOptions = { - columns?: ListUsersOptions['columns'] -} + columns?: ListUsersOptions["columns"]; +}; export type UserView = { - enterprise_user_id: number - username: string - node_id: number - node_name: string - full_name: string - status: string - tfa_enabled: boolean - transfer_status: string - aliases?: string[] - teams?: UserTeamInfo[] - queued_teams?: UserTeamInfo[] - roles?: UserRoleInfo[] - share_admins?: string[] -} + enterprise_user_id: number; + username: string; + node_id: number; + node_name: string; + full_name: string; + status: string; + tfa_enabled: boolean; + transfer_status: string; + aliases?: string[]; + teams?: UserTeamInfo[]; + queued_teams?: UserTeamInfo[]; + roles?: UserRoleInfo[]; + share_admins?: string[]; +}; export type FormatUserViewOptions = { - verbose?: boolean -} + verbose?: boolean; +}; export type UserViewTableRow = { - label: string - value: string | string[] - id?: string | string[] -} + label: string; + value: string | string[]; + id?: string | string[]; +}; export type FormattedUserViewTable = { - rows: UserViewTableRow[] - hasIdColumn: boolean -} + rows: UserViewTableRow[]; + hasIdColumn: boolean; +}; export type AddUserInput = { - emails: string[] - parent?: string | number | null - fullName?: string - jobTitle?: string -} + emails: string[]; + parent?: string | number | null; + fullName?: string; + jobTitle?: string; +}; export type AddUserItemResult = { - username: string - enterpriseUserId?: number - nodeId?: number - status: AddUserStatus - skipReason?: AddUserSkipReason - message?: string -} + username: string; + enterpriseUserId?: number; + nodeId?: number; + status: AddUserStatus; + skipReason?: AddUserSkipReason; + message?: string; +}; export type AddUserResult = { - success: boolean - parentNodeId: number - parentNodeName: string - items: AddUserItemResult[] - added: number - reinvited: number - skipped: number - failed: number -} + success: boolean; + parentNodeId: number; + parentNodeName: string; + items: AddUserItemResult[]; + added: number; + reinvited: number; + skipped: number; + failed: number; +}; export type FormatAddUserResultOptions = { - showSkipped?: boolean -} + showSkipped?: boolean; +}; export type FormattedAddUserTable = { - headers: string[] - rows: string[][] - parentNodeName: string - summary: string -} + headers: string[]; + rows: string[][]; + parentNodeName: string; + summary: string; +}; export type UpdateUserInput = { - emails: string[] - parent?: string | number | null - fullName?: string - jobTitle?: string - removeTeam?: string[] - removeRole?: string[] -} + emails: string[]; + parent?: string | number | null; + fullName?: string; + jobTitle?: string; + removeTeam?: string[]; + removeRole?: string[]; +}; export type UpdateUserItemResult = { - username: string - enterpriseUserId: number - nodeId: number - status: UpdateUserStatus - message?: string -} + username: string; + enterpriseUserId: number; + nodeId: number; + status: UpdateUserStatus; + message?: string; +}; export type UpdateUserResult = { - success: boolean - items: UpdateUserItemResult[] - updated: number - failed: number -} + success: boolean; + items: UpdateUserItemResult[]; + updated: number; + failed: number; +}; export type FormattedUpdateUserTable = { - headers: string[] - rows: string[][] - summary: string -} + headers: string[]; + rows: string[][]; + summary: string; +}; export type DeleteUserInput = { - emails: string[] -} + emails: string[]; +}; export type DeleteUserItemResult = { - username: string - enterpriseUserId: number - status: DeleteUserStatus - message?: string -} + username: string; + enterpriseUserId: number; + status: DeleteUserStatus; + message?: string; +}; export type DeleteUserResult = { - success: boolean - items: DeleteUserItemResult[] - deleted: number - failed: number -} + success: boolean; + items: DeleteUserItemResult[]; + deleted: number; + failed: number; +}; export type FormattedDeleteUserTable = { - headers: string[] - rows: string[][] - summary: string -} + headers: string[]; + rows: string[][]; + summary: string; +}; export type UserActionInput = { - emails: string[] - action: UserAction -} + emails: string[]; + action: UserAction; +}; export type UserActionItemResult = { - username: string - enterpriseUserId?: number - status: UserActionStatus - skipReason?: UserActionSkipReason - message?: string -} + username: string; + enterpriseUserId?: number; + status: UserActionStatus; + skipReason?: UserActionSkipReason; + message?: string; +}; export type UserActionResult = { - success: boolean - items: UserActionItemResult[] - succeeded: number - skipped: number - failed: number -} + success: boolean; + items: UserActionItemResult[]; + succeeded: number; + skipped: number; + failed: number; +}; export type FormattedUserActionTable = { - headers: string[] - rows: string[][] - summary: string -} + headers: string[]; + rows: string[][]; + summary: string; +}; export enum AliasOperation { - Add = 'add', - Remove = 'remove', + Add = "add", + Remove = "remove", } export type AliasUserInput = { - email: string - operation: AliasOperation - alias: string -} + email: string; + operation: AliasOperation; + alias: string; +}; export type AliasUserResult = { - success: boolean - username: string - enterpriseUserId: number - alias: string - operation: AliasOperation - detail: string -} + success: boolean; + username: string; + enterpriseUserId: number; + alias: string; + operation: AliasOperation; + detail: string; +}; export enum TeamUserStatus { - Added = 'added', - Removed = 'removed', - Skipped = 'skipped', - Failed = 'failed', - MissingPublicKey = 'missing_public_key', + Added = "added", + Removed = "removed", + Skipped = "skipped", + Failed = "failed", + MissingPublicKey = "missing_public_key", } export enum TeamUserSkipReason { - AlreadyMember = 'already_member', - NotMember = 'not_member', + AlreadyMember = "already_member", + NotMember = "not_member", } export type AddUsersToTeamsInput = { - users: string[] - teams: string[] - hideSharedFolders?: boolean -} + users: string[]; + teams: string[]; + hideSharedFolders?: boolean; +}; export type RemoveUsersFromTeamsInput = { - users: string[] - teams: string[] -} + users: string[]; + teams: string[]; +}; export type TeamUserItemResult = { - username: string - enterpriseUserId: number - teamUid: string - teamName: string - status: TeamUserStatus - skipReason?: TeamUserSkipReason - message?: string -} + username: string; + enterpriseUserId: number; + teamUid: string; + teamName: string; + status: TeamUserStatus; + skipReason?: TeamUserSkipReason; + message?: string; +}; export type TeamUserResult = { - success: boolean - items: TeamUserItemResult[] - succeeded: number - skipped: number - failed: number -} + success: boolean; + items: TeamUserItemResult[]; + succeeded: number; + skipped: number; + failed: number; +}; export type FormattedTeamUserTable = { - headers: string[] - rows: string[][] - summary: string -} + headers: string[]; + rows: string[][]; + summary: string; +}; export function normalizeEmailInputs(emails: string[] | undefined): string[] { - return (emails || []).map((e) => e.trim()).filter((e) => e.length > 0) -} - -export function validateUserProfileFields(fullName: string | undefined, jobTitle: string | undefined): void { - if (fullName !== undefined && fullName.length > MAX_FULL_NAME_LENGTH) { - throw new KeeperSdkError( - `Full name exceeds ${MAX_FULL_NAME_LENGTH} characters.`, - ResultCodes.USER_UPDATE_FAILED - ) + return (emails || []).map((e) => e.trim()).filter((e) => e.length > 0); +} + +export function validateUserProfileFields( + fullName: string | undefined, + jobTitle: string | undefined, +): void { + if (fullName !== undefined && fullName.length > MAX_FULL_NAME_LENGTH) { + throw new KeeperSdkError( + `Full name exceeds ${MAX_FULL_NAME_LENGTH} characters.`, + ResultCodes.USER_UPDATE_FAILED, + ); + } + if (jobTitle !== undefined && jobTitle.length > MAX_JOB_TITLE_LENGTH) { + throw new KeeperSdkError( + `Job title exceeds ${MAX_JOB_TITLE_LENGTH} characters.`, + ResultCodes.USER_UPDATE_FAILED, + ); + } +} + +export function resolveExistingUsers( + users: EnterpriseUser[], + identifiers: string[], +): EnterpriseUser[] { + const byEmail = new Map(); + const byId = new Map(); + for (const u of users) { + if (u.username) byEmail.set(u.username.toLowerCase(), u); + byId.set(u.enterprise_user_id, u); + } + + const result: EnterpriseUser[] = []; + const seen = new Set(); + + for (const identifier of identifiers) { + const trimmed = identifier.trim(); + const numericId = Number(trimmed); + let user: EnterpriseUser | undefined; + + if (Number.isInteger(numericId)) { + user = byId.get(numericId); } - if (jobTitle !== undefined && jobTitle.length > MAX_JOB_TITLE_LENGTH) { - throw new KeeperSdkError( - `Job title exceeds ${MAX_JOB_TITLE_LENGTH} characters.`, - ResultCodes.USER_UPDATE_FAILED - ) + if (!user) { + user = byEmail.get(trimmed.toLowerCase()); } -} - -export function resolveExistingUsers(users: EnterpriseUser[], identifiers: string[]): EnterpriseUser[] { - const byEmail = new Map() - const byId = new Map() - for (const u of users) { - if (u.username) byEmail.set(u.username.toLowerCase(), u) - byId.set(u.enterprise_user_id, u) + if (!user) { + throw new KeeperSdkError( + `User "${trimmed}" does not exist.`, + ResultCodes.USER_NOT_FOUND, + ); } - - const result: EnterpriseUser[] = [] - const seen = new Set() - - for (const identifier of identifiers) { - const trimmed = identifier.trim() - const numericId = Number(trimmed) - let user: EnterpriseUser | undefined - - if (Number.isInteger(numericId)) { - user = byId.get(numericId) - } - if (!user) { - user = byEmail.get(trimmed.toLowerCase()) - } - if (!user) { - throw new KeeperSdkError(`User "${trimmed}" does not exist.`, ResultCodes.USER_NOT_FOUND) - } - if (!seen.has(user.enterprise_user_id)) { - seen.add(user.enterprise_user_id) - result.push(user) - } + if (!seen.has(user.enterprise_user_id)) { + seen.add(user.enterprise_user_id); + result.push(user); } + } - return result + return result; } -export type FormattedUserStatus = 'Active' | 'Invited' | 'Locked' | 'Disabled' +export type FormattedUserStatus = "Active" | "Invited" | "Locked" | "Disabled"; export function formatUserStatus(user: EnterpriseUser): FormattedUserStatus { - if (user.status === EnterpriseUserStatus.Invited) return 'Invited' - if (user.lock === 1) return 'Locked' - if ((user.lock ?? 0) > 1) return 'Disabled' - return 'Active' + if (user.status === EnterpriseUserStatus.Invited) return "Invited"; + if (user.lock === 1) return "Locked"; + if ((user.lock ?? 0) > 1) return "Disabled"; + return "Active"; } export function formatTransferStatus(user: EnterpriseUser): string { - const exp = user.account_share_expiration - if (exp != null && exp > 0) { - return new Date(exp) < new Date() ? 'Blocked' : 'Pending Transfer' - } - return '' -} \ No newline at end of file + const exp = user.account_share_expiration; + if (exp != null && exp > 0) { + return new Date(exp) < new Date() ? "Blocked" : "Pending Transfer"; + } + return ""; +} diff --git a/KeeperSdk/src/users/viewUser.ts b/KeeperSdk/src/users/viewUser.ts index d8d4c910..e31abafa 100644 --- a/KeeperSdk/src/users/viewUser.ts +++ b/KeeperSdk/src/users/viewUser.ts @@ -1,284 +1,359 @@ -import { Enterprise, type Auth } from '@keeper-security/keeperapi' -import { extractErrorMessage, isNumber, KeeperSdkError, ResultCodes, logger } from '../utils' +import { Enterprise, type Auth } from "@keeper-security/keeperapi"; import { - EnterpriseDataInclude, - EnterpriseDataManager, - type EnterpriseQueuedTeamRecord, - type EnterpriseQueuedTeamUserLink, - type EnterpriseRoleUserLink, - type EnterpriseTeamRecord, - type EnterpriseTeamUserLink, - type EnterpriseUser, - type EnterpriseUserAliasLink, -} from '../teams/enterpriseData' + extractErrorMessage, + isNumber, + KeeperSdkError, + ResultCodes, + logger, +} from "../utils"; import { - formatTransferStatus, - formatUserStatus, - type FormattedUserViewTable, - type FormatUserViewOptions, - type UserRoleInfo, - type UserTeamInfo, - type UserView, - type UserViewTableRow, -} from './userTypes' - -const NODE_PATH_SEPARATOR = '\\' + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseQueuedTeamRecord, + type EnterpriseQueuedTeamUserLink, + type EnterpriseRoleUserLink, + type EnterpriseTeamRecord, + type EnterpriseTeamUserLink, + type EnterpriseUser, + type EnterpriseUserAliasLink, +} from "../teams/enterpriseData"; +import { + formatTransferStatus, + formatUserStatus, + type FormattedUserViewTable, + type FormatUserViewOptions, + type UserRoleInfo, + type UserTeamInfo, + type UserView, + type UserViewTableRow, +} from "./userTypes"; + +const NODE_PATH_SEPARATOR = "\\"; const VIEW_USER_INCLUDES: EnterpriseDataInclude[] = [ - EnterpriseDataInclude.Users, - EnterpriseDataInclude.Nodes, - EnterpriseDataInclude.Teams, - EnterpriseDataInclude.TeamUsers, - EnterpriseDataInclude.QueuedTeamUsers, - EnterpriseDataInclude.QueuedTeams, - EnterpriseDataInclude.RoleUsers, - EnterpriseDataInclude.Roles, - EnterpriseDataInclude.UserAliases, -] - -export async function viewUser(auth: Auth, identifier: string): Promise { - const enterpriseData = new EnterpriseDataManager(auth) - const [response, displayNames] = await Promise.all([ - enterpriseData.getData(VIEW_USER_INCLUDES), - enterpriseData.getDisplayNames(), - ]) - - const user = resolveUser(response.users || [], identifier) - - const nodes = response.nodes || [] - for (const node of nodes) { - const display = displayNames.nodes.get(node.node_id) - if (display) node.displayName = display - } - const nodeName = EnterpriseDataManager.getNodePath(nodes, user.node_id ?? 0, { - omitRoot: false, - separator: NODE_PATH_SEPARATOR, - }) - - const view: UserView = { - enterprise_user_id: user.enterprise_user_id, - username: user.username, - node_id: user.node_id ?? 0, - node_name: nodeName, - full_name: (user.full_name || '').trim(), - status: formatUserStatus(user), - tfa_enabled: user.tfa_enabled ?? false, - transfer_status: formatTransferStatus(user), - } - - const aliases = buildAliases(response.user_aliases || [], user) - if (aliases.length > 0) view.aliases = aliases - - const teams = buildUserTeams(response.team_users || [], response.teams || [], user.enterprise_user_id) - if (teams.length > 0) view.teams = teams - - const queuedTeams = buildUserQueuedTeams( - response.queued_team_users || [], - response.teams || [], - response.queued_teams || [], - user.enterprise_user_id - ) - if (queuedTeams.length > 0) view.queued_teams = queuedTeams - - const roles = buildUserRoles(response.role_users || [], displayNames.roles, user.enterprise_user_id) - if (roles.length > 0) view.roles = roles - - const shareAdmins = await fetchShareAdmins(auth, user.username) - if (shareAdmins.length > 0) view.share_admins = shareAdmins - - return view + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.QueuedTeamUsers, + EnterpriseDataInclude.QueuedTeams, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.UserAliases, +]; + +export async function viewUser( + auth: Auth, + identifier: string, +): Promise { + const enterpriseData = new EnterpriseDataManager(auth); + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(VIEW_USER_INCLUDES), + enterpriseData.getDisplayNames(), + ]); + + const user = resolveUser(response.users || [], identifier); + + const nodes = response.nodes || []; + for (const node of nodes) { + const display = displayNames.nodes.get(node.node_id); + if (display) node.displayName = display; + } + const nodeName = EnterpriseDataManager.getNodePath(nodes, user.node_id ?? 0, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }); + + const view: UserView = { + enterprise_user_id: user.enterprise_user_id, + username: user.username, + node_id: user.node_id ?? 0, + node_name: nodeName, + full_name: (user.full_name || "").trim(), + status: formatUserStatus(user), + tfa_enabled: user.tfa_enabled ?? false, + transfer_status: formatTransferStatus(user), + }; + + const aliases = buildAliases(response.user_aliases || [], user); + if (aliases.length > 0) view.aliases = aliases; + + const teams = buildUserTeams( + response.team_users || [], + response.teams || [], + user.enterprise_user_id, + ); + if (teams.length > 0) view.teams = teams; + + const queuedTeams = buildUserQueuedTeams( + response.queued_team_users || [], + response.teams || [], + response.queued_teams || [], + user.enterprise_user_id, + ); + if (queuedTeams.length > 0) view.queued_teams = queuedTeams; + + const roles = buildUserRoles( + response.role_users || [], + displayNames.roles, + user.enterprise_user_id, + ); + if (roles.length > 0) view.roles = roles; + + const shareAdmins = await fetchShareAdmins(auth, user.username); + if (shareAdmins.length > 0) view.share_admins = shareAdmins; + + return view; } -export function formatUserView(view: UserView, options: FormatUserViewOptions = {}): FormattedUserViewTable { - const verbose = options.verbose === true - - const rows: UserViewTableRow[] = [ - { label: 'User ID', value: String(view.enterprise_user_id) }, - { label: 'Email', value: view.username }, - { label: 'Full Name', value: view.full_name }, - { label: 'Node Name', value: view.node_name, id: verbose ? String(view.node_id) : undefined }, - { label: 'Status', value: view.status }, - { label: 'Transfer Status', value: view.transfer_status }, - { label: '2FA Enabled', value: String(view.tfa_enabled) }, - ] - - if (view.aliases && view.aliases.length > 0) { - rows.push({ label: 'Email Alias(es)', value: view.aliases }) - } - if (view.teams && view.teams.length > 0) { - rows.push({ - label: 'Team(s)', - value: view.teams.map((t) => t.name), - id: verbose ? view.teams.map((t) => t.team_uid) : undefined, - }) - } - if (view.queued_teams && view.queued_teams.length > 0) { - rows.push({ - label: 'Queued Team(s)', - value: view.queued_teams.map((t) => t.name), - id: verbose ? view.queued_teams.map((t) => t.team_uid) : undefined, - }) - } - if (view.roles && view.roles.length > 0) { - rows.push({ - label: 'Role(s)', - value: view.roles.map((r) => r.role_name), - id: verbose ? view.roles.map((r) => String(r.role_id)) : undefined, - }) - } - if (view.share_admins && view.share_admins.length > 0) { - rows.push({ label: 'Share Admin(s)', value: view.share_admins }) - } - - return { rows, hasIdColumn: verbose } +export function formatUserView( + view: UserView, + options: FormatUserViewOptions = {}, +): FormattedUserViewTable { + const verbose = options.verbose === true; + + const rows: UserViewTableRow[] = [ + { label: "User ID", value: String(view.enterprise_user_id) }, + { label: "Email", value: view.username }, + { label: "Full Name", value: view.full_name }, + { + label: "Node Name", + value: view.node_name, + id: verbose ? String(view.node_id) : undefined, + }, + { label: "Status", value: view.status }, + { label: "Transfer Status", value: view.transfer_status }, + { label: "2FA Enabled", value: String(view.tfa_enabled) }, + ]; + + if (view.aliases && view.aliases.length > 0) { + rows.push({ label: "Email Alias(es)", value: view.aliases }); + } + if (view.teams && view.teams.length > 0) { + rows.push({ + label: "Team(s)", + value: view.teams.map((t) => t.name), + id: verbose ? view.teams.map((t) => t.team_uid) : undefined, + }); + } + if (view.queued_teams && view.queued_teams.length > 0) { + rows.push({ + label: "Queued Team(s)", + value: view.queued_teams.map((t) => t.name), + id: verbose ? view.queued_teams.map((t) => t.team_uid) : undefined, + }); + } + if (view.roles && view.roles.length > 0) { + rows.push({ + label: "Role(s)", + value: view.roles.map((r) => r.role_name), + id: verbose ? view.roles.map((r) => String(r.role_id)) : undefined, + }); + } + if (view.share_admins && view.share_admins.length > 0) { + rows.push({ label: "Share Admin(s)", value: view.share_admins }); + } + + return { rows, hasIdColumn: verbose }; } export function userViewTable(table: FormattedUserViewTable): string { - const labelWidth = table.rows.reduce((max, row) => Math.max(max, row.label.length), 0) - - const expandedRows = table.rows.map((row) => ({ - label: row.label, - valueLines: asLines(row.value), - idLines: table.hasIdColumn ? asLines(row.id ?? []) : [], - })) - - const valueWidth = expandedRows.reduce( - (max, row) => Math.max(max, ...row.valueLines.map((l) => l.length)), - 0 - ) - const idWidth = table.hasIdColumn - ? expandedRows.reduce((max, row) => Math.max(max, ...row.idLines.map((l) => l.length)), 0) - : 0 - - const padLeft = (text: string, width: number): string => - ' '.repeat(Math.max(0, width - text.length)) + text - const padRight = (text: string, width: number): string => - text + ' '.repeat(Math.max(0, width - text.length)) - - const lines: string[] = [] - for (const row of expandedRows) { - const lineCount = Math.max(row.valueLines.length, table.hasIdColumn ? row.idLines.length : 0, 1) - for (let li = 0; li < lineCount; li += 1) { - const labelCell = padLeft(li === 0 ? row.label : '', labelWidth) - const valueCell = padRight(row.valueLines[li] ?? '', valueWidth) - const cells: string[] = [labelCell, valueCell] - if (table.hasIdColumn) cells.push(padRight(row.idLines[li] ?? '', idWidth)) - lines.push(cells.join(' ').trimEnd()) - } + const labelWidth = table.rows.reduce( + (max, row) => Math.max(max, row.label.length), + 0, + ); + + const expandedRows = table.rows.map((row) => ({ + label: row.label, + valueLines: asLines(row.value), + idLines: table.hasIdColumn ? asLines(row.id ?? []) : [], + })); + + const valueWidth = expandedRows.reduce( + (max, row) => Math.max(max, ...row.valueLines.map((l) => l.length)), + 0, + ); + const idWidth = table.hasIdColumn + ? expandedRows.reduce( + (max, row) => Math.max(max, ...row.idLines.map((l) => l.length)), + 0, + ) + : 0; + + const padLeft = (text: string, width: number): string => + " ".repeat(Math.max(0, width - text.length)) + text; + const padRight = (text: string, width: number): string => + text + " ".repeat(Math.max(0, width - text.length)); + + const lines: string[] = []; + for (const row of expandedRows) { + const lineCount = Math.max( + row.valueLines.length, + table.hasIdColumn ? row.idLines.length : 0, + 1, + ); + for (let li = 0; li < lineCount; li += 1) { + const labelCell = padLeft(li === 0 ? row.label : "", labelWidth); + const valueCell = padRight(row.valueLines[li] ?? "", valueWidth); + const cells: string[] = [labelCell, valueCell]; + if (table.hasIdColumn) + cells.push(padRight(row.idLines[li] ?? "", idWidth)); + lines.push(cells.join(" ").trimEnd()); } - return lines.join('\n') + } + return lines.join("\n"); } -function resolveUser(users: EnterpriseUser[], identifier: string): EnterpriseUser { - const trimmed = (identifier ?? '').trim() - if (!trimmed) { - throw new KeeperSdkError('User email or ID is required.', ResultCodes.USER_NOT_FOUND) - } - - const numericId = Number(trimmed) - if (Number.isInteger(numericId)) { - const byId = users.find((u) => u.enterprise_user_id === numericId) - if (byId) return byId - } - - const lowered = trimmed.toLowerCase() - const byEmail = users.filter((u) => (u.username || '').toLowerCase() === lowered) - if (byEmail.length === 1) return byEmail[0] - if (byEmail.length > 1) { - throw new KeeperSdkError( - `Multiple users match "${trimmed}". Specify the enterprise user ID instead.`, - ResultCodes.MULTIPLE_USER_MATCHES - ) - } - - throw new KeeperSdkError(`User "${trimmed}" does not exist.`, ResultCodes.USER_NOT_FOUND) +function resolveUser( + users: EnterpriseUser[], + identifier: string, +): EnterpriseUser { + const trimmed = (identifier ?? "").trim(); + if (!trimmed) { + throw new KeeperSdkError( + "User email or ID is required.", + ResultCodes.USER_NOT_FOUND, + ); + } + + const numericId = Number(trimmed); + if (Number.isInteger(numericId)) { + const byId = users.find((u) => u.enterprise_user_id === numericId); + if (byId) return byId; + } + + const lowered = trimmed.toLowerCase(); + const byEmail = users.filter( + (u) => (u.username || "").toLowerCase() === lowered, + ); + if (byEmail.length === 1) return byEmail[0]; + if (byEmail.length > 1) { + throw new KeeperSdkError( + `Multiple users match "${trimmed}". Specify the enterprise user ID instead.`, + ResultCodes.MULTIPLE_USER_MATCHES, + ); + } + + throw new KeeperSdkError( + `User "${trimmed}" does not exist.`, + ResultCodes.USER_NOT_FOUND, + ); } -function buildAliases(links: EnterpriseUserAliasLink[], user: EnterpriseUser): string[] { - const primary = user.username.toLowerCase() - return links - .filter((l) => l.enterprise_user_id === user.enterprise_user_id && l.username.toLowerCase() !== primary) - .map((l) => l.username) - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) +function buildAliases( + links: EnterpriseUserAliasLink[], + user: EnterpriseUser, +): string[] { + const primary = user.username.toLowerCase(); + return links + .filter( + (l) => + l.enterprise_user_id === user.enterprise_user_id && + l.username.toLowerCase() !== primary, + ) + .map((l) => l.username) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); } function buildUserTeams( - teamUsers: EnterpriseTeamUserLink[], - teams: EnterpriseTeamRecord[], - userId: number + teamUsers: EnterpriseTeamUserLink[], + teams: EnterpriseTeamRecord[], + userId: number, ): UserTeamInfo[] { - const teamByUid = new Map(teams.filter((t) => t.team_uid).map((t) => [t.team_uid, t])) - - return teamUsers - .filter((link) => link.enterprise_user_id === userId) - .flatMap((link) => { - const team = teamByUid.get(link.team_uid) - return team ? [{ team_uid: team.team_uid, name: team.name || team.team_uid }] : [] - }) - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + const teamByUid = new Map( + teams.filter((t) => t.team_uid).map((t) => [t.team_uid, t]), + ); + + return teamUsers + .filter((link) => link.enterprise_user_id === userId) + .flatMap((link) => { + const team = teamByUid.get(link.team_uid); + return team + ? [{ team_uid: team.team_uid, name: team.name || team.team_uid }] + : []; + }) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); } function buildUserQueuedTeams( - queuedTeamUsers: EnterpriseQueuedTeamUserLink[], - teams: EnterpriseTeamRecord[], - queuedTeams: EnterpriseQueuedTeamRecord[], - userId: number + queuedTeamUsers: EnterpriseQueuedTeamUserLink[], + teams: EnterpriseTeamRecord[], + queuedTeams: EnterpriseQueuedTeamRecord[], + userId: number, ): UserTeamInfo[] { - const teamByUid = new Map() - for (const t of teams) { - if (t.team_uid) teamByUid.set(t.team_uid, t.name || t.team_uid) - } - for (const qt of queuedTeams) { - if (qt.team_uid && !teamByUid.has(qt.team_uid)) teamByUid.set(qt.team_uid, qt.name || qt.team_uid) - } - - return queuedTeamUsers - .filter((link) => link.enterprise_user_id === userId) - .flatMap((link) => { - const name = teamByUid.get(link.team_uid) - return name ? [{ team_uid: link.team_uid, name }] : [] - }) - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + const teamByUid = new Map(); + for (const t of teams) { + if (t.team_uid) teamByUid.set(t.team_uid, t.name || t.team_uid); + } + for (const qt of queuedTeams) { + if (qt.team_uid && !teamByUid.has(qt.team_uid)) + teamByUid.set(qt.team_uid, qt.name || qt.team_uid); + } + + return queuedTeamUsers + .filter((link) => link.enterprise_user_id === userId) + .flatMap((link) => { + const name = teamByUid.get(link.team_uid); + return name ? [{ team_uid: link.team_uid, name }] : []; + }) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); } function buildUserRoles( - roleUsers: EnterpriseRoleUserLink[], - decryptedRoleNames: Map, - userId: number + roleUsers: EnterpriseRoleUserLink[], + decryptedRoleNames: Map, + userId: number, ): UserRoleInfo[] { - return roleUsers - .filter((link) => link.enterprise_user_id === userId && isNumber(link.role_id)) - .map((link) => ({ - role_id: link.role_id, - role_name: decryptedRoleNames.get(link.role_id) || String(link.role_id), - })) - .sort((a, b) => a.role_name.localeCompare(b.role_name, undefined, { sensitivity: 'base' })) + return roleUsers + .filter( + (link) => link.enterprise_user_id === userId && isNumber(link.role_id), + ) + .map((link) => ({ + role_id: link.role_id, + role_name: decryptedRoleNames.get(link.role_id) || String(link.role_id), + })) + .sort((a, b) => + a.role_name.localeCompare(b.role_name, undefined, { + sensitivity: "base", + }), + ); } -async function fetchShareAdmins(auth: Auth, username: string): Promise { - try { - const message = { - path: 'enterprise/get_sharing_admins', - toBytes(): Uint8Array { - return Enterprise.GetSharingAdminsRequest.encode({ username }).finish() - }, - fromBytes(data: Uint8Array): Enterprise.IGetSharingAdminsResponse { - return Enterprise.GetSharingAdminsResponse.decode(data) - }, - } - const response = await auth.executeRest(message) - return (response.userProfileExts || []) - .map((ext) => ext.email || '') - .filter((email) => email.length > 0) - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) - } catch (err) { - const masked = username.includes('@') ? `***@${username.split('@')[1]}` : '***' - logger.warn(`get_sharing_admins failed for ${masked}: ${extractErrorMessage(err)}`) - return [] - } +async function fetchShareAdmins( + auth: Auth, + username: string, +): Promise { + try { + const message = { + path: "enterprise/get_sharing_admins", + toBytes(): Uint8Array { + return Enterprise.GetSharingAdminsRequest.encode({ username }).finish(); + }, + fromBytes(data: Uint8Array): Enterprise.IGetSharingAdminsResponse { + return Enterprise.GetSharingAdminsResponse.decode(data); + }, + }; + const response = await auth.executeRest(message); + return (response.userProfileExts || []) + .map((ext) => ext.email || "") + .filter((email) => email.length > 0) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + } catch (err) { + const masked = username.includes("@") + ? `***@${username.split("@")[1]}` + : "***"; + logger.warn( + `get_sharing_admins failed for ${masked}: ${extractErrorMessage(err)}`, + ); + return []; + } } function asLines(value: string | string[]): string[] { - if (Array.isArray(value)) return value.length > 0 ? value : [''] - return value.length > 0 ? [value] : [''] + if (Array.isArray(value)) return value.length > 0 ? value : [""]; + return value.length > 0 ? [value] : [""]; } diff --git a/KeeperSdk/src/utils/Logger.ts b/KeeperSdk/src/utils/Logger.ts index d9d1ceef..e0f810a4 100644 --- a/KeeperSdk/src/utils/Logger.ts +++ b/KeeperSdk/src/utils/Logger.ts @@ -1,87 +1,91 @@ export enum LogLevel { - DEBUG = 0, - INFO = 1, - WARN = 2, - ERROR = 3, - NONE = 4, + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4, } export interface ILogger { - debug(...args: unknown[]): void - info(...args: unknown[]): void - warn(...args: unknown[]): void - error(...args: unknown[]): void + debug(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; } -const CREDENTIAL_PATTERN = /\b(password|passwd|pwd|secret|api[_-]?key|auth[_-]?token)\s*[:=]\s*\S+/gi +const CREDENTIAL_PATTERN = + /\b(password|passwd|pwd|secret|api[_-]?key|auth[_-]?token)\s*[:=]\s*\S+/gi; function redactSensitiveString(value: string): string { - return value.replace(CREDENTIAL_PATTERN, (_, key: string) => `${key}=[REDACTED]`) + return value.replace( + CREDENTIAL_PATTERN, + (_, key: string) => `${key}=[REDACTED]`, + ); } function sanitizeArg(arg: unknown): unknown { - if (typeof arg === 'string') return redactSensitiveString(arg) - if (Array.isArray(arg)) return arg.map(sanitizeArg) - return arg + if (typeof arg === "string") return redactSensitiveString(arg); + if (Array.isArray(arg)) return arg.map(sanitizeArg); + return arg; } export function writeOutput(message: string): void { - process.stdout.write(message.endsWith('\n') ? message : `${message}\n`) + process.stdout.write(message.endsWith("\n") ? message : `${message}\n`); } export class ConsoleLogger implements ILogger { - private level: LogLevel + private level: LogLevel; - constructor(level: LogLevel = LogLevel.INFO) { - this.level = level - } + constructor(level: LogLevel = LogLevel.INFO) { + this.level = level; + } - public setLevel(level: LogLevel): void { - this.level = level - } + public setLevel(level: LogLevel): void { + this.level = level; + } - public getLevel(): LogLevel { - return this.level - } + public getLevel(): LogLevel { + return this.level; + } - public debug(...args: unknown[]): void { - if (this.level <= LogLevel.DEBUG) console.debug(...args.map(sanitizeArg)) - } + public debug(...args: unknown[]): void { + if (this.level <= LogLevel.DEBUG) console.debug(...args.map(sanitizeArg)); + } - public info(...args: unknown[]): void { - if (this.level <= LogLevel.INFO) console.log(...args.map(sanitizeArg)) - } + public info(...args: unknown[]): void { + if (this.level <= LogLevel.INFO) console.log(...args.map(sanitizeArg)); + } - public warn(...args: unknown[]): void { - if (this.level <= LogLevel.WARN) console.warn(...args.map(sanitizeArg)) - } + public warn(...args: unknown[]): void { + if (this.level <= LogLevel.WARN) console.warn(...args.map(sanitizeArg)); + } - public error(...args: unknown[]): void { - if (this.level <= LogLevel.ERROR) console.error(...args.map(sanitizeArg)) - } + public error(...args: unknown[]): void { + if (this.level <= LogLevel.ERROR) console.error(...args.map(sanitizeArg)); + } } -let globalLogger: ILogger = new ConsoleLogger() +let globalLogger: ILogger = new ConsoleLogger(); export function setLogger(newLogger: ILogger): void { - globalLogger = newLogger + globalLogger = newLogger; } export function getLogger(): ILogger { - return globalLogger + return globalLogger; } export function resetLogger(level: LogLevel = LogLevel.INFO): ConsoleLogger { - const c = new ConsoleLogger(level) - globalLogger = c - return c + const c = new ConsoleLogger(level); + globalLogger = c; + return c; } export const logger: ILogger = { - debug: (...args) => globalLogger.debug(...args), - info: (...args) => globalLogger.info(...args), - warn: (...args) => globalLogger.warn(...args), - error: (...args) => globalLogger.error(...args), -} + debug: (...args) => globalLogger.debug(...args), + info: (...args) => globalLogger.info(...args), + warn: (...args) => globalLogger.warn(...args), + error: (...args) => globalLogger.error(...args), +}; -export { ConsoleLogger as Logger } +export { ConsoleLogger as Logger }; diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 6146d3ac..ab21276d 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,96 +1,114 @@ -const DEFAULT_CLIENT_VERSION = 'c18.0.0' -const DEFAULT_DEVICE_NAME = 'JavaScript Keeper SDK' -const DEFAULT_CONFIG_DIR = '.keeper' -const DEFAULT_LOG_FORMAT = '!' +const DEFAULT_CLIENT_VERSION = "c18.0.0"; +const DEFAULT_DEVICE_NAME = "JavaScript Keeper SDK"; +const DEFAULT_CONFIG_DIR = ".keeper"; +const DEFAULT_LOG_FORMAT = "!"; export const SdkDefaults = { - CLIENT_VERSION: DEFAULT_CLIENT_VERSION, - DEVICE_NAME: DEFAULT_DEVICE_NAME, - CONFIG_DIR: DEFAULT_CONFIG_DIR, - LOG_FORMAT: DEFAULT_LOG_FORMAT, -} as const + CLIENT_VERSION: DEFAULT_CLIENT_VERSION, + DEVICE_NAME: DEFAULT_DEVICE_NAME, + CONFIG_DIR: DEFAULT_CONFIG_DIR, + LOG_FORMAT: DEFAULT_LOG_FORMAT, +} as const; export const AuthDefaults = { - MAX_LOGIN_ATTEMPTS: 5, - APPROVAL_TIMEOUT_MS: 60_000, - CODE_VALIDATION_DELAY_MS: 2_000, -} as const + MAX_LOGIN_ATTEMPTS: 5, + APPROVAL_TIMEOUT_MS: 60_000, + CODE_VALIDATION_DELAY_MS: 2_000, +} as const; export enum AuthErrorCode { - InvalidCredentials = 'invalid_credentials', - MissingUsername = 'missing_username', - MissingPassword = 'missing_password', - MaxAttemptsExceeded = 'max_attempts_exceeded', - UserCancelled = 'user_cancelled', - Unsupported2FAChannel = 'unsupported_2fa_channel', + InvalidCredentials = "invalid_credentials", + MissingUsername = "missing_username", + MissingPassword = "missing_password", + MaxAttemptsExceeded = "max_attempts_exceeded", + UserCancelled = "user_cancelled", + Unsupported2FAChannel = "unsupported_2fa_channel", } export enum SessionErrorCode { - NotLoggedIn = 'not_logged_in', - DeviceNotRegistered = 'device_not_registered', - NoPreviousLogin = 'no_previous_login', - NoCloneCode = 'no_clone_code', - PersistentLoginFailed = 'persistent_login_failed', - SessionTokenExpired = 'session_token_expired', + NotLoggedIn = "not_logged_in", + DeviceNotRegistered = "device_not_registered", + NoPreviousLogin = "no_previous_login", + NoCloneCode = "no_clone_code", + PersistentLoginFailed = "persistent_login_failed", + SessionTokenExpired = "session_token_expired", } export enum ValidationErrorCode { - InvalidPattern = 'invalid_pattern', + InvalidPattern = "invalid_pattern", } export enum RoleErrorCode { - RoleRequired = 'role_required', - RoleNotFound = 'role_not_found', - MultipleRoleMatches = 'multiple_role_matches', - NoRolesToAdd = 'no_roles_to_add', - NoRolesToUpdate = 'no_roles_to_update', - NoRolesToDelete = 'no_roles_to_delete', - RoleAddFailed = 'role_add_failed', - RoleUpdateFailed = 'role_update_failed', - RoleDeleteFailed = 'role_delete_failed', - RoleIdAllocationFailed = 'role_id_allocation_failed', - RoleRenameMultiNotAllowed = 'role_rename_multi_not_allowed', - RoleNameEmpty = 'role_name_empty', - RoleEnforcementFailed = 'role_enforcement_failed', + RoleRequired = "role_required", + RoleNotFound = "role_not_found", + MultipleRoleMatches = "multiple_role_matches", + NoRolesToAdd = "no_roles_to_add", + NoRolesToUpdate = "no_roles_to_update", + NoRolesToDelete = "no_roles_to_delete", + RoleAddFailed = "role_add_failed", + RoleUpdateFailed = "role_update_failed", + RoleDeleteFailed = "role_delete_failed", + RoleIdAllocationFailed = "role_id_allocation_failed", + RoleRenameMultiNotAllowed = "role_rename_multi_not_allowed", + RoleNameEmpty = "role_name_empty", + RoleEnforcementFailed = "role_enforcement_failed", + NoEnforcementsToApply = "no_enforcements_to_apply", + RoleSourceRequired = "role_source_required", + RoleCopyFailed = "role_copy_failed", + NoUsersForRoleOp = "no_users_for_role_op", + RoleUserAddFailed = "role_user_add_failed", + RoleUserRemoveFailed = "role_user_remove_failed", + NoNodesForRoleOp = "no_nodes_for_role_op", + RoleManagedNodeAddFailed = "role_managed_node_add_failed", + RoleManagedNodeUpdateFailed = "role_managed_node_update_failed", + RoleManagedNodeRemoveFailed = "role_managed_node_remove_failed", + ManagedNodeNotManagedByRole = "managed_node_not_managed_by_role", + NoPrivilegesSpecified = "no_privileges_specified", + RolePrivilegeAddFailed = "role_privilege_add_failed", + RolePrivilegeRemoveFailed = "role_privilege_remove_failed", } export enum NsfErrorCode { - NotFound = 'nsf_not_found', - MultipleMatches = 'nsf_multiple_matches', - LegacyRecord = 'nsf_legacy_record', - LegacyFolder = 'nsf_legacy_folder', - PermissionDenied = 'nsf_permission_denied', - LinkFailed = 'nsf_link_failed', - RemoveFailed = 'nsf_remove_failed', - FolderRequired = 'nsf_folder_required', - TooManyRecords = 'nsf_too_many_records', - TooManyFolders = 'nsf_too_many_folders', - MissingKey = 'nsf_missing_key', - MkdirFailed = 'nsf_mkdir_failed', - UpdateFailed = 'nsf_update_failed', - DetailsFailed = 'nsf_details_failed', - AddFailed = 'nsf_add_failed', + NotFound = "nsf_not_found", + MultipleMatches = "nsf_multiple_matches", + LegacyRecord = "nsf_legacy_record", + LegacyFolder = "nsf_legacy_folder", + PermissionDenied = "nsf_permission_denied", + LinkFailed = "nsf_link_failed", + RemoveFailed = "nsf_remove_failed", + FolderRequired = "nsf_folder_required", + TooManyRecords = "nsf_too_many_records", + TooManyFolders = "nsf_too_many_folders", + MissingKey = "nsf_missing_key", + MkdirFailed = "nsf_mkdir_failed", + UpdateFailed = "nsf_update_failed", + DetailsFailed = "nsf_details_failed", + AddFailed = "nsf_add_failed", + RecordPermissionFailed = "nsf_record_permission_failed", + ShareFailed = "nsf_share_failed", + ShortcutFailed = "nsf_shortcut_failed", + TransferFailed = "nsf_transfer_failed", } export enum TeamErrorCode { - TeamRequired = 'team_required', - TeamNotFound = 'team_not_found', - MultipleTeamMatches = 'multiple_team_matches', - NoTeamsToAdd = 'no_teams_to_add', - NoTeamsToUpdate = 'no_teams_to_update', - NoTeamsToDelete = 'no_teams_to_delete', - ParentNodeRequired = 'parent_node_required', - ParentNodeNotFound = 'parent_node_not_found', - MultipleParentNodeMatches = 'multiple_parent_node_matches', - EnterprisePublicKeyMissing = 'enterprise_public_key_missing', - EnterpriseTreeKeyUnavailable = 'enterprise_tree_key_unavailable', - DataKeyMissing = 'data_key_missing', - TeamAddFailed = 'team_add_failed', - TeamUpdateFailed = 'team_update_failed', - TeamDeleteFailed = 'team_delete_failed', - MultipleTeamRenameNotAllowed = 'multiple_team_rename_not_allowed', - QueuedTeamNotFound = 'queued_team_not_found', - TeamNameTooLong = 'team_name_too_long', + TeamRequired = "team_required", + TeamNotFound = "team_not_found", + MultipleTeamMatches = "multiple_team_matches", + NoTeamsToAdd = "no_teams_to_add", + NoTeamsToUpdate = "no_teams_to_update", + NoTeamsToDelete = "no_teams_to_delete", + ParentNodeRequired = "parent_node_required", + ParentNodeNotFound = "parent_node_not_found", + MultipleParentNodeMatches = "multiple_parent_node_matches", + EnterprisePublicKeyMissing = "enterprise_public_key_missing", + EnterpriseTreeKeyUnavailable = "enterprise_tree_key_unavailable", + DataKeyMissing = "data_key_missing", + TeamAddFailed = "team_add_failed", + TeamUpdateFailed = "team_update_failed", + TeamDeleteFailed = "team_delete_failed", + MultipleTeamRenameNotAllowed = "multiple_team_rename_not_allowed", + QueuedTeamNotFound = "queued_team_not_found", + TeamNameTooLong = "team_name_too_long", } export enum AuditReportErrorCode { @@ -120,123 +138,151 @@ export enum PasswordReportErrorCode { } export enum UserErrorCode { - NoUsersToUpdate = 'no_users_to_update', - NoUsersToAdd = 'no_users_to_add', - NoUsersToDelete = 'no_users_to_delete', - UserNotFound = 'user_not_found', - MultipleUserMatches = 'multiple_user_matches', - UserAddFailed = 'user_add_failed', - UserUpdateFailed = 'user_update_failed', - UserDeleteFailed = 'user_delete_failed', - RoleNotFound = 'role_not_found', - NoUsersToAction = 'no_users_to_action', - UserActionFailed = 'user_action_failed', - UserActionAllNotSupported = 'user_action_all_not_supported', - UserAliasFailed = 'user_alias_failed', - NoTeamsForUserOp = 'no_teams_for_user_op', - TeamUserAddFailed = 'team_user_add_failed', - TeamUserRemoveFailed = 'team_user_remove_failed', + NoUsersToUpdate = "no_users_to_update", + NoUsersToAdd = "no_users_to_add", + NoUsersToDelete = "no_users_to_delete", + UserNotFound = "user_not_found", + MultipleUserMatches = "multiple_user_matches", + UserAddFailed = "user_add_failed", + UserUpdateFailed = "user_update_failed", + UserDeleteFailed = "user_delete_failed", + RoleNotFound = "role_not_found", + NoUsersToAction = "no_users_to_action", + UserActionFailed = "user_action_failed", + UserActionAllNotSupported = "user_action_all_not_supported", + UserAliasFailed = "user_alias_failed", + NoTeamsForUserOp = "no_teams_for_user_op", + TeamUserAddFailed = "team_user_add_failed", + TeamUserRemoveFailed = "team_user_remove_failed", + TeamEnterpriseUserUpdateFailed = "team_enterprise_user_update_failed", + HideSharedFoldersRequired = "hide_shared_folders_required", +} + +export enum SyncErrorCode { + SyncFailed = "sync_failed", } export const ResultCodes = { - INVALID_CREDENTIALS: AuthErrorCode.InvalidCredentials, - MISSING_USERNAME: AuthErrorCode.MissingUsername, - MISSING_PASSWORD: AuthErrorCode.MissingPassword, - MAX_ATTEMPTS_EXCEEDED: AuthErrorCode.MaxAttemptsExceeded, - USER_CANCELLED: AuthErrorCode.UserCancelled, - UNSUPPORTED_2FA_CHANNEL: AuthErrorCode.Unsupported2FAChannel, - NOT_LOGGED_IN: SessionErrorCode.NotLoggedIn, - DEVICE_NOT_REGISTERED: SessionErrorCode.DeviceNotRegistered, - NO_PREVIOUS_LOGIN: SessionErrorCode.NoPreviousLogin, - NO_CLONE_CODE: SessionErrorCode.NoCloneCode, - PERSISTENT_LOGIN_FAILED: SessionErrorCode.PersistentLoginFailed, - SESSION_TOKEN_EXPIRED: SessionErrorCode.SessionTokenExpired, - INVALID_PATTERN: ValidationErrorCode.InvalidPattern, - ROLE_REQUIRED: RoleErrorCode.RoleRequired, - ROLE_NOT_FOUND: RoleErrorCode.RoleNotFound, - MULTIPLE_ROLE_MATCHES: RoleErrorCode.MultipleRoleMatches, - NO_ROLES_TO_ADD: RoleErrorCode.NoRolesToAdd, - NO_ROLES_TO_UPDATE: RoleErrorCode.NoRolesToUpdate, - NO_ROLES_TO_DELETE: RoleErrorCode.NoRolesToDelete, - ROLE_ADD_FAILED: RoleErrorCode.RoleAddFailed, - ROLE_UPDATE_FAILED: RoleErrorCode.RoleUpdateFailed, - ROLE_DELETE_FAILED: RoleErrorCode.RoleDeleteFailed, - ROLE_ID_ALLOCATION_FAILED: RoleErrorCode.RoleIdAllocationFailed, - ROLE_RENAME_MULTI_NOT_ALLOWED: RoleErrorCode.RoleRenameMultiNotAllowed, - ROLE_NAME_EMPTY: RoleErrorCode.RoleNameEmpty, - ROLE_ENFORCEMENT_FAILED: RoleErrorCode.RoleEnforcementFailed, - NSF_NOT_FOUND: NsfErrorCode.NotFound, - MULTIPLE_NSF_MATCHES: NsfErrorCode.MultipleMatches, - NSF_LEGACY_RECORD: NsfErrorCode.LegacyRecord, - NSF_LEGACY_FOLDER: NsfErrorCode.LegacyFolder, - NSF_PERMISSION_DENIED: NsfErrorCode.PermissionDenied, - NSF_LINK_FAILED: NsfErrorCode.LinkFailed, - NSF_REMOVE_FAILED: NsfErrorCode.RemoveFailed, - NSF_FOLDER_REQUIRED: NsfErrorCode.FolderRequired, - NSF_TOO_MANY_RECORDS: NsfErrorCode.TooManyRecords, - NSF_TOO_MANY_FOLDERS: NsfErrorCode.TooManyFolders, - NSF_MISSING_KEY: NsfErrorCode.MissingKey, - NSF_MKDIR_FAILED: NsfErrorCode.MkdirFailed, - NSF_UPDATE_FAILED: NsfErrorCode.UpdateFailed, - NSF_DETAILS_FAILED: NsfErrorCode.DetailsFailed, - NSF_ADD_FAILED: NsfErrorCode.AddFailed, - TEAM_REQUIRED: TeamErrorCode.TeamRequired, - TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, - MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, - NO_TEAMS_TO_ADD: TeamErrorCode.NoTeamsToAdd, - NO_TEAMS_TO_UPDATE: TeamErrorCode.NoTeamsToUpdate, - NO_TEAMS_TO_DELETE: TeamErrorCode.NoTeamsToDelete, - PARENT_NODE_REQUIRED: TeamErrorCode.ParentNodeRequired, - PARENT_NODE_NOT_FOUND: TeamErrorCode.ParentNodeNotFound, - MULTIPLE_PARENT_NODE_MATCHES: TeamErrorCode.MultipleParentNodeMatches, - ENTERPRISE_PUBLIC_KEY_MISSING: TeamErrorCode.EnterprisePublicKeyMissing, - ENTERPRISE_TREE_KEY_UNAVAILABLE: TeamErrorCode.EnterpriseTreeKeyUnavailable, - DATA_KEY_MISSING: TeamErrorCode.DataKeyMissing, - TEAM_ADD_FAILED: TeamErrorCode.TeamAddFailed, - TEAM_UPDATE_FAILED: TeamErrorCode.TeamUpdateFailed, - TEAM_DELETE_FAILED: TeamErrorCode.TeamDeleteFailed, - MULTIPLE_TEAM_RENAME_NOT_ALLOWED: TeamErrorCode.MultipleTeamRenameNotAllowed, - QUEUED_TEAM_NOT_FOUND: TeamErrorCode.QueuedTeamNotFound, - TEAM_NAME_TOO_LONG: TeamErrorCode.TeamNameTooLong, - NO_USERS_TO_UPDATE: UserErrorCode.NoUsersToUpdate, - NO_USERS_TO_ADD: UserErrorCode.NoUsersToAdd, - NO_USERS_TO_DELETE: UserErrorCode.NoUsersToDelete, - USER_NOT_FOUND: UserErrorCode.UserNotFound, - MULTIPLE_USER_MATCHES: UserErrorCode.MultipleUserMatches, - USER_ADD_FAILED: UserErrorCode.UserAddFailed, - USER_UPDATE_FAILED: UserErrorCode.UserUpdateFailed, - USER_DELETE_FAILED: UserErrorCode.UserDeleteFailed, - NO_USERS_TO_ACTION: UserErrorCode.NoUsersToAction, - USER_ACTION_FAILED: UserErrorCode.UserActionFailed, - USER_ACTION_ALL_NOT_SUPPORTED: UserErrorCode.UserActionAllNotSupported, - USER_ALIAS_FAILED: UserErrorCode.UserAliasFailed, - NO_TEAMS_FOR_USER_OP: UserErrorCode.NoTeamsForUserOp, - TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, - TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, - AUDIT_INVALID_REPORT_TYPE: AuditReportErrorCode.InvalidReportType, - AUDIT_INVALID_CREATED_FILTER: AuditReportErrorCode.InvalidCreatedFilter, - AUDIT_INVALID_FILTER: AuditReportErrorCode.InvalidFilter, - AUDIT_INVALID_LIMIT: AuditReportErrorCode.InvalidLimit, - AUDIT_COLUMNS_REQUIRED: AuditReportErrorCode.ColumnsRequired, - AUDIT_DIMENSION_COLUMN_REQUIRED: AuditReportErrorCode.DimensionColumnRequired, - AUDIT_DIMENSION_FAILED: AuditReportErrorCode.DimensionFailed, - AUDIT_REPORT_FAILED: AuditReportErrorCode.ReportFailed, - AUDIT_REPORTING_NOT_ENABLED: AuditReportErrorCode.ReportingNotEnabled, - AUDIT_LICENSE_CHECK_FAILED: AuditReportErrorCode.LicenseCheckFailed, - ACTION_REPORT_FAILED: ActionReportErrorCode.ReportFailed, - ACTION_REPORT_INVALID_ACTION: ActionReportErrorCode.InvalidAction, - ACTION_REPORT_TARGET_USER_REQUIRED: ActionReportErrorCode.TargetUserRequired, - ACTION_REPORT_TRANSFER_NOT_SUPPORTED: ActionReportErrorCode.TransferNotSupported, - ACTION_REPORT_NODE_NOT_FOUND: ActionReportErrorCode.NodeNotFound, - ACTION_REPORT_NODE_NOT_UNIQUE: ActionReportErrorCode.NodeNotUnique, - PASSWORD_REPORT_POLICY_REQUIRED: PasswordReportErrorCode.PolicyRequired, -} as const + INVALID_CREDENTIALS: AuthErrorCode.InvalidCredentials, + MISSING_USERNAME: AuthErrorCode.MissingUsername, + MISSING_PASSWORD: AuthErrorCode.MissingPassword, + MAX_ATTEMPTS_EXCEEDED: AuthErrorCode.MaxAttemptsExceeded, + USER_CANCELLED: AuthErrorCode.UserCancelled, + UNSUPPORTED_2FA_CHANNEL: AuthErrorCode.Unsupported2FAChannel, + NOT_LOGGED_IN: SessionErrorCode.NotLoggedIn, + DEVICE_NOT_REGISTERED: SessionErrorCode.DeviceNotRegistered, + NO_PREVIOUS_LOGIN: SessionErrorCode.NoPreviousLogin, + NO_CLONE_CODE: SessionErrorCode.NoCloneCode, + PERSISTENT_LOGIN_FAILED: SessionErrorCode.PersistentLoginFailed, + SESSION_TOKEN_EXPIRED: SessionErrorCode.SessionTokenExpired, + INVALID_PATTERN: ValidationErrorCode.InvalidPattern, + ROLE_REQUIRED: RoleErrorCode.RoleRequired, + ROLE_NOT_FOUND: RoleErrorCode.RoleNotFound, + MULTIPLE_ROLE_MATCHES: RoleErrorCode.MultipleRoleMatches, + NO_ROLES_TO_ADD: RoleErrorCode.NoRolesToAdd, + NO_ROLES_TO_UPDATE: RoleErrorCode.NoRolesToUpdate, + NO_ROLES_TO_DELETE: RoleErrorCode.NoRolesToDelete, + ROLE_ADD_FAILED: RoleErrorCode.RoleAddFailed, + ROLE_UPDATE_FAILED: RoleErrorCode.RoleUpdateFailed, + ROLE_DELETE_FAILED: RoleErrorCode.RoleDeleteFailed, + ROLE_ID_ALLOCATION_FAILED: RoleErrorCode.RoleIdAllocationFailed, + ROLE_RENAME_MULTI_NOT_ALLOWED: RoleErrorCode.RoleRenameMultiNotAllowed, + ROLE_NAME_EMPTY: RoleErrorCode.RoleNameEmpty, + ROLE_ENFORCEMENT_FAILED: RoleErrorCode.RoleEnforcementFailed, + NO_ENFORCEMENTS_TO_APPLY: RoleErrorCode.NoEnforcementsToApply, + ROLE_SOURCE_REQUIRED: RoleErrorCode.RoleSourceRequired, + ROLE_COPY_FAILED: RoleErrorCode.RoleCopyFailed, + NO_USERS_FOR_ROLE_OP: RoleErrorCode.NoUsersForRoleOp, + ROLE_USER_ADD_FAILED: RoleErrorCode.RoleUserAddFailed, + ROLE_USER_REMOVE_FAILED: RoleErrorCode.RoleUserRemoveFailed, + NO_NODES_FOR_ROLE_OP: RoleErrorCode.NoNodesForRoleOp, + ROLE_MANAGED_NODE_ADD_FAILED: RoleErrorCode.RoleManagedNodeAddFailed, + ROLE_MANAGED_NODE_UPDATE_FAILED: RoleErrorCode.RoleManagedNodeUpdateFailed, + ROLE_MANAGED_NODE_REMOVE_FAILED: RoleErrorCode.RoleManagedNodeRemoveFailed, + MANAGED_NODE_NOT_MANAGED_BY_ROLE: RoleErrorCode.ManagedNodeNotManagedByRole, + NO_PRIVILEGES_SPECIFIED: RoleErrorCode.NoPrivilegesSpecified, + ROLE_PRIVILEGE_ADD_FAILED: RoleErrorCode.RolePrivilegeAddFailed, + ROLE_PRIVILEGE_REMOVE_FAILED: RoleErrorCode.RolePrivilegeRemoveFailed, + NSF_NOT_FOUND: NsfErrorCode.NotFound, + MULTIPLE_NSF_MATCHES: NsfErrorCode.MultipleMatches, + NSF_LEGACY_RECORD: NsfErrorCode.LegacyRecord, + NSF_LEGACY_FOLDER: NsfErrorCode.LegacyFolder, + NSF_PERMISSION_DENIED: NsfErrorCode.PermissionDenied, + NSF_LINK_FAILED: NsfErrorCode.LinkFailed, + NSF_REMOVE_FAILED: NsfErrorCode.RemoveFailed, + NSF_FOLDER_REQUIRED: NsfErrorCode.FolderRequired, + NSF_TOO_MANY_RECORDS: NsfErrorCode.TooManyRecords, + NSF_TOO_MANY_FOLDERS: NsfErrorCode.TooManyFolders, + NSF_MISSING_KEY: NsfErrorCode.MissingKey, + NSF_MKDIR_FAILED: NsfErrorCode.MkdirFailed, + NSF_UPDATE_FAILED: NsfErrorCode.UpdateFailed, + NSF_DETAILS_FAILED: NsfErrorCode.DetailsFailed, + NSF_ADD_FAILED: NsfErrorCode.AddFailed, + NSF_RECORD_PERMISSION_FAILED: NsfErrorCode.RecordPermissionFailed, + NSF_SHARE_FAILED: NsfErrorCode.ShareFailed, + NSF_SHORTCUT_FAILED: NsfErrorCode.ShortcutFailed, + NSF_TRANSFER_FAILED: NsfErrorCode.TransferFailed, + TEAM_REQUIRED: TeamErrorCode.TeamRequired, + TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, + MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, + NO_TEAMS_TO_ADD: TeamErrorCode.NoTeamsToAdd, + NO_TEAMS_TO_UPDATE: TeamErrorCode.NoTeamsToUpdate, + NO_TEAMS_TO_DELETE: TeamErrorCode.NoTeamsToDelete, + PARENT_NODE_REQUIRED: TeamErrorCode.ParentNodeRequired, + PARENT_NODE_NOT_FOUND: TeamErrorCode.ParentNodeNotFound, + MULTIPLE_PARENT_NODE_MATCHES: TeamErrorCode.MultipleParentNodeMatches, + ENTERPRISE_PUBLIC_KEY_MISSING: TeamErrorCode.EnterprisePublicKeyMissing, + ENTERPRISE_TREE_KEY_UNAVAILABLE: TeamErrorCode.EnterpriseTreeKeyUnavailable, + DATA_KEY_MISSING: TeamErrorCode.DataKeyMissing, + TEAM_ADD_FAILED: TeamErrorCode.TeamAddFailed, + TEAM_UPDATE_FAILED: TeamErrorCode.TeamUpdateFailed, + TEAM_DELETE_FAILED: TeamErrorCode.TeamDeleteFailed, + MULTIPLE_TEAM_RENAME_NOT_ALLOWED: TeamErrorCode.MultipleTeamRenameNotAllowed, + QUEUED_TEAM_NOT_FOUND: TeamErrorCode.QueuedTeamNotFound, + TEAM_NAME_TOO_LONG: TeamErrorCode.TeamNameTooLong, + NO_USERS_TO_UPDATE: UserErrorCode.NoUsersToUpdate, + NO_USERS_TO_ADD: UserErrorCode.NoUsersToAdd, + NO_USERS_TO_DELETE: UserErrorCode.NoUsersToDelete, + USER_NOT_FOUND: UserErrorCode.UserNotFound, + MULTIPLE_USER_MATCHES: UserErrorCode.MultipleUserMatches, + USER_ADD_FAILED: UserErrorCode.UserAddFailed, + USER_UPDATE_FAILED: UserErrorCode.UserUpdateFailed, + USER_DELETE_FAILED: UserErrorCode.UserDeleteFailed, + NO_USERS_TO_ACTION: UserErrorCode.NoUsersToAction, + USER_ACTION_FAILED: UserErrorCode.UserActionFailed, + USER_ACTION_ALL_NOT_SUPPORTED: UserErrorCode.UserActionAllNotSupported, + USER_ALIAS_FAILED: UserErrorCode.UserAliasFailed, + NO_TEAMS_FOR_USER_OP: UserErrorCode.NoTeamsForUserOp, + TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, + TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, + TEAM_ENTERPRISE_USER_UPDATE_FAILED: UserErrorCode.TeamEnterpriseUserUpdateFailed, + HIDE_SHARED_FOLDERS_REQUIRED: UserErrorCode.HideSharedFoldersRequired, + SYNC_FAILED: SyncErrorCode.SyncFailed, + AUDIT_INVALID_REPORT_TYPE: AuditReportErrorCode.InvalidReportType, + AUDIT_INVALID_CREATED_FILTER: AuditReportErrorCode.InvalidCreatedFilter, + AUDIT_INVALID_FILTER: AuditReportErrorCode.InvalidFilter, + AUDIT_INVALID_LIMIT: AuditReportErrorCode.InvalidLimit, + AUDIT_COLUMNS_REQUIRED: AuditReportErrorCode.ColumnsRequired, + AUDIT_DIMENSION_COLUMN_REQUIRED: AuditReportErrorCode.DimensionColumnRequired, + AUDIT_DIMENSION_FAILED: AuditReportErrorCode.DimensionFailed, + AUDIT_REPORT_FAILED: AuditReportErrorCode.ReportFailed, + AUDIT_REPORTING_NOT_ENABLED: AuditReportErrorCode.ReportingNotEnabled, + AUDIT_LICENSE_CHECK_FAILED: AuditReportErrorCode.LicenseCheckFailed, + ACTION_REPORT_FAILED: ActionReportErrorCode.ReportFailed, + ACTION_REPORT_INVALID_ACTION: ActionReportErrorCode.InvalidAction, + ACTION_REPORT_TARGET_USER_REQUIRED: ActionReportErrorCode.TargetUserRequired, + ACTION_REPORT_TRANSFER_NOT_SUPPORTED: + ActionReportErrorCode.TransferNotSupported, + ACTION_REPORT_NODE_NOT_FOUND: ActionReportErrorCode.NodeNotFound, + ACTION_REPORT_NODE_NOT_UNIQUE: ActionReportErrorCode.NodeNotUnique, + PASSWORD_REPORT_POLICY_REQUIRED: PasswordReportErrorCode.PolicyRequired, +} as const; export const KEEPER_PUBLIC_HOSTS: Record = { - US: 'keepersecurity.com', - EU: 'keepersecurity.eu', - AU: 'keepersecurity.com.au', - CA: 'keepersecurity.ca', - JP: 'keepersecurity.jp', - GOV: 'govcloud.keepersecurity.us', -} \ No newline at end of file + US: "keepersecurity.com", + EU: "keepersecurity.eu", + AU: "keepersecurity.com.au", + CA: "keepersecurity.ca", + JP: "keepersecurity.jp", + GOV: "govcloud.keepersecurity.us", +}; diff --git a/KeeperSdk/src/utils/errors.ts b/KeeperSdk/src/utils/errors.ts index b8f6622c..addefad8 100644 --- a/KeeperSdk/src/utils/errors.ts +++ b/KeeperSdk/src/utils/errors.ts @@ -1,108 +1,114 @@ -import type { KeeperError } from '@keeper-security/keeperapi' +import type { KeeperError } from "@keeper-security/keeperapi"; function parseJsonObjectIfPresent(s: string): Record | null { - const t = s.trim() - if (t.length === 0 || (t[0] !== '{' && t[0] !== '[')) return null - try { - const parsed: unknown = JSON.parse(s) - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return parsed as Record - } - } catch { - /* not JSON */ + const t = s.trim(); + if (t.length === 0 || (t[0] !== "{" && t[0] !== "[")) return null; + try { + const parsed: unknown = JSON.parse(s); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + return parsed as Record; } - return null + } catch { + /* not JSON */ + } + return null; } export function isKeeperError(err: unknown): err is KeeperError { - return ( - err != null && - typeof err === 'object' && - !(err instanceof Error) && - ('result_code' in err || 'error' in err || 'response_code' in err) - ) + return ( + err != null && + typeof err === "object" && + !(err instanceof Error) && + ("result_code" in err || "error" in err || "response_code" in err) + ); } export function extractResultCode(err: unknown): string | undefined { - if (isKeeperError(err)) { - return err.result_code || err.error - } - if (err instanceof Error) { - const msg = err.message - if (msg.length > 0 && (msg[0] === '{' || msg[0] === '[')) { - try { - const parsed = JSON.parse(msg) - return parsed.result_code || parsed.error - } catch {} - } + if (isKeeperError(err)) { + return err.result_code || err.error; + } + if (err instanceof Error) { + const msg = err.message; + if (msg.length > 0 && (msg[0] === "{" || msg[0] === "[")) { + try { + const parsed = JSON.parse(msg); + return parsed.result_code || parsed.error; + } catch {} } - if (typeof err === 'string') { - const parsed = parseJsonObjectIfPresent(err) - if (parsed) { - if (typeof parsed.result_code === 'string') return parsed.result_code - if (typeof parsed.error === 'string') return parsed.error - } - return err + } + if (typeof err === "string") { + const parsed = parseJsonObjectIfPresent(err); + if (parsed) { + if (typeof parsed.result_code === "string") return parsed.result_code; + if (typeof parsed.error === "string") return parsed.error; } - if (typeof err === 'object' && err !== null) { - const obj = err as Record - if (typeof obj.result_code === 'string') return obj.result_code - if (typeof obj.error === 'string') return obj.error - } - return undefined + return err; + } + if (typeof err === "object" && err !== null) { + const obj = err as Record; + if (typeof obj.result_code === "string") return obj.result_code; + if (typeof obj.error === "string") return obj.error; + } + return undefined; } export function extractErrorMessage(err: unknown): string { - if (isKeeperError(err)) { - return err.message || err.result_code || err.error || 'Unknown Keeper error' - } - if (err instanceof Error) { - const parsed = parseJsonObjectIfPresent(err.message) - if (parsed) { - if (typeof parsed.message === 'string') return parsed.message - if (typeof parsed.result_code === 'string') return parsed.result_code - if (typeof parsed.error === 'string') return parsed.error - } - return err.message - } - if (typeof err === 'string') { - const parsed = parseJsonObjectIfPresent(err) - if (parsed) { - if (typeof parsed.message === 'string') return parsed.message - if (typeof parsed.result_code === 'string') return parsed.result_code - if (typeof parsed.error === 'string') return parsed.error - } - return err + if (isKeeperError(err)) { + return ( + err.message || err.result_code || err.error || "Unknown Keeper error" + ); + } + if (err instanceof Error) { + const parsed = parseJsonObjectIfPresent(err.message); + if (parsed) { + if (typeof parsed.message === "string") return parsed.message; + if (typeof parsed.result_code === "string") return parsed.result_code; + if (typeof parsed.error === "string") return parsed.error; } - if (typeof err === 'object' && err !== null) { - const obj = err as Record - if (typeof obj.message === 'string') return obj.message - if (typeof obj.result_code === 'string') return obj.result_code + return err.message; + } + if (typeof err === "string") { + const parsed = parseJsonObjectIfPresent(err); + if (parsed) { + if (typeof parsed.message === "string") return parsed.message; + if (typeof parsed.result_code === "string") return parsed.result_code; + if (typeof parsed.error === "string") return parsed.error; } - return String(err) + return err; + } + if (typeof err === "object" && err !== null) { + const obj = err as Record; + if (typeof obj.message === "string") return obj.message; + if (typeof obj.result_code === "string") return obj.result_code; + } + return String(err); } export class KeeperSdkError extends Error { - readonly resultCode?: string - readonly keeperError?: KeeperError + readonly resultCode?: string; + readonly keeperError?: KeeperError; - constructor(message: string, resultCode?: string, keeperError?: KeeperError) { - super(message) - this.name = 'KeeperSdkError' - this.resultCode = resultCode - this.keeperError = keeperError - } + constructor(message: string, resultCode?: string, keeperError?: KeeperError) { + super(message); + this.name = "KeeperSdkError"; + this.resultCode = resultCode; + this.keeperError = keeperError; + } - static from(err: unknown): KeeperSdkError { - if (err instanceof KeeperSdkError) return err - if (isKeeperError(err)) { - return new KeeperSdkError( - err.message || err.result_code || err.error || 'Unknown Keeper error', - err.result_code || err.error, - err - ) - } - if (err instanceof Error) return new KeeperSdkError(err.message) - return new KeeperSdkError(extractErrorMessage(err)) + static from(err: unknown): KeeperSdkError { + if (err instanceof KeeperSdkError) return err; + if (isKeeperError(err)) { + return new KeeperSdkError( + err.message || err.result_code || err.error || "Unknown Keeper error", + err.result_code || err.error, + err, + ); } + if (err instanceof Error) return new KeeperSdkError(err.message); + return new KeeperSdkError(extractErrorMessage(err)); + } } diff --git a/KeeperSdk/src/utils/guards.ts b/KeeperSdk/src/utils/guards.ts index 972f7769..b2ba9499 100644 --- a/KeeperSdk/src/utils/guards.ts +++ b/KeeperSdk/src/utils/guards.ts @@ -1,24 +1,24 @@ export function isBoolean(value: unknown): value is boolean { - return typeof value === 'boolean' + return typeof value === "boolean"; } export function isString(value: unknown): value is string { - return typeof value === 'string' + return typeof value === "string"; } export function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0 + return typeof value === "string" && value.trim().length > 0; } export function isNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) + return typeof value === "number" && Number.isFinite(value); } export function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + return typeof value === "object" && value !== null && !Array.isArray(value); } /** True if at least one of the values is a boolean (true or false). */ export function anyIsBoolean(...values: unknown[]): boolean { - return values.some(isBoolean) + return values.some(isBoolean); } diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index 65ea9f2b..e5ba9d15 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -1,30 +1,71 @@ export { - SdkDefaults, - AuthDefaults, - ResultCodes, - AuthErrorCode, - SessionErrorCode, - ValidationErrorCode, - RoleErrorCode, - TeamErrorCode, - UserErrorCode, - AuditReportErrorCode, - ActionReportErrorCode, - PasswordReportErrorCode, - NsfErrorCode, - KEEPER_PUBLIC_HOSTS, -} from './constants' -export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger, writeOutput } from './Logger' -export type { ILogger } from './Logger' -export { KeeperSdkError, isKeeperError, extractErrorMessage, extractResultCode } from './errors' -export type { Nullable, Optional, DeepPartial, Immutable } from './types' -export { isBoolean, isString, isNonEmptyString, isNumber, isObject, anyIsBoolean } from './guards' + SdkDefaults, + AuthDefaults, + ResultCodes, + AuthErrorCode, + SessionErrorCode, + ValidationErrorCode, + RoleErrorCode, + TeamErrorCode, + UserErrorCode, + AuditReportErrorCode, + ActionReportErrorCode, + PasswordReportErrorCode, + NsfErrorCode, + KEEPER_PUBLIC_HOSTS, +} from "./constants"; export { - EMAIL_PATTERN, - EMAIL_LIST_SEPARATOR_PATTERN, - TOKEN_SEPARATOR_PATTERN, - REGEX_ESCAPE_PATTERN, - isValidEmail, - escapeRegExp, - resolveSearchPattern, -} from './patterns' + Logger, + ConsoleLogger, + LogLevel, + logger, + setLogger, + getLogger, + resetLogger, + writeOutput, +} from "./Logger"; +export type { ILogger } from "./Logger"; +export { + KeeperSdkError, + isKeeperError, + extractErrorMessage, + extractResultCode, +} from "./errors"; +export type { Nullable, Optional, DeepPartial, Immutable } from "./types"; +export { + isBoolean, + isString, + isNonEmptyString, + isNumber, + isObject, + anyIsBoolean, +} from "./guards"; +export { + EMAIL_PATTERN, + EMAIL_LIST_SEPARATOR_PATTERN, + TOKEN_SEPARATOR_PATTERN, + REGEX_ESCAPE_PATTERN, + TRAILING_EQUALS_PATTERN, + WHITESPACE_PATTERN, + isValidEmail, + escapeRegExp, + resolveSearchPattern, +} from "./patterns"; +export { + DEFAULT_PASSWORD_LENGTH, + PW_SPECIAL_CHARACTERS, + GEN_PASSWORD_ALGORITHMS, + KeeperPasswordGenerator, + generatePasswordFromOptions, + resolveGenPasswordAlgorithm, + generatePassword, + parseGenParametersFromValue, + isGenerateFieldValue, + parseGeneratePasswordFlag, +} from "./passwordGenerator"; +export type { + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, +} from "./passwordGenerator"; diff --git a/KeeperSdk/src/utils/passwordGenerator.ts b/KeeperSdk/src/utils/passwordGenerator.ts new file mode 100644 index 00000000..947b1603 --- /dev/null +++ b/KeeperSdk/src/utils/passwordGenerator.ts @@ -0,0 +1,627 @@ +/** + * Password generation — random passwords match Keeper .NET + * `CryptoUtils.GeneratePassword` (keeper-sdk-dotnet). + * Passphrase/dice/crypto/$GEN helpers align with Commander `generator.py`. + */ + +import { BIP39_WORDS, DICEWARE_WORDS } from "./resources/wordlists"; + +/** Matches `CryptoUtils.SpecialCharacters` in keeper-sdk-dotnet. */ +export const PW_SPECIAL_CHARACTERS = "!@#$%()+;<>=?[]{}^.,"; + +export const DEFAULT_PASSWORD_LENGTH = 20; +export const GEN_PASSWORD_ALGORITHMS = [ + "rand", + "dice", + "crypto", + "passphrase", +] as const; + +export type GenPasswordAlgorithm = (typeof GEN_PASSWORD_ALGORITHMS)[number]; + +/** + * Matches `PasswordGenerationOptions` in keeper-sdk-dotnet. + * Use -1 for upper/lower/digit/special to exclude that character class. + */ +export type PasswordGenerationOptions = { + length?: number; + lower?: number; + upper?: number; + digit?: number; + special?: number; + specialCharacters?: string; +}; + +export type PasswordComplexityPolicy = { + length?: number; + "lower-use"?: boolean; + "lower-min"?: number; + "upper-use"?: boolean; + "upper-min"?: number; + "digit-use"?: boolean; + "digit-min"?: number; + "special-use"?: boolean; + "special-min"?: number; + special?: string; + "passphrase-allow"?: boolean; + "passphrase-length"?: number; + "passphrase-separator"?: string; +}; + +const PP_SEPARATOR_CHARACTERS = "-._?! "; +const DEFAULT_PASSPHRASE_SEPARATOR = "-"; +const DEFAULT_PASSPHRASE_WORD_COUNT = 5; +const MIN_PASSPHRASE_WORD_COUNT = 5; +const MAX_PASSPHRASE_WORD_COUNT = 9; +const LETTER_COUNT = "z".charCodeAt(0) - "a".charCodeAt(0) + 1; + +export type PassphraseGenOptions = { + wordCount: number | null; + separator: string | null; + capitalize: boolean | null; + appendNumber: boolean | null; +}; + +function randomBytes(length: number): Uint8Array { + const buf = new Uint8Array(length); + crypto.getRandomValues(buf); + return buf; +} + +function randomInt(max: number): number { + if (max <= 0) return 0; + const buf = new Uint32Array(1); + crypto.getRandomValues(buf); + return buf[0] % max; +} + +/** Matches `CryptoUtils.Shuffle` (keeper-sdk-dotnet). */ +function shuffleInPlace(array: T[]): void { + if (!array || array.length < 2) return; + const bigArray = array.length > 255; + const randoms = randomBytes(array.length * (bigArray ? 4 : 1)); + for (let i = array.length - 1; i >= 0; i--) { + let random: number; + if (bigArray) { + const offset = i * 4; + random = + ((randoms[offset] | + (randoms[offset + 1] << 8) | + (randoms[offset + 2] << 16) | + (randoms[offset + 3] << 24)) >>> + 0) & + 0x7fffffff; + } else { + random = randoms[i]; + } + const j = random % array.length; + if (i !== j) { + const ch = array[i]; + array[i] = array[j]; + array[j] = ch; + } + } +} + +/** + * Generates a random password using Keeper vault rules. + * Port of `CryptoUtils.GeneratePassword` (keeper-sdk-dotnet). + */ +export function generatePasswordFromOptions( + options?: PasswordGenerationOptions | null, +): string { + let length = options?.length ?? 20; + let upper = options?.upper ?? 4; + let lower = options?.lower ?? 4; + let digit = options?.digit ?? 2; + let special = options?.special ?? -1; + + if (length <= 0) { + length = 20; + } + + if (upper < 0 && lower < 0 && digit < 0 && special < 0) { + lower = length; + } + + let required = + Math.max(upper, 0) + + Math.max(lower, 0) + + Math.max(digit, 0) + + Math.max(special, 0); + let extra = required - length; + if (extra > 0) { + let left = extra; + if (lower > 0) { + let toSubtract = Math.ceil((lower / required) * extra); + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract); + lower -= toSubtract; + left -= toSubtract; + } + } + if (left > 0 && upper > 0) { + let toSubtract = Math.ceil((upper / required) * extra); + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract); + upper -= toSubtract; + left -= toSubtract; + } + } + if (left > 0 && digit > 0) { + let toSubtract = Math.ceil((digit / required) * extra); + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract); + digit -= toSubtract; + left -= toSubtract; + } + } + if (left > 0 && special > 0) { + let toSubtract = Math.ceil((special / required) * extra); + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract); + special -= toSubtract; + left -= toSubtract; + } + } + } + + required = + Math.max(upper, 0) + + Math.max(lower, 0) + + Math.max(digit, 0) + + Math.max(special, 0); + extra = length - required; + while (extra > 0) { + if (extra > 0 && lower >= 0) { + lower++; + extra--; + } + if (extra > 0 && upper >= 0) { + upper++; + extra--; + } + if (extra > 0 && digit >= 0) { + digit++; + extra--; + } + if (extra > 0 && special >= 0) { + special++; + extra--; + } + } + + const buffer = new Array(length); + const indexes = Array.from({ length }, (_, i) => i); + shuffleInPlace(indexes); + const randoms = randomBytes(length); + const specialCharacters = + options?.specialCharacters && options.specialCharacters.length > 0 + ? options.specialCharacters + : PW_SPECIAL_CHARACTERS; + + for (const pos of indexes) { + if (upper > 0) { + buffer[pos] = String.fromCharCode( + "A".charCodeAt(0) + (randoms[pos] % LETTER_COUNT), + ); + upper--; + } else if (lower > 0) { + buffer[pos] = String.fromCharCode( + "a".charCodeAt(0) + (randoms[pos] % LETTER_COUNT), + ); + lower--; + } else if (digit > 0) { + buffer[pos] = String.fromCharCode( + "0".charCodeAt(0) + (randoms[pos] % 10), + ); + digit--; + } else if (special > 0) { + buffer[pos] = specialCharacters[randoms[pos] % specialCharacters.length]; + special--; + } else { + buffer[pos] = String.fromCharCode( + "a".charCodeAt(0) + (randoms[pos] % LETTER_COUNT), + ); + } + } + + shuffleInPlace(buffer); + return buffer.join(""); +} + +function policyToGenerationOptions( + policy: PasswordComplexityPolicy, + lengthOverride?: number | null, +): PasswordGenerationOptions { + const opts: PasswordGenerationOptions = { + length: lengthOverride ?? policy.length, + specialCharacters: policy.special, + }; + if (policy["lower-use"] === false) opts.lower = -1; + else if (policy["lower-min"] !== undefined) opts.lower = policy["lower-min"]; + if (policy["upper-use"] === false) opts.upper = -1; + else if (policy["upper-min"] !== undefined) opts.upper = policy["upper-min"]; + if (policy["digit-use"] === false) opts.digit = -1; + else if (policy["digit-min"] !== undefined) opts.digit = policy["digit-min"]; + if (policy["special-use"] === false) opts.special = -1; + else if (policy["special-min"] !== undefined) + opts.special = policy["special-min"]; + return opts; +} + +/** Thin wrapper over {@link generatePasswordFromOptions} for CLI / legacy callers. */ +export class KeeperPasswordGenerator { + private readonly options: PasswordGenerationOptions; + + constructor( + lengthOrOptions: + | number + | PasswordGenerationOptions = DEFAULT_PASSWORD_LENGTH, + ) { + this.options = + typeof lengthOrOptions === "number" + ? { length: lengthOrOptions } + : { ...lengthOrOptions }; + } + + generate(): string { + return generatePasswordFromOptions(this.options); + } + + static createFromPolicy( + policy: PasswordComplexityPolicy, + lengthOverride?: number | null, + ): KeeperPasswordGenerator { + return new KeeperPasswordGenerator( + policyToGenerationOptions(policy, lengthOverride), + ); + } +} + +function clampPassphraseWordCount( + wordCount: number | null | undefined, +): number { + if (typeof wordCount !== "number") return DEFAULT_PASSPHRASE_WORD_COUNT; + if (wordCount < MIN_PASSPHRASE_WORD_COUNT) return MIN_PASSPHRASE_WORD_COUNT; + if (wordCount > MAX_PASSPHRASE_WORD_COUNT) return MAX_PASSPHRASE_WORD_COUNT; + return wordCount; +} + +class KeeperPassphraseGenerator { + private readonly wordCount: number; + private readonly separator: string; + private readonly capitalize: boolean; + private readonly appendNumber: boolean; + private readonly vocabulary: readonly string[]; + + constructor( + wordCount: number, + separator: string, + capitalize: boolean, + appendNumber: boolean, + vocabulary: readonly string[], + ) { + this.wordCount = wordCount; + this.separator = separator; + this.capitalize = capitalize; + this.appendNumber = appendNumber; + this.vocabulary = vocabulary; + } + + generate(): string { + let passphrase = ""; + for (let i = 0; i < this.wordCount; i++) { + let word = this.vocabulary[randomInt(this.vocabulary.length)]; + if (this.capitalize && word) word = word[0].toUpperCase() + word.slice(1); + if (this.appendNumber && i === 0) word += String(randomInt(10)); + if (i > 0) passphrase += this.separator; + passphrase += word; + } + return passphrase; + } + + static createWithOptions( + policy: PasswordComplexityPolicy | null | undefined, + options: PassphraseGenOptions, + ): KeeperPassphraseGenerator { + const wordCount = clampPassphraseWordCount( + options.wordCount ?? + policy?.["passphrase-length"] ?? + DEFAULT_PASSPHRASE_WORD_COUNT, + ); + const separator = + options.separator ?? + (policy?.["passphrase-separator"]?.trim() + ? policy["passphrase-separator"].replace(/\u2423/g, " ")[0] || + DEFAULT_PASSPHRASE_SEPARATOR + : DEFAULT_PASSPHRASE_SEPARATOR); + const capitalize = options.capitalize ?? true; + const appendNumber = options.appendNumber ?? true; + return new KeeperPassphraseGenerator( + wordCount, + separator, + capitalize, + appendNumber, + DICEWARE_WORDS, + ); + } +} + +class DicewarePasswordGenerator { + private readonly rolls: number; + private readonly vocabulary: readonly string[]; + private readonly delimiter: string; + + constructor(rolls: number, vocabulary: readonly string[], delimiter = " ") { + this.rolls = rolls; + this.vocabulary = vocabulary; + this.delimiter = delimiter; + } + + generate(): string { + const words: string[] = []; + for (let i = 0; i < this.rolls; i++) + words.push(this.vocabulary[randomInt(this.vocabulary.length)]); + shuffleInPlace(words); + return words.join(this.delimiter); + } +} + +class CryptoPassphraseGenerator { + generate(): string { + const key = randomBytes(32); + const digest = new Uint8Array(syncSha256(key)); + const secretBytes = new Uint8Array(33); + secretBytes.set(key); + secretBytes[32] = digest[0]; + let secret = BigInt(0); + for (const b of secretBytes) secret = (secret << BigInt(8)) | BigInt(b); + const indices: number[] = []; + for (let i = 0; i < 24; i++) { + indices.push(Number(secret & BigInt(0x7ff))); + secret >>= BigInt(11); + } + indices.reverse(); + return indices.map((idx) => BIP39_WORDS[idx]).join(" "); + } +} + +function syncSha256(data: Uint8Array): Uint8Array { + if (typeof process !== "undefined" && process.versions?.node) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { createHash } = require("crypto") as typeof import("crypto"); + return new Uint8Array(createHash("sha256").update(data).digest()); + } + throw new Error( + "crypto passphrase generation requires Node.js (use rand or passphrase instead)", + ); +} + +export function resolveGenPasswordAlgorithm( + parameters?: readonly string[] | null, +): { algorithm: GenPasswordAlgorithm | null; error: string | null } { + if (!parameters || parameters.length === 0) + return { algorithm: "rand", error: null }; + const first = parameters[0].trim(); + const firstLower = first.toLowerCase(); + if ((GEN_PASSWORD_ALGORITHMS as readonly string[]).includes(firstLower)) { + return { algorithm: firstLower as GenPasswordAlgorithm, error: null }; + } + if (/^\d+$/.test(first)) return { algorithm: "rand", error: null }; + return { + algorithm: null, + error: `Unknown $GEN password algorithm "${first}". Valid algorithms: ${GEN_PASSWORD_ALGORITHMS.join(", ")}.`, + }; +} + +function parsePassphraseGenParameters(parameters: readonly string[]): { + options: PassphraseGenOptions; + error: string | null; +} { + const empty: PassphraseGenOptions = { + wordCount: null, + separator: null, + capitalize: null, + appendNumber: null, + }; + if ( + !parameters.length || + parameters[0].trim().toLowerCase() !== "passphrase" + ) { + return { options: empty, error: null }; + } + const extras = parameters.slice(1); + if (extras.some((t) => t.trim() === "")) { + return { + options: empty, + error: + "Incomplete $GEN:passphrase parameters: missing value after comma. " + + "Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number]", + }; + } + let wordCount: number | null = null; + let separator: string | null = null; + let capitalize: boolean | null = null; + let appendNumber: boolean | null = null; + let idx = 0; + if (idx < extras.length) { + const token = extras[idx].trim(); + if (/^\d+$/.test(token)) { + wordCount = Number(token); + if ( + wordCount < MIN_PASSPHRASE_WORD_COUNT || + wordCount > MAX_PASSPHRASE_WORD_COUNT + ) { + return { + options: empty, + error: `Passphrase word count must be between ${MIN_PASSPHRASE_WORD_COUNT} and ${MAX_PASSPHRASE_WORD_COUNT} (got ${wordCount}).`, + }; + } + idx++; + } + } + if (idx < extras.length && !/^(true|false)$/i.test(extras[idx].trim())) { + const token = extras[idx].trim(); + if (token.toLowerCase() === "space" || token.toLowerCase() === "sp") + separator = " "; + else if (token.length === 1 && PP_SEPARATOR_CHARACTERS.includes(token)) + separator = token; + else { + return { + options: empty, + error: `Invalid passphrase separator "${token}". Allowed: ${PP_SEPARATOR_CHARACTERS.replace(/ /g, "space ")}.`, + }; + } + idx++; + } + if (idx < extras.length) { + const token = extras[idx].trim().toLowerCase(); + if (token === "true") capitalize = true; + else if (token === "false") capitalize = false; + else { + return { + options: empty, + error: `Invalid $GEN:passphrase capitalize parameter "${extras[idx]}". Expected true or false.`, + }; + } + idx++; + } + if (idx < extras.length) { + const token = extras[idx].trim().toLowerCase(); + if (token === "true") appendNumber = true; + else if (token === "false") appendNumber = false; + else { + return { + options: empty, + error: `Invalid $GEN:passphrase number parameter "${extras[idx]}". Expected true or false.`, + }; + } + idx++; + } + if (idx < extras.length) { + return { + options: empty, + error: `Unexpected $GEN:passphrase parameter "${extras[idx].trim()}".`, + }; + } + return { + options: { wordCount, separator, capitalize, appendNumber }, + error: null, + }; +} + +function parseLength(parameters: readonly string[]): number | null { + const numeric = parameters.find((p) => /^\d+$/.test(p.trim())); + if (!numeric) return null; + return Number(numeric); +} + +function randomPasswordOptions( + length: number | null, + policy?: PasswordComplexityPolicy | null, +): PasswordGenerationOptions { + if (policy) { + return policyToGenerationOptions(policy, length); + } + const opts: PasswordGenerationOptions = {}; + if (typeof length === "number") { + let clamped = length; + if (clamped < 4) clamped = 4; + if (clamped > 200) clamped = 200; + opts.length = clamped; + } + return opts; +} + +export function generatePassword( + parameters?: readonly string[] | null, + policy?: PasswordComplexityPolicy | null, +): { password: string | null; error: string | null } { + const { algorithm, error: algError } = + resolveGenPasswordAlgorithm(parameters); + if (algError) return { password: null, error: algError }; + if (!algorithm) + return { password: null, error: "Unknown password generation algorithm." }; + + const params = parameters ?? []; + const length = parseLength(params); + + try { + if (algorithm === "crypto") { + if (typeof process === "undefined" || !process.versions?.node) { + return { + password: null, + error: + "$GEN:crypto is not supported in the browser; use rand or passphrase.", + }; + } + return { + password: new CryptoPassphraseGenerator().generate(), + error: null, + }; + } + if (algorithm === "passphrase") { + const { options, error: ppError } = parsePassphraseGenParameters(params); + if (ppError) return { password: null, error: ppError }; + if (policy?.["passphrase-allow"] === false) { + return { + password: generatePasswordFromOptions( + randomPasswordOptions(length, policy), + ), + error: null, + }; + } + const gen = KeeperPassphraseGenerator.createWithOptions(policy, options); + return { password: gen.generate(), error: null }; + } + if (algorithm === "dice") { + let rolls = length ?? 5; + if (rolls < 1) rolls = 1; + if (rolls > 40) rolls = 40; + return { + password: new DicewarePasswordGenerator( + rolls, + DICEWARE_WORDS, + ).generate(), + error: null, + }; + } + + return { + password: generatePasswordFromOptions( + randomPasswordOptions(length, policy), + ), + error: null, + }; + } catch (e) { + return { + password: null, + error: e instanceof Error ? e.message : String(e), + }; + } +} + +/** Parse `$GEN:rand,16` or `$GEN` into generation parameters. */ +export function parseGenParametersFromValue(value: string): string[] { + if (!value.startsWith("$GEN")) return []; + let rest = value.slice(4); + if (rest.startsWith(":")) rest = rest.slice(1); + if (!rest.trim()) return []; + return rest.split(",").map((part) => part.trim()); +} + +export function isGenerateFieldValue(value: string): boolean { + return value.startsWith("$GEN"); +} + +/** Parse `--generate-password` or `--generate-password=rand,16` into $GEN parameters. */ +export function parseGeneratePasswordFlag( + raw: string | true | undefined, +): string[] | null { + if (raw === undefined) return null; + if (raw === true) return []; + const trimmed = raw.trim(); + if (!trimmed) return []; + return trimmed.split(",").map((part) => part.trim()); +} diff --git a/KeeperSdk/src/utils/patterns.ts b/KeeperSdk/src/utils/patterns.ts index d65cc00f..a4468bf4 100644 --- a/KeeperSdk/src/utils/patterns.ts +++ b/KeeperSdk/src/utils/patterns.ts @@ -1,32 +1,39 @@ -import { KeeperSdkError } from './errors' -import { ResultCodes } from './constants' +import { KeeperSdkError } from "./errors"; +import { ResultCodes } from "./constants"; -export const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+\.[^\s@]+$/ +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+\.[^\s@]+$/; /** Splits user-entered email lists on whitespace, commas, and semicolons. */ -export const EMAIL_LIST_SEPARATOR_PATTERN = /[\s,;]+/ +export const EMAIL_LIST_SEPARATOR_PATTERN = /[\s,;]+/; /** Splits free-form text into search tokens on whitespace and common punctuation. */ -export const TOKEN_SEPARATOR_PATTERN = /[\s\-_.,;:!?@#$%^&*()[\]{}|\\/<>]+/ +export const TOKEN_SEPARATOR_PATTERN = /[\s\-_.,;:!?@#$%^&*()[\]{}|\\/<>]+/; /** Characters that must be escaped when embedding user input into a RegExp. */ -export const REGEX_ESCAPE_PATTERN = /[.+^${}()|[\]\\]/g +export const REGEX_ESCAPE_PATTERN = /[.+^${}()|[\]\\]/g; -const MAX_EMAIL_LENGTH = 254 +/** Sequence of one or more `=` characters at end of string (Base32 padding). */ +export const TRAILING_EQUALS_PATTERN = /=+$/g; + +/** Any whitespace run. */ +export const WHITESPACE_PATTERN = /\s+/g; export function isValidEmail(value: string): boolean { - return value.length <= MAX_EMAIL_LENGTH && EMAIL_PATTERN.test(value) + return EMAIL_PATTERN.test(value); } export function escapeRegExp(value: string): string { - return value.replace(REGEX_ESCAPE_PATTERN, '\\$&') + return value.replace(REGEX_ESCAPE_PATTERN, "\\$&"); } export function resolveSearchPattern(pattern: unknown): string | null { - if (pattern == null) return null - if (typeof pattern !== 'string') { - throw new KeeperSdkError('Pattern must be a string.', ResultCodes.INVALID_PATTERN) - } - const trimmed = pattern.trim() - return trimmed.length > 0 ? trimmed : null + if (pattern == null) return null; + if (typeof pattern !== "string") { + throw new KeeperSdkError( + "Pattern must be a string.", + ResultCodes.INVALID_PATTERN, + ); + } + const trimmed = pattern.trim(); + return trimmed.length > 0 ? trimmed : null; } diff --git a/KeeperSdk/src/utils/resources/wordlists.ts b/KeeperSdk/src/utils/resources/wordlists.ts new file mode 100644 index 00000000..564e26f1 --- /dev/null +++ b/KeeperSdk/src/utils/resources/wordlists.ts @@ -0,0 +1,9830 @@ +// Auto-generated from Commander keepercommander/resources word lists. +export const DICEWARE_WORDS: readonly string[] = [ + "abacus", + "abdomen", + "abdominal", + "abide", + "abiding", + "ability", + "ablaze", + "able", + "abnormal", + "abrasion", + "abrasive", + "abreast", + "abridge", + "abroad", + "abruptly", + "absence", + "absentee", + "absently", + "absinthe", + "absolute", + "absolve", + "abstain", + "abstract", + "absurd", + "accent", + "acclaim", + "acclimate", + "accompany", + "account", + "accuracy", + "accurate", + "accustom", + "acetone", + "achiness", + "aching", + "acid", + "acorn", + "acquaint", + "acquire", + "acre", + "acrobat", + "acronym", + "acting", + "action", + "activate", + "activator", + "active", + "activism", + "activist", + "activity", + "actress", + "acts", + "acutely", + "acuteness", + "aeration", + "aerobics", + "aerosol", + "aerospace", + "afar", + "affair", + "affected", + "affecting", + "affection", + "affidavit", + "affiliate", + "affirm", + "affix", + "afflicted", + "affluent", + "afford", + "affront", + "aflame", + "afloat", + "aflutter", + "afoot", + "afraid", + "afterglow", + "afterlife", + "aftermath", + "aftermost", + "afternoon", + "aged", + "ageless", + "agency", + "agenda", + "agent", + "aggregate", + "aghast", + "agile", + "agility", + "aging", + "agnostic", + "agonize", + "agonizing", + "agony", + "agreeable", + "agreeably", + "agreed", + "agreeing", + "agreement", + "aground", + "ahead", + "ahoy", + "aide", + "aids", + "aim", + "ajar", + "alabaster", + "alarm", + "albatross", + "album", + "alfalfa", + "algebra", + "algorithm", + "alias", + "alibi", + "alienable", + "alienate", + "aliens", + "alike", + "alive", + "alkaline", + "alkalize", + "almanac", + "almighty", + "almost", + "aloe", + "aloft", + "aloha", + "alone", + "alongside", + "aloof", + "alphabet", + "alright", + "although", + "altitude", + "alto", + "aluminum", + "alumni", + "always", + "amaretto", + "amaze", + "amazingly", + "amber", + "ambiance", + "ambiguity", + "ambiguous", + "ambition", + "ambitious", + "ambulance", + "ambush", + "amendable", + "amendment", + "amends", + "amenity", + "amiable", + "amicably", + "amid", + "amigo", + "amino", + "amiss", + "ammonia", + "ammonium", + "amnesty", + "amniotic", + "among", + "amount", + "amperage", + "ample", + "amplifier", + "amplify", + "amply", + "amuck", + "amulet", + "amusable", + "amused", + "amusement", + "amuser", + "amusing", + "anaconda", + "anaerobic", + "anagram", + "anatomist", + "anatomy", + "anchor", + "anchovy", + "ancient", + "android", + "anemia", + "anemic", + "aneurism", + "anew", + "angelfish", + "angelic", + "anger", + "angled", + "angler", + "angles", + "angling", + "angrily", + "angriness", + "anguished", + "angular", + "animal", + "animate", + "animating", + "animation", + "animator", + "anime", + "animosity", + "ankle", + "annex", + "annotate", + "announcer", + "annoying", + "annually", + "annuity", + "anointer", + "another", + "answering", + "antacid", + "antarctic", + "anteater", + "antelope", + "antennae", + "anthem", + "anthill", + "anthology", + "antibody", + "antics", + "antidote", + "antihero", + "antiquely", + "antiques", + "antiquity", + "antirust", + "antitoxic", + "antitrust", + "antiviral", + "antivirus", + "antler", + "antonym", + "antsy", + "anvil", + "anybody", + "anyhow", + "anymore", + "anyone", + "anyplace", + "anything", + "anytime", + "anyway", + "anywhere", + "aorta", + "apache", + "apostle", + "appealing", + "appear", + "appease", + "appeasing", + "appendage", + "appendix", + "appetite", + "appetizer", + "applaud", + "applause", + "apple", + "appliance", + "applicant", + "applied", + "apply", + "appointee", + "appraisal", + "appraiser", + "apprehend", + "approach", + "approval", + "approve", + "apricot", + "april", + "apron", + "aptitude", + "aptly", + "aqua", + "aqueduct", + "arbitrary", + "arbitrate", + "ardently", + "area", + "arena", + "arguable", + "arguably", + "argue", + "arise", + "armadillo", + "armband", + "armchair", + "armed", + "armful", + "armhole", + "arming", + "armless", + "armoire", + "armored", + "armory", + "armrest", + "army", + "aroma", + "arose", + "around", + "arousal", + "arrange", + "array", + "arrest", + "arrival", + "arrive", + "arrogance", + "arrogant", + "arson", + "art", + "ascend", + "ascension", + "ascent", + "ascertain", + "ashamed", + "ashen", + "ashes", + "ashy", + "aside", + "askew", + "asleep", + "asparagus", + "aspect", + "aspirate", + "aspire", + "aspirin", + "astonish", + "astound", + "astride", + "astrology", + "astronaut", + "astronomy", + "astute", + "atlantic", + "atlas", + "atom", + "atonable", + "atop", + "atrium", + "atrocious", + "atrophy", + "attach", + "attain", + "attempt", + "attendant", + "attendee", + "attention", + "attentive", + "attest", + "attic", + "attire", + "attitude", + "attractor", + "attribute", + "atypical", + "auction", + "audacious", + "audacity", + "audible", + "audibly", + "audience", + "audio", + "audition", + "augmented", + "august", + "authentic", + "author", + "autism", + "autistic", + "autograph", + "automaker", + "automated", + "automatic", + "autopilot", + "available", + "avalanche", + "avatar", + "avenge", + "avenging", + "avenue", + "average", + "aversion", + "avert", + "aviation", + "aviator", + "avid", + "avoid", + "await", + "awaken", + "award", + "aware", + "awhile", + "awkward", + "awning", + "awoke", + "awry", + "axis", + "babble", + "babbling", + "babied", + "baboon", + "backache", + "backboard", + "backboned", + "backdrop", + "backed", + "backer", + "backfield", + "backfire", + "backhand", + "backing", + "backlands", + "backlash", + "backless", + "backlight", + "backlit", + "backlog", + "backpack", + "backpedal", + "backrest", + "backroom", + "backshift", + "backside", + "backslid", + "backspace", + "backspin", + "backstab", + "backstage", + "backtalk", + "backtrack", + "backup", + "backward", + "backwash", + "backwater", + "backyard", + "bacon", + "bacteria", + "bacterium", + "badass", + "badge", + "badland", + "badly", + "badness", + "baffle", + "baffling", + "bagel", + "bagful", + "baggage", + "bagged", + "baggie", + "bagginess", + "bagging", + "baggy", + "bagpipe", + "baguette", + "baked", + "bakery", + "bakeshop", + "baking", + "balance", + "balancing", + "balcony", + "balmy", + "balsamic", + "bamboo", + "banana", + "banish", + "banister", + "banjo", + "bankable", + "bankbook", + "banked", + "banker", + "banking", + "banknote", + "bankroll", + "banner", + "bannister", + "banshee", + "banter", + "barbecue", + "barbed", + "barbell", + "barber", + "barcode", + "barge", + "bargraph", + "barista", + "baritone", + "barley", + "barmaid", + "barman", + "barn", + "barometer", + "barrack", + "barracuda", + "barrel", + "barrette", + "barricade", + "barrier", + "barstool", + "bartender", + "barterer", + "bash", + "basically", + "basics", + "basil", + "basin", + "basis", + "basket", + "batboy", + "batch", + "bath", + "baton", + "bats", + "battalion", + "battered", + "battering", + "battery", + "batting", + "battle", + "bauble", + "bazooka", + "blabber", + "bladder", + "blade", + "blah", + "blame", + "blaming", + "blanching", + "blandness", + "blank", + "blaspheme", + "blasphemy", + "blast", + "blatancy", + "blatantly", + "blazer", + "blazing", + "bleach", + "bleak", + "bleep", + "blemish", + "blend", + "bless", + "blighted", + "blimp", + "bling", + "blinked", + "blinker", + "blinking", + "blinks", + "blip", + "blissful", + "blitz", + "blizzard", + "bloated", + "bloating", + "blob", + "blog", + "bloomers", + "blooming", + "blooper", + "blot", + "blouse", + "blubber", + "bluff", + "bluish", + "blunderer", + "blunt", + "blurb", + "blurred", + "blurry", + "blurt", + "blush", + "blustery", + "boaster", + "boastful", + "boasting", + "boat", + "bobbed", + "bobbing", + "bobble", + "bobcat", + "bobsled", + "bobtail", + "bodacious", + "body", + "bogged", + "boggle", + "bogus", + "boil", + "bok", + "bolster", + "bolt", + "bonanza", + "bonded", + "bonding", + "bondless", + "boned", + "bonehead", + "boneless", + "bonelike", + "boney", + "bonfire", + "bonnet", + "bonsai", + "bonus", + "bony", + "boogeyman", + "boogieman", + "book", + "boondocks", + "booted", + "booth", + "bootie", + "booting", + "bootlace", + "bootleg", + "boots", + "boozy", + "borax", + "boring", + "borough", + "borrower", + "borrowing", + "boss", + "botanical", + "botanist", + "botany", + "botch", + "both", + "bottle", + "bottling", + "bottom", + "bounce", + "bouncing", + "bouncy", + "bounding", + "boundless", + "bountiful", + "bovine", + "boxcar", + "boxer", + "boxing", + "boxlike", + "boxy", + "breach", + "breath", + "breeches", + "breeching", + "breeder", + "breeding", + "breeze", + "breezy", + "brethren", + "brewery", + "brewing", + "briar", + "bribe", + "brick", + "bride", + "bridged", + "brigade", + "bright", + "brilliant", + "brim", + "bring", + "brink", + "brisket", + "briskly", + "briskness", + "bristle", + "brittle", + "broadband", + "broadcast", + "broaden", + "broadly", + "broadness", + "broadside", + "broadways", + "broiler", + "broiling", + "broken", + "broker", + "bronchial", + "bronco", + "bronze", + "bronzing", + "brook", + "broom", + "brought", + "browbeat", + "brownnose", + "browse", + "browsing", + "bruising", + "brunch", + "brunette", + "brunt", + "brush", + "brussels", + "brute", + "brutishly", + "bubble", + "bubbling", + "bubbly", + "buccaneer", + "bucked", + "bucket", + "buckle", + "buckshot", + "buckskin", + "bucktooth", + "buckwheat", + "buddhism", + "buddhist", + "budding", + "buddy", + "budget", + "buffalo", + "buffed", + "buffer", + "buffing", + "buffoon", + "buggy", + "bulb", + "bulge", + "bulginess", + "bulgur", + "bulk", + "bulldog", + "bulldozer", + "bullfight", + "bullfrog", + "bullhorn", + "bullion", + "bullish", + "bullpen", + "bullring", + "bullseye", + "bullwhip", + "bully", + "bunch", + "bundle", + "bungee", + "bunion", + "bunkbed", + "bunkhouse", + "bunkmate", + "bunny", + "bunt", + "busboy", + "bush", + "busily", + "busload", + "bust", + "busybody", + "buzz", + "cabana", + "cabbage", + "cabbie", + "cabdriver", + "cable", + "caboose", + "cache", + "cackle", + "cacti", + "cactus", + "caddie", + "caddy", + "cadet", + "cadillac", + "cadmium", + "cage", + "cahoots", + "cake", + "calamari", + "calamity", + "calcium", + "calculate", + "calculus", + "caliber", + "calibrate", + "calm", + "caloric", + "calorie", + "calzone", + "camcorder", + "cameo", + "camera", + "camisole", + "camper", + "campfire", + "camping", + "campsite", + "campus", + "canal", + "canary", + "cancel", + "candied", + "candle", + "candy", + "cane", + "canine", + "canister", + "cannabis", + "canned", + "canning", + "cannon", + "cannot", + "canola", + "canon", + "canopener", + "canopy", + "canteen", + "canyon", + "capable", + "capably", + "capacity", + "cape", + "capillary", + "capital", + "capitol", + "capped", + "capricorn", + "capsize", + "capsule", + "caption", + "captivate", + "captive", + "captivity", + "capture", + "caramel", + "carat", + "caravan", + "carbon", + "cardboard", + "carded", + "cardiac", + "cardigan", + "cardinal", + "cardstock", + "carefully", + "caregiver", + "careless", + "caress", + "caretaker", + "cargo", + "caring", + "carless", + "carload", + "carmaker", + "carnage", + "carnation", + "carnival", + "carnivore", + "carol", + "carpenter", + "carpentry", + "carpool", + "carport", + "carried", + "carrot", + "carrousel", + "carry", + "cartel", + "cartload", + "carton", + "cartoon", + "cartridge", + "cartwheel", + "carve", + "carving", + "carwash", + "cascade", + "case", + "cash", + "casing", + "casino", + "casket", + "cassette", + "casually", + "casualty", + "catacomb", + "catalog", + "catalyst", + "catalyze", + "catapult", + "cataract", + "catatonic", + "catcall", + "catchable", + "catcher", + "catching", + "catchy", + "caterer", + "catering", + "catfight", + "catfish", + "cathedral", + "cathouse", + "catlike", + "catnap", + "catnip", + "catsup", + "cattail", + "cattishly", + "cattle", + "catty", + "catwalk", + "caucasian", + "caucus", + "causal", + "causation", + "cause", + "causing", + "cauterize", + "caution", + "cautious", + "cavalier", + "cavalry", + "caviar", + "cavity", + "cedar", + "celery", + "celestial", + "celibacy", + "celibate", + "celtic", + "cement", + "census", + "ceramics", + "ceremony", + "certainly", + "certainty", + "certified", + "certify", + "cesarean", + "cesspool", + "chafe", + "chaffing", + "chain", + "chair", + "chalice", + "challenge", + "chamber", + "chamomile", + "champion", + "chance", + "change", + "channel", + "chant", + "chaos", + "chaperone", + "chaplain", + "chapped", + "chaps", + "chapter", + "character", + "charbroil", + "charcoal", + "charger", + "charging", + "chariot", + "charity", + "charm", + "charred", + "charter", + "charting", + "chase", + "chasing", + "chaste", + "chastise", + "chastity", + "chatroom", + "chatter", + "chatting", + "chatty", + "cheating", + "cheddar", + "cheek", + "cheer", + "cheese", + "cheesy", + "chef", + "chemicals", + "chemist", + "chemo", + "cherisher", + "cherub", + "chess", + "chest", + "chevron", + "chevy", + "chewable", + "chewer", + "chewing", + "chewy", + "chief", + "chihuahua", + "childcare", + "childhood", + "childish", + "childless", + "childlike", + "chili", + "chill", + "chimp", + "chip", + "chirping", + "chirpy", + "chitchat", + "chivalry", + "chive", + "chloride", + "chlorine", + "choice", + "chokehold", + "choking", + "chomp", + "chooser", + "choosing", + "choosy", + "chop", + "chosen", + "chowder", + "chowtime", + "chrome", + "chubby", + "chuck", + "chug", + "chummy", + "chump", + "chunk", + "churn", + "chute", + "cider", + "cilantro", + "cinch", + "cinema", + "cinnamon", + "circle", + "circling", + "circular", + "circulate", + "circus", + "citable", + "citadel", + "citation", + "citizen", + "citric", + "citrus", + "city", + "civic", + "civil", + "clad", + "claim", + "clambake", + "clammy", + "clamor", + "clamp", + "clamshell", + "clang", + "clanking", + "clapped", + "clapper", + "clapping", + "clarify", + "clarinet", + "clarity", + "clash", + "clasp", + "class", + "clatter", + "clause", + "clavicle", + "claw", + "clay", + "clean", + "clear", + "cleat", + "cleaver", + "cleft", + "clench", + "clergyman", + "clerical", + "clerk", + "clever", + "clicker", + "client", + "climate", + "climatic", + "cling", + "clinic", + "clinking", + "clip", + "clique", + "cloak", + "clobber", + "clock", + "clone", + "cloning", + "closable", + "closure", + "clothes", + "clothing", + "cloud", + "clover", + "clubbed", + "clubbing", + "clubhouse", + "clump", + "clumsily", + "clumsy", + "clunky", + "clustered", + "clutch", + "clutter", + "coach", + "coagulant", + "coastal", + "coaster", + "coasting", + "coastland", + "coastline", + "coat", + "coauthor", + "cobalt", + "cobbler", + "cobweb", + "cocoa", + "coconut", + "cod", + "coeditor", + "coerce", + "coexist", + "coffee", + "cofounder", + "cognition", + "cognitive", + "cogwheel", + "coherence", + "coherent", + "cohesive", + "coil", + "coke", + "cola", + "cold", + "coleslaw", + "coliseum", + "collage", + "collapse", + "collar", + "collected", + "collector", + "collide", + "collie", + "collision", + "colonial", + "colonist", + "colonize", + "colony", + "colossal", + "colt", + "coma", + "come", + "comfort", + "comfy", + "comic", + "coming", + "comma", + "commence", + "commend", + "comment", + "commerce", + "commode", + "commodity", + "commodore", + "common", + "commotion", + "commute", + "commuting", + "compacted", + "compacter", + "compactly", + "compactor", + "companion", + "company", + "compare", + "compel", + "compile", + "comply", + "component", + "composed", + "composer", + "composite", + "compost", + "composure", + "compound", + "compress", + "comprised", + "computer", + "computing", + "comrade", + "concave", + "conceal", + "conceded", + "concept", + "concerned", + "concert", + "conch", + "concierge", + "concise", + "conclude", + "concrete", + "concur", + "condense", + "condiment", + "condition", + "condone", + "conducive", + "conductor", + "conduit", + "cone", + "confess", + "confetti", + "confidant", + "confident", + "confider", + "confiding", + "configure", + "confined", + "confining", + "confirm", + "conflict", + "conform", + "confound", + "confront", + "confused", + "confusing", + "confusion", + "congenial", + "congested", + "congrats", + "congress", + "conical", + "conjoined", + "conjure", + "conjuror", + "connected", + "connector", + "consensus", + "consent", + "console", + "consoling", + "consonant", + "constable", + "constant", + "constrain", + "constrict", + "construct", + "consult", + "consumer", + "consuming", + "contact", + "container", + "contempt", + "contend", + "contented", + "contently", + "contents", + "contest", + "context", + "contort", + "contour", + "contrite", + "control", + "contusion", + "convene", + "convent", + "copartner", + "cope", + "copied", + "copier", + "copilot", + "coping", + "copious", + "copper", + "copy", + "coral", + "cork", + "cornball", + "cornbread", + "corncob", + "cornea", + "corned", + "corner", + "cornfield", + "cornflake", + "cornhusk", + "cornmeal", + "cornstalk", + "corny", + "coronary", + "coroner", + "corporal", + "corporate", + "corral", + "correct", + "corridor", + "corrode", + "corroding", + "corrosive", + "corsage", + "corset", + "cortex", + "cosigner", + "cosmetics", + "cosmic", + "cosmos", + "cosponsor", + "cost", + "cottage", + "cotton", + "couch", + "cough", + "could", + "countable", + "countdown", + "counting", + "countless", + "country", + "county", + "courier", + "covenant", + "cover", + "coveted", + "coveting", + "coyness", + "cozily", + "coziness", + "cozy", + "crabbing", + "crabgrass", + "crablike", + "crabmeat", + "cradle", + "cradling", + "crafter", + "craftily", + "craftsman", + "craftwork", + "crafty", + "cramp", + "cranberry", + "crane", + "cranial", + "cranium", + "crank", + "crate", + "crave", + "craving", + "crawfish", + "crawlers", + "crawling", + "crayfish", + "crayon", + "crazed", + "crazily", + "craziness", + "crazy", + "creamed", + "creamer", + "creamlike", + "crease", + "creasing", + "creatable", + "create", + "creation", + "creative", + "creature", + "credible", + "credibly", + "credit", + "creed", + "creme", + "creole", + "crepe", + "crept", + "crescent", + "crested", + "cresting", + "crestless", + "crevice", + "crewless", + "crewman", + "crewmate", + "crib", + "cricket", + "cried", + "crier", + "crimp", + "crimson", + "cringe", + "cringing", + "crinkle", + "crinkly", + "crisped", + "crisping", + "crisply", + "crispness", + "crispy", + "criteria", + "critter", + "croak", + "crock", + "crook", + "croon", + "crop", + "cross", + "crouch", + "crouton", + "crowbar", + "crowd", + "crown", + "crucial", + "crudely", + "crudeness", + "cruelly", + "cruelness", + "cruelty", + "crumb", + "crummiest", + "crummy", + "crumpet", + "crumpled", + "cruncher", + "crunching", + "crunchy", + "crusader", + "crushable", + "crushed", + "crusher", + "crushing", + "crust", + "crux", + "crying", + "cryptic", + "crystal", + "cubbyhole", + "cube", + "cubical", + "cubicle", + "cucumber", + "cuddle", + "cuddly", + "cufflink", + "culinary", + "culminate", + "culpable", + "culprit", + "cultivate", + "cultural", + "culture", + "cupbearer", + "cupcake", + "cupid", + "cupped", + "cupping", + "curable", + "curator", + "curdle", + "cure", + "curfew", + "curing", + "curled", + "curler", + "curliness", + "curling", + "curly", + "curry", + "curse", + "cursive", + "cursor", + "curtain", + "curtly", + "curtsy", + "curvature", + "curve", + "curvy", + "cushy", + "cusp", + "cussed", + "custard", + "custodian", + "custody", + "customary", + "customer", + "customize", + "customs", + "cut", + "cycle", + "cyclic", + "cycling", + "cyclist", + "cylinder", + "cymbal", + "cytoplasm", + "cytoplast", + "dab", + "dad", + "daffodil", + "dagger", + "daily", + "daintily", + "dainty", + "dairy", + "daisy", + "dallying", + "dance", + "dancing", + "dandelion", + "dander", + "dandruff", + "dandy", + "danger", + "dangle", + "dangling", + "daredevil", + "dares", + "daringly", + "darkened", + "darkening", + "darkish", + "darkness", + "darkroom", + "darling", + "darn", + "dart", + "darwinism", + "dash", + "dastardly", + "data", + "datebook", + "dating", + "daughter", + "daunting", + "dawdler", + "dawn", + "daybed", + "daybreak", + "daycare", + "daydream", + "daylight", + "daylong", + "dayroom", + "daytime", + "dazzler", + "dazzling", + "deacon", + "deafening", + "deafness", + "dealer", + "dealing", + "dealmaker", + "dealt", + "dean", + "debatable", + "debate", + "debating", + "debit", + "debrief", + "debtless", + "debtor", + "debug", + "debunk", + "decade", + "decaf", + "decal", + "decathlon", + "decay", + "deceased", + "deceit", + "deceiver", + "deceiving", + "december", + "decency", + "decent", + "deception", + "deceptive", + "decibel", + "decidable", + "decimal", + "decimeter", + "decipher", + "deck", + "declared", + "decline", + "decode", + "decompose", + "decorated", + "decorator", + "decoy", + "decrease", + "decree", + "dedicate", + "dedicator", + "deduce", + "deduct", + "deed", + "deem", + "deepen", + "deeply", + "deepness", + "deface", + "defacing", + "defame", + "default", + "defeat", + "defection", + "defective", + "defendant", + "defender", + "defense", + "defensive", + "deferral", + "deferred", + "defiance", + "defiant", + "defile", + "defiling", + "define", + "definite", + "deflate", + "deflation", + "deflator", + "deflected", + "deflector", + "defog", + "deforest", + "defraud", + "defrost", + "deftly", + "defuse", + "defy", + "degraded", + "degrading", + "degrease", + "degree", + "dehydrate", + "deity", + "dejected", + "delay", + "delegate", + "delegator", + "delete", + "deletion", + "delicacy", + "delicate", + "delicious", + "delighted", + "delirious", + "delirium", + "deliverer", + "delivery", + "delouse", + "delta", + "deluge", + "delusion", + "deluxe", + "demanding", + "demeaning", + "demeanor", + "demise", + "democracy", + "democrat", + "demote", + "demotion", + "demystify", + "denatured", + "deniable", + "denial", + "denim", + "denote", + "dense", + "density", + "dental", + "dentist", + "denture", + "deny", + "deodorant", + "deodorize", + "departed", + "departure", + "depict", + "deplete", + "depletion", + "deplored", + "deploy", + "deport", + "depose", + "depraved", + "depravity", + "deprecate", + "depress", + "deprive", + "depth", + "deputize", + "deputy", + "derail", + "deranged", + "derby", + "derived", + "desecrate", + "deserve", + "deserving", + "designate", + "designed", + "designer", + "designing", + "deskbound", + "desktop", + "deskwork", + "desolate", + "despair", + "despise", + "despite", + "destiny", + "destitute", + "destruct", + "detached", + "detail", + "detection", + "detective", + "detector", + "detention", + "detergent", + "detest", + "detonate", + "detonator", + "detoxify", + "detract", + "deuce", + "devalue", + "deviancy", + "deviant", + "deviate", + "deviation", + "deviator", + "device", + "devious", + "devotedly", + "devotee", + "devotion", + "devourer", + "devouring", + "devoutly", + "dexterity", + "dexterous", + "diabetes", + "diabetic", + "diabolic", + "diagnoses", + "diagnosis", + "diagram", + "dial", + "diameter", + "diaper", + "diaphragm", + "diary", + "dice", + "dicing", + "dictate", + "dictation", + "dictator", + "difficult", + "diffused", + "diffuser", + "diffusion", + "diffusive", + "dig", + "dilation", + "diligence", + "diligent", + "dill", + "dilute", + "dime", + "diminish", + "dimly", + "dimmed", + "dimmer", + "dimness", + "dimple", + "diner", + "dingbat", + "dinghy", + "dinginess", + "dingo", + "dingy", + "dining", + "dinner", + "diocese", + "dioxide", + "diploma", + "dipped", + "dipper", + "dipping", + "directed", + "direction", + "directive", + "directly", + "directory", + "direness", + "dirtiness", + "disabled", + "disagree", + "disallow", + "disarm", + "disarray", + "disaster", + "disband", + "disbelief", + "disburse", + "discard", + "discern", + "discharge", + "disclose", + "discolor", + "discount", + "discourse", + "discover", + "discuss", + "disdain", + "disengage", + "disfigure", + "disgrace", + "dish", + "disinfect", + "disjoin", + "disk", + "dislike", + "disliking", + "dislocate", + "dislodge", + "disloyal", + "dismantle", + "dismay", + "dismiss", + "dismount", + "disobey", + "disorder", + "disown", + "disparate", + "disparity", + "dispatch", + "dispense", + "dispersal", + "dispersed", + "disperser", + "displace", + "display", + "displease", + "disposal", + "dispose", + "disprove", + "dispute", + "disregard", + "disrupt", + "dissuade", + "distance", + "distant", + "distaste", + "distill", + "distinct", + "distort", + "distract", + "distress", + "district", + "distrust", + "ditch", + "ditto", + "ditzy", + "dividable", + "divided", + "dividend", + "dividers", + "dividing", + "divinely", + "diving", + "divinity", + "divisible", + "divisibly", + "division", + "divisive", + "divorcee", + "dizziness", + "dizzy", + "doable", + "docile", + "dock", + "doctrine", + "document", + "dodge", + "dodgy", + "doily", + "doing", + "dole", + "dollar", + "dollhouse", + "dollop", + "dolly", + "dolphin", + "domain", + "domelike", + "domestic", + "dominion", + "dominoes", + "donated", + "donation", + "donator", + "donor", + "donut", + "doodle", + "doorbell", + "doorframe", + "doorknob", + "doorman", + "doormat", + "doornail", + "doorpost", + "doorstep", + "doorstop", + "doorway", + "doozy", + "dork", + "dormitory", + "dorsal", + "dosage", + "dose", + "dotted", + "doubling", + "douche", + "dove", + "down", + "dowry", + "doze", + "drab", + "dragging", + "dragonfly", + "dragonish", + "dragster", + "drainable", + "drainage", + "drained", + "drainer", + "drainpipe", + "dramatic", + "dramatize", + "drank", + "drapery", + "drastic", + "draw", + "dreaded", + "dreadful", + "dreadlock", + "dreamboat", + "dreamily", + "dreamland", + "dreamless", + "dreamlike", + "dreamt", + "dreamy", + "drearily", + "dreary", + "drench", + "dress", + "drew", + "dribble", + "dried", + "drier", + "drift", + "driller", + "drilling", + "drinkable", + "drinking", + "dripping", + "drippy", + "drivable", + "driven", + "driver", + "driveway", + "driving", + "drizzle", + "drizzly", + "drone", + "drool", + "droop", + "drop-down", + "dropbox", + "dropkick", + "droplet", + "dropout", + "dropper", + "drove", + "drown", + "drowsily", + "drudge", + "drum", + "dry", + "dubbed", + "dubiously", + "duchess", + "duckbill", + "ducking", + "duckling", + "ducktail", + "ducky", + "duct", + "dude", + "duffel", + "dugout", + "duh", + "duke", + "duller", + "dullness", + "duly", + "dumping", + "dumpling", + "dumpster", + "duo", + "dupe", + "duplex", + "duplicate", + "duplicity", + "durable", + "durably", + "duration", + "duress", + "during", + "dusk", + "dust", + "dutiful", + "duty", + "duvet", + "dwarf", + "dweeb", + "dwelled", + "dweller", + "dwelling", + "dwindle", + "dwindling", + "dynamic", + "dynamite", + "dynasty", + "dyslexia", + "dyslexic", + "each", + "eagle", + "earache", + "eardrum", + "earflap", + "earful", + "earlobe", + "early", + "earmark", + "earmuff", + "earphone", + "earpiece", + "earplugs", + "earring", + "earshot", + "earthen", + "earthlike", + "earthling", + "earthly", + "earthworm", + "earthy", + "earwig", + "easeful", + "easel", + "easiest", + "easily", + "easiness", + "easing", + "eastbound", + "eastcoast", + "easter", + "eastward", + "eatable", + "eaten", + "eatery", + "eating", + "eats", + "ebay", + "ebony", + "ebook", + "ecard", + "eccentric", + "echo", + "eclair", + "eclipse", + "ecologist", + "ecology", + "economic", + "economist", + "economy", + "ecosphere", + "ecosystem", + "edge", + "edginess", + "edging", + "edgy", + "edition", + "editor", + "educated", + "education", + "educator", + "eel", + "effective", + "effects", + "efficient", + "effort", + "eggbeater", + "egging", + "eggnog", + "eggplant", + "eggshell", + "egomaniac", + "egotism", + "egotistic", + "either", + "eject", + "elaborate", + "elastic", + "elated", + "elbow", + "eldercare", + "elderly", + "eldest", + "electable", + "election", + "elective", + "elephant", + "elevate", + "elevating", + "elevation", + "elevator", + "eleven", + "elf", + "eligible", + "eligibly", + "eliminate", + "elite", + "elitism", + "elixir", + "elk", + "ellipse", + "elliptic", + "elm", + "elongated", + "elope", + "eloquence", + "eloquent", + "elsewhere", + "elude", + "elusive", + "elves", + "email", + "embargo", + "embark", + "embassy", + "embattled", + "embellish", + "ember", + "embezzle", + "emblaze", + "emblem", + "embody", + "embolism", + "emboss", + "embroider", + "emcee", + "emerald", + "emergency", + "emission", + "emit", + "emote", + "emoticon", + "emotion", + "empathic", + "empathy", + "emperor", + "emphases", + "emphasis", + "emphasize", + "emphatic", + "empirical", + "employed", + "employee", + "employer", + "emporium", + "empower", + "emptier", + "emptiness", + "empty", + "emu", + "enable", + "enactment", + "enamel", + "enchanted", + "enchilada", + "encircle", + "enclose", + "enclosure", + "encode", + "encore", + "encounter", + "encourage", + "encroach", + "encrust", + "encrypt", + "endanger", + "endeared", + "endearing", + "ended", + "ending", + "endless", + "endnote", + "endocrine", + "endorphin", + "endorse", + "endowment", + "endpoint", + "endurable", + "endurance", + "enduring", + "energetic", + "energize", + "energy", + "enforced", + "enforcer", + "engaged", + "engaging", + "engine", + "engorge", + "engraved", + "engraver", + "engraving", + "engross", + "engulf", + "enhance", + "enigmatic", + "enjoyable", + "enjoyably", + "enjoyer", + "enjoying", + "enjoyment", + "enlarged", + "enlarging", + "enlighten", + "enlisted", + "enquirer", + "enrage", + "enrich", + "enroll", + "enslave", + "ensnare", + "ensure", + "entail", + "entangled", + "entering", + "entertain", + "enticing", + "entire", + "entitle", + "entity", + "entomb", + "entourage", + "entrap", + "entree", + "entrench", + "entrust", + "entryway", + "entwine", + "enunciate", + "envelope", + "enviable", + "enviably", + "envious", + "envision", + "envoy", + "envy", + "enzyme", + "epic", + "epidemic", + "epidermal", + "epidermis", + "epidural", + "epilepsy", + "epileptic", + "epilogue", + "epiphany", + "episode", + "equal", + "equate", + "equation", + "equator", + "equinox", + "equipment", + "equity", + "equivocal", + "eradicate", + "erasable", + "erased", + "eraser", + "erasure", + "ergonomic", + "errand", + "errant", + "erratic", + "error", + "erupt", + "escalate", + "escalator", + "escapable", + "escapade", + "escapist", + "escargot", + "eskimo", + "esophagus", + "espionage", + "espresso", + "esquire", + "essay", + "essence", + "essential", + "establish", + "estate", + "esteemed", + "estimate", + "estimator", + "estranged", + "estrogen", + "etching", + "eternal", + "eternity", + "ethanol", + "ether", + "ethically", + "ethics", + "euphemism", + "evacuate", + "evacuee", + "evade", + "evaluate", + "evaluator", + "evaporate", + "evasion", + "evasive", + "even", + "everglade", + "evergreen", + "everybody", + "everyday", + "everyone", + "evict", + "evidence", + "evident", + "evil", + "evoke", + "evolution", + "evolve", + "exact", + "exalted", + "example", + "excavate", + "excavator", + "exceeding", + "exception", + "excess", + "exchange", + "excitable", + "exciting", + "exclaim", + "exclude", + "excluding", + "exclusion", + "exclusive", + "excretion", + "excretory", + "excursion", + "excusable", + "excusably", + "excuse", + "exemplary", + "exemplify", + "exemption", + "exerciser", + "exert", + "exes", + "exfoliate", + "exhale", + "exhaust", + "exhume", + "exile", + "existing", + "exit", + "exodus", + "exonerate", + "exorcism", + "exorcist", + "expand", + "expanse", + "expansion", + "expansive", + "expectant", + "expedited", + "expediter", + "expel", + "expend", + "expenses", + "expensive", + "expert", + "expire", + "expiring", + "explain", + "expletive", + "explicit", + "explode", + "exploit", + "explore", + "exploring", + "exponent", + "exporter", + "exposable", + "expose", + "exposure", + "express", + "expulsion", + "exquisite", + "extended", + "extending", + "extent", + "extenuate", + "exterior", + "external", + "extinct", + "extortion", + "extradite", + "extras", + "extrovert", + "extrude", + "extruding", + "exuberant", + "fable", + "fabric", + "fabulous", + "facebook", + "facecloth", + "facedown", + "faceless", + "facelift", + "faceplate", + "faceted", + "facial", + "facility", + "facing", + "facsimile", + "faction", + "factoid", + "factor", + "factsheet", + "factual", + "faculty", + "fade", + "fading", + "failing", + "falcon", + "fall", + "false", + "falsify", + "fame", + "familiar", + "family", + "famine", + "famished", + "fanatic", + "fancied", + "fanciness", + "fancy", + "fanfare", + "fang", + "fanning", + "fantasize", + "fantastic", + "fantasy", + "fascism", + "fastball", + "faster", + "fasting", + "fastness", + "faucet", + "favorable", + "favorably", + "favored", + "favoring", + "favorite", + "fax", + "feast", + "federal", + "fedora", + "feeble", + "feed", + "feel", + "feisty", + "feline", + "felt-tip", + "feminine", + "feminism", + "feminist", + "feminize", + "femur", + "fence", + "fencing", + "fender", + "ferment", + "fernlike", + "ferocious", + "ferocity", + "ferret", + "ferris", + "ferry", + "fervor", + "fester", + "festival", + "festive", + "festivity", + "fetal", + "fetch", + "fever", + "fiber", + "fiction", + "fiddle", + "fiddling", + "fidelity", + "fidgeting", + "fidgety", + "fifteen", + "fifth", + "fiftieth", + "fifty", + "figment", + "figure", + "figurine", + "filing", + "filled", + "filler", + "filling", + "film", + "filter", + "filth", + "filtrate", + "finale", + "finalist", + "finalize", + "finally", + "finance", + "financial", + "finch", + "fineness", + "finer", + "finicky", + "finished", + "finisher", + "finishing", + "finite", + "finless", + "finlike", + "fiscally", + "fit", + "five", + "flaccid", + "flagman", + "flagpole", + "flagship", + "flagstick", + "flagstone", + "flail", + "flakily", + "flaky", + "flame", + "flammable", + "flanked", + "flanking", + "flannels", + "flap", + "flaring", + "flashback", + "flashbulb", + "flashcard", + "flashily", + "flashing", + "flashy", + "flask", + "flatbed", + "flatfoot", + "flatly", + "flatness", + "flatten", + "flattered", + "flatterer", + "flattery", + "flattop", + "flatware", + "flatworm", + "flavored", + "flavorful", + "flavoring", + "flaxseed", + "fled", + "fleshed", + "fleshy", + "flick", + "flier", + "flight", + "flinch", + "fling", + "flint", + "flip", + "flirt", + "float", + "flock", + "flogging", + "flop", + "floral", + "florist", + "floss", + "flounder", + "flyable", + "flyaway", + "flyer", + "flying", + "flyover", + "flypaper", + "foam", + "foe", + "fog", + "foil", + "folic", + "folk", + "follicle", + "follow", + "fondling", + "fondly", + "fondness", + "fondue", + "font", + "food", + "fool", + "footage", + "football", + "footbath", + "footboard", + "footer", + "footgear", + "foothill", + "foothold", + "footing", + "footless", + "footman", + "footnote", + "footpad", + "footpath", + "footprint", + "footrest", + "footsie", + "footsore", + "footwear", + "footwork", + "fossil", + "foster", + "founder", + "founding", + "fountain", + "fox", + "foyer", + "fraction", + "fracture", + "fragile", + "fragility", + "fragment", + "fragrance", + "fragrant", + "frail", + "frame", + "framing", + "frantic", + "fraternal", + "frayed", + "fraying", + "frays", + "freckled", + "freckles", + "freebase", + "freebee", + "freebie", + "freedom", + "freefall", + "freehand", + "freeing", + "freeload", + "freely", + "freemason", + "freeness", + "freestyle", + "freeware", + "freeway", + "freewill", + "freezable", + "freezing", + "freight", + "french", + "frenzied", + "frenzy", + "frequency", + "frequent", + "fresh", + "fretful", + "fretted", + "friction", + "friday", + "fridge", + "fried", + "friend", + "frighten", + "frightful", + "frigidity", + "frigidly", + "frill", + "fringe", + "frisbee", + "frisk", + "fritter", + "frivolous", + "frolic", + "from", + "front", + "frostbite", + "frosted", + "frostily", + "frosting", + "frostlike", + "frosty", + "froth", + "frown", + "frozen", + "fructose", + "frugality", + "frugally", + "fruit", + "frustrate", + "frying", + "gab", + "gaffe", + "gag", + "gainfully", + "gaining", + "gains", + "gala", + "gallantly", + "galleria", + "gallery", + "galley", + "gallon", + "gallows", + "gallstone", + "galore", + "galvanize", + "gambling", + "game", + "gaming", + "gamma", + "gander", + "gangly", + "gangrene", + "gangway", + "gap", + "garage", + "garbage", + "garden", + "gargle", + "garland", + "garlic", + "garment", + "garnet", + "garnish", + "garter", + "gas", + "gatherer", + "gathering", + "gating", + "gauging", + "gauntlet", + "gauze", + "gave", + "gawk", + "gazing", + "gear", + "gecko", + "geek", + "geiger", + "gem", + "gender", + "generic", + "generous", + "genetics", + "genre", + "gentile", + "gentleman", + "gently", + "gents", + "geography", + "geologic", + "geologist", + "geology", + "geometric", + "geometry", + "geranium", + "gerbil", + "geriatric", + "germicide", + "germinate", + "germless", + "germproof", + "gestate", + "gestation", + "gesture", + "getaway", + "getting", + "getup", + "giant", + "gibberish", + "giblet", + "giddily", + "giddiness", + "giddy", + "gift", + "gigabyte", + "gigahertz", + "gigantic", + "giggle", + "giggling", + "giggly", + "gigolo", + "gilled", + "gills", + "gimmick", + "girdle", + "giveaway", + "given", + "giver", + "giving", + "gizmo", + "gizzard", + "glacial", + "glacier", + "glade", + "gladiator", + "gladly", + "glamorous", + "glamour", + "glance", + "glancing", + "glandular", + "glare", + "glaring", + "glass", + "glaucoma", + "glazing", + "gleaming", + "gleeful", + "glider", + "gliding", + "glimmer", + "glimpse", + "glisten", + "glitch", + "glitter", + "glitzy", + "gloater", + "gloating", + "gloomily", + "gloomy", + "glorified", + "glorifier", + "glorify", + "glorious", + "glory", + "gloss", + "glove", + "glowing", + "glowworm", + "glucose", + "glue", + "gluten", + "glutinous", + "glutton", + "gnarly", + "gnat", + "goal", + "goatskin", + "goes", + "goggles", + "going", + "goldfish", + "goldmine", + "goldsmith", + "golf", + "goliath", + "gonad", + "gondola", + "gone", + "gong", + "good", + "gooey", + "goofball", + "goofiness", + "goofy", + "google", + "goon", + "gopher", + "gore", + "gorged", + "gorgeous", + "gory", + "gosling", + "gossip", + "gothic", + "gotten", + "gout", + "gown", + "grab", + "graceful", + "graceless", + "gracious", + "gradation", + "graded", + "grader", + "gradient", + "grading", + "gradually", + "graduate", + "graffiti", + "grafted", + "grafting", + "grain", + "granddad", + "grandkid", + "grandly", + "grandma", + "grandpa", + "grandson", + "granite", + "granny", + "granola", + "grant", + "granular", + "grape", + "graph", + "grapple", + "grappling", + "grasp", + "grass", + "gratified", + "gratify", + "grating", + "gratitude", + "gratuity", + "gravel", + "graveness", + "graves", + "graveyard", + "gravitate", + "gravity", + "gravy", + "gray", + "grazing", + "greasily", + "greedily", + "greedless", + "greedy", + "green", + "greeter", + "greeting", + "grew", + "greyhound", + "grid", + "grief", + "grievance", + "grieving", + "grievous", + "grill", + "grimace", + "grimacing", + "grime", + "griminess", + "grimy", + "grinch", + "grinning", + "grip", + "gristle", + "grit", + "groggily", + "groggy", + "groin", + "groom", + "groove", + "grooving", + "groovy", + "grope", + "ground", + "grouped", + "grout", + "grove", + "grower", + "growing", + "growl", + "grub", + "grudge", + "grudging", + "grueling", + "gruffly", + "grumble", + "grumbling", + "grumbly", + "grumpily", + "grunge", + "grunt", + "guacamole", + "guidable", + "guidance", + "guide", + "guiding", + "guileless", + "guise", + "gulf", + "gullible", + "gully", + "gulp", + "gumball", + "gumdrop", + "gumminess", + "gumming", + "gummy", + "gurgle", + "gurgling", + "guru", + "gush", + "gusto", + "gusty", + "gutless", + "guts", + "gutter", + "guy", + "guzzler", + "gyration", + "habitable", + "habitant", + "habitat", + "habitual", + "hacked", + "hacker", + "hacking", + "hacksaw", + "had", + "haggler", + "haiku", + "half", + "halogen", + "halt", + "halved", + "halves", + "hamburger", + "hamlet", + "hammock", + "hamper", + "hamster", + "hamstring", + "handbag", + "handball", + "handbook", + "handbrake", + "handcart", + "handclap", + "handclasp", + "handcraft", + "handcuff", + "handed", + "handful", + "handgrip", + "handgun", + "handheld", + "handiness", + "handiwork", + "handlebar", + "handled", + "handler", + "handling", + "handmade", + "handoff", + "handpick", + "handprint", + "handrail", + "handsaw", + "handset", + "handsfree", + "handshake", + "handstand", + "handwash", + "handwork", + "handwoven", + "handwrite", + "handyman", + "hangnail", + "hangout", + "hangover", + "hangup", + "hankering", + "hankie", + "hanky", + "haphazard", + "happening", + "happier", + "happiest", + "happily", + "happiness", + "happy", + "harbor", + "hardcopy", + "hardcore", + "hardcover", + "harddisk", + "hardened", + "hardener", + "hardening", + "hardhat", + "hardhead", + "hardiness", + "hardly", + "hardness", + "hardship", + "hardware", + "hardwired", + "hardwood", + "hardy", + "harmful", + "harmless", + "harmonica", + "harmonics", + "harmonize", + "harmony", + "harness", + "harpist", + "harsh", + "harvest", + "hash", + "hassle", + "haste", + "hastily", + "hastiness", + "hasty", + "hatbox", + "hatchback", + "hatchery", + "hatchet", + "hatching", + "hatchling", + "hate", + "hatless", + "hatred", + "haunt", + "haven", + "hazard", + "hazelnut", + "hazily", + "haziness", + "hazing", + "hazy", + "headache", + "headband", + "headboard", + "headcount", + "headdress", + "headed", + "header", + "headfirst", + "headgear", + "heading", + "headlamp", + "headless", + "headlock", + "headphone", + "headpiece", + "headrest", + "headroom", + "headscarf", + "headset", + "headsman", + "headstand", + "headstone", + "headway", + "headwear", + "heap", + "heat", + "heave", + "heavily", + "heaviness", + "heaving", + "hedge", + "hedging", + "heftiness", + "hefty", + "helium", + "helmet", + "helper", + "helpful", + "helping", + "helpless", + "helpline", + "hemlock", + "hemstitch", + "hence", + "henchman", + "henna", + "herald", + "herbal", + "herbicide", + "herbs", + "heritage", + "hermit", + "heroics", + "heroism", + "herring", + "herself", + "hertz", + "hesitancy", + "hesitant", + "hesitate", + "hexagon", + "hexagram", + "hubcap", + "huddle", + "huddling", + "huff", + "hug", + "hula", + "hulk", + "hull", + "human", + "humble", + "humbling", + "humbly", + "humid", + "humiliate", + "humility", + "humming", + "hummus", + "humongous", + "humorist", + "humorless", + "humorous", + "humpback", + "humped", + "humvee", + "hunchback", + "hundredth", + "hunger", + "hungrily", + "hungry", + "hunk", + "hunter", + "hunting", + "huntress", + "huntsman", + "hurdle", + "hurled", + "hurler", + "hurling", + "hurray", + "hurricane", + "hurried", + "hurry", + "hurt", + "husband", + "hush", + "husked", + "huskiness", + "hut", + "hybrid", + "hydrant", + "hydrated", + "hydration", + "hydrogen", + "hydroxide", + "hyperlink", + "hypertext", + "hyphen", + "hypnoses", + "hypnosis", + "hypnotic", + "hypnotism", + "hypnotist", + "hypnotize", + "hypocrisy", + "hypocrite", + "ibuprofen", + "ice", + "iciness", + "icing", + "icky", + "icon", + "icy", + "idealism", + "idealist", + "idealize", + "ideally", + "idealness", + "identical", + "identify", + "identity", + "ideology", + "idiocy", + "idiom", + "idly", + "igloo", + "ignition", + "ignore", + "iguana", + "illicitly", + "illusion", + "illusive", + "image", + "imaginary", + "imagines", + "imaging", + "imbecile", + "imitate", + "imitation", + "immature", + "immerse", + "immersion", + "imminent", + "immobile", + "immodest", + "immorally", + "immortal", + "immovable", + "immovably", + "immunity", + "immunize", + "impaired", + "impale", + "impart", + "impatient", + "impeach", + "impeding", + "impending", + "imperfect", + "imperial", + "impish", + "implant", + "implement", + "implicate", + "implicit", + "implode", + "implosion", + "implosive", + "imply", + "impolite", + "important", + "importer", + "impose", + "imposing", + "impotence", + "impotency", + "impotent", + "impound", + "imprecise", + "imprint", + "imprison", + "impromptu", + "improper", + "improve", + "improving", + "improvise", + "imprudent", + "impulse", + "impulsive", + "impure", + "impurity", + "iodine", + "iodize", + "ion", + "ipad", + "iphone", + "ipod", + "irate", + "irk", + "iron", + "irregular", + "irrigate", + "irritable", + "irritably", + "irritant", + "irritate", + "islamic", + "islamist", + "isolated", + "isolating", + "isolation", + "isotope", + "issue", + "issuing", + "italicize", + "italics", + "item", + "itinerary", + "itunes", + "ivory", + "ivy", + "jab", + "jackal", + "jacket", + "jackknife", + "jackpot", + "jailbird", + "jailbreak", + "jailer", + "jailhouse", + "jalapeno", + "jam", + "janitor", + "january", + "jargon", + "jarring", + "jasmine", + "jaundice", + "jaunt", + "java", + "jawed", + "jawless", + "jawline", + "jaws", + "jaybird", + "jaywalker", + "jazz", + "jeep", + "jeeringly", + "jellied", + "jelly", + "jersey", + "jester", + "jet", + "jiffy", + "jigsaw", + "jimmy", + "jingle", + "jingling", + "jinx", + "jitters", + "jittery", + "job", + "jockey", + "jockstrap", + "jogger", + "jogging", + "john", + "joining", + "jokester", + "jokingly", + "jolliness", + "jolly", + "jolt", + "jot", + "jovial", + "joyfully", + "joylessly", + "joyous", + "joyride", + "joystick", + "jubilance", + "jubilant", + "judge", + "judgingly", + "judicial", + "judiciary", + "judo", + "juggle", + "juggling", + "jugular", + "juice", + "juiciness", + "juicy", + "jujitsu", + "jukebox", + "july", + "jumble", + "jumbo", + "jump", + "junction", + "juncture", + "june", + "junior", + "juniper", + "junkie", + "junkman", + "junkyard", + "jurist", + "juror", + "jury", + "justice", + "justifier", + "justify", + "justly", + "justness", + "juvenile", + "kabob", + "kangaroo", + "karaoke", + "karate", + "karma", + "kebab", + "keenly", + "keenness", + "keep", + "keg", + "kelp", + "kennel", + "kept", + "kerchief", + "kerosene", + "kettle", + "kick", + "kiln", + "kilobyte", + "kilogram", + "kilometer", + "kilowatt", + "kilt", + "kimono", + "kindle", + "kindling", + "kindly", + "kindness", + "kindred", + "kinetic", + "kinfolk", + "king", + "kinship", + "kinsman", + "kinswoman", + "kissable", + "kisser", + "kissing", + "kitchen", + "kite", + "kitten", + "kitty", + "kiwi", + "kleenex", + "knapsack", + "knee", + "knelt", + "knickers", + "knoll", + "koala", + "kooky", + "kosher", + "krypton", + "kudos", + "kung", + "labored", + "laborer", + "laboring", + "laborious", + "labrador", + "ladder", + "ladies", + "ladle", + "ladybug", + "ladylike", + "lagged", + "lagging", + "lagoon", + "lair", + "lake", + "lance", + "landed", + "landfall", + "landfill", + "landing", + "landlady", + "landless", + "landline", + "landlord", + "landmark", + "landmass", + "landmine", + "landowner", + "landscape", + "landside", + "landslide", + "language", + "lankiness", + "lanky", + "lantern", + "lapdog", + "lapel", + "lapped", + "lapping", + "laptop", + "lard", + "large", + "lark", + "lash", + "lasso", + "last", + "latch", + "late", + "lather", + "latitude", + "latrine", + "latter", + "latticed", + "launch", + "launder", + "laundry", + "laurel", + "lavender", + "lavish", + "laxative", + "lazily", + "laziness", + "lazy", + "lecturer", + "left", + "legacy", + "legal", + "legend", + "legged", + "leggings", + "legible", + "legibly", + "legislate", + "lego", + "legroom", + "legume", + "legwarmer", + "legwork", + "lemon", + "lend", + "length", + "lens", + "lent", + "leotard", + "lesser", + "letdown", + "lethargic", + "lethargy", + "letter", + "lettuce", + "level", + "leverage", + "levers", + "levitate", + "levitator", + "liability", + "liable", + "liberty", + "librarian", + "library", + "licking", + "licorice", + "lid", + "life", + "lifter", + "lifting", + "liftoff", + "ligament", + "likely", + "likeness", + "likewise", + "liking", + "lilac", + "lilly", + "lily", + "limb", + "limeade", + "limelight", + "limes", + "limit", + "limping", + "limpness", + "line", + "lingo", + "linguini", + "linguist", + "lining", + "linked", + "linoleum", + "linseed", + "lint", + "lion", + "lip", + "liquefy", + "liqueur", + "liquid", + "lisp", + "list", + "litigate", + "litigator", + "litmus", + "litter", + "little", + "livable", + "lived", + "lively", + "liver", + "livestock", + "lividly", + "living", + "lizard", + "lubricant", + "lubricate", + "lucid", + "luckily", + "luckiness", + "luckless", + "lucrative", + "ludicrous", + "lugged", + "lukewarm", + "lullaby", + "lumber", + "luminance", + "luminous", + "lumpiness", + "lumping", + "lumpish", + "lunacy", + "lunar", + "lunchbox", + "luncheon", + "lunchroom", + "lunchtime", + "lung", + "lurch", + "lure", + "luridness", + "lurk", + "lushly", + "lushness", + "luster", + "lustfully", + "lustily", + "lustiness", + "lustrous", + "lusty", + "luxurious", + "luxury", + "lying", + "lyrically", + "lyricism", + "lyricist", + "lyrics", + "macarena", + "macaroni", + "macaw", + "mace", + "machine", + "machinist", + "magazine", + "magenta", + "maggot", + "magical", + "magician", + "magma", + "magnesium", + "magnetic", + "magnetism", + "magnetize", + "magnifier", + "magnify", + "magnitude", + "magnolia", + "mahogany", + "maimed", + "majestic", + "majesty", + "majorette", + "majority", + "makeover", + "maker", + "makeshift", + "making", + "malformed", + "malt", + "mama", + "mammal", + "mammary", + "mammogram", + "manager", + "managing", + "manatee", + "mandarin", + "mandate", + "mandatory", + "mandolin", + "manger", + "mangle", + "mango", + "mangy", + "manhandle", + "manhole", + "manhood", + "manhunt", + "manicotti", + "manicure", + "manifesto", + "manila", + "mankind", + "manlike", + "manliness", + "manly", + "manmade", + "manned", + "mannish", + "manor", + "manpower", + "mantis", + "mantra", + "manual", + "many", + "map", + "marathon", + "marauding", + "marbled", + "marbles", + "marbling", + "march", + "mardi", + "margarine", + "margarita", + "margin", + "marigold", + "marina", + "marine", + "marital", + "maritime", + "marlin", + "marmalade", + "maroon", + "married", + "marrow", + "marry", + "marshland", + "marshy", + "marsupial", + "marvelous", + "marxism", + "mascot", + "masculine", + "mashed", + "mashing", + "massager", + "masses", + "massive", + "mastiff", + "matador", + "matchbook", + "matchbox", + "matcher", + "matching", + "matchless", + "material", + "maternal", + "maternity", + "math", + "mating", + "matriarch", + "matrimony", + "matrix", + "matron", + "matted", + "matter", + "maturely", + "maturing", + "maturity", + "mauve", + "maverick", + "maximize", + "maximum", + "maybe", + "mayday", + "mayflower", + "moaner", + "moaning", + "mobile", + "mobility", + "mobilize", + "mobster", + "mocha", + "mocker", + "mockup", + "modified", + "modify", + "modular", + "modulator", + "module", + "moisten", + "moistness", + "moisture", + "molar", + "molasses", + "mold", + "molecular", + "molecule", + "molehill", + "mollusk", + "mom", + "monastery", + "monday", + "monetary", + "monetize", + "moneybags", + "moneyless", + "moneywise", + "mongoose", + "mongrel", + "monitor", + "monkhood", + "monogamy", + "monogram", + "monologue", + "monopoly", + "monorail", + "monotone", + "monotype", + "monoxide", + "monsieur", + "monsoon", + "monstrous", + "monthly", + "monument", + "moocher", + "moodiness", + "moody", + "mooing", + "moonbeam", + "mooned", + "moonlight", + "moonlike", + "moonlit", + "moonrise", + "moonscape", + "moonshine", + "moonstone", + "moonwalk", + "mop", + "morale", + "morality", + "morally", + "morbidity", + "morbidly", + "morphine", + "morphing", + "morse", + "mortality", + "mortally", + "mortician", + "mortified", + "mortify", + "mortuary", + "mosaic", + "mossy", + "most", + "mothball", + "mothproof", + "motion", + "motivate", + "motivator", + "motive", + "motocross", + "motor", + "motto", + "mountable", + "mountain", + "mounted", + "mounting", + "mourner", + "mournful", + "mouse", + "mousiness", + "moustache", + "mousy", + "mouth", + "movable", + "move", + "movie", + "moving", + "mower", + "mowing", + "much", + "muck", + "mud", + "mug", + "mulberry", + "mulch", + "mule", + "mulled", + "mullets", + "multiple", + "multiply", + "multitask", + "multitude", + "mumble", + "mumbling", + "mumbo", + "mummified", + "mummify", + "mummy", + "mumps", + "munchkin", + "mundane", + "municipal", + "muppet", + "mural", + "murkiness", + "murky", + "murmuring", + "muscular", + "museum", + "mushily", + "mushiness", + "mushroom", + "mushy", + "music", + "musket", + "muskiness", + "musky", + "mustang", + "mustard", + "muster", + "mustiness", + "musty", + "mutable", + "mutate", + "mutation", + "mute", + "mutilated", + "mutilator", + "mutiny", + "mutt", + "mutual", + "muzzle", + "myself", + "myspace", + "mystified", + "mystify", + "myth", + "nacho", + "nag", + "nail", + "name", + "naming", + "nanny", + "nanometer", + "nape", + "napkin", + "napped", + "napping", + "nappy", + "narrow", + "nastily", + "nastiness", + "national", + "native", + "nativity", + "natural", + "nature", + "naturist", + "nautical", + "navigate", + "navigator", + "navy", + "nearby", + "nearest", + "nearly", + "nearness", + "neatly", + "neatness", + "nebula", + "nebulizer", + "nectar", + "negate", + "negation", + "negative", + "neglector", + "negligee", + "negligent", + "negotiate", + "nemeses", + "nemesis", + "neon", + "nephew", + "nerd", + "nervous", + "nervy", + "nest", + "net", + "neurology", + "neuron", + "neurosis", + "neurotic", + "neuter", + "neutron", + "never", + "next", + "nibble", + "nickname", + "nicotine", + "niece", + "nifty", + "nimble", + "nimbly", + "nineteen", + "ninetieth", + "ninja", + "nintendo", + "ninth", + "nuclear", + "nuclei", + "nucleus", + "nugget", + "nullify", + "number", + "numbing", + "numbly", + "numbness", + "numeral", + "numerate", + "numerator", + "numeric", + "numerous", + "nuptials", + "nursery", + "nursing", + "nurture", + "nutcase", + "nutlike", + "nutmeg", + "nutrient", + "nutshell", + "nuttiness", + "nutty", + "nuzzle", + "nylon", + "oaf", + "oak", + "oasis", + "oat", + "obedience", + "obedient", + "obituary", + "object", + "obligate", + "obliged", + "oblivion", + "oblivious", + "oblong", + "obnoxious", + "oboe", + "obscure", + "obscurity", + "observant", + "observer", + "observing", + "obsessed", + "obsession", + "obsessive", + "obsolete", + "obstacle", + "obstinate", + "obstruct", + "obtain", + "obtrusive", + "obtuse", + "obvious", + "occultist", + "occupancy", + "occupant", + "occupier", + "occupy", + "ocean", + "ocelot", + "octagon", + "octane", + "october", + "octopus", + "ogle", + "oil", + "oink", + "ointment", + "okay", + "old", + "olive", + "olympics", + "omega", + "omen", + "ominous", + "omission", + "omit", + "omnivore", + "onboard", + "oncoming", + "ongoing", + "onion", + "online", + "onlooker", + "only", + "onscreen", + "onset", + "onshore", + "onslaught", + "onstage", + "onto", + "onward", + "onyx", + "oops", + "ooze", + "oozy", + "opacity", + "opal", + "open", + "operable", + "operate", + "operating", + "operation", + "operative", + "operator", + "opium", + "opossum", + "opponent", + "oppose", + "opposing", + "opposite", + "oppressed", + "oppressor", + "opt", + "opulently", + "osmosis", + "other", + "otter", + "ouch", + "ought", + "ounce", + "outage", + "outback", + "outbid", + "outboard", + "outbound", + "outbreak", + "outburst", + "outcast", + "outclass", + "outcome", + "outdated", + "outdoors", + "outer", + "outfield", + "outfit", + "outflank", + "outgoing", + "outgrow", + "outhouse", + "outing", + "outlast", + "outlet", + "outline", + "outlook", + "outlying", + "outmatch", + "outmost", + "outnumber", + "outplayed", + "outpost", + "outpour", + "output", + "outrage", + "outrank", + "outreach", + "outright", + "outscore", + "outsell", + "outshine", + "outshoot", + "outsider", + "outskirts", + "outsmart", + "outsource", + "outspoken", + "outtakes", + "outthink", + "outward", + "outweigh", + "outwit", + "oval", + "ovary", + "oven", + "overact", + "overall", + "overarch", + "overbid", + "overbill", + "overbite", + "overblown", + "overboard", + "overbook", + "overbuilt", + "overcast", + "overcoat", + "overcome", + "overcook", + "overcrowd", + "overdraft", + "overdrawn", + "overdress", + "overdrive", + "overdue", + "overeager", + "overeater", + "overexert", + "overfed", + "overfeed", + "overfill", + "overflow", + "overfull", + "overgrown", + "overhand", + "overhang", + "overhaul", + "overhead", + "overhear", + "overheat", + "overhung", + "overjoyed", + "overkill", + "overlabor", + "overlaid", + "overlap", + "overlay", + "overload", + "overlook", + "overlord", + "overlying", + "overnight", + "overpass", + "overpay", + "overplant", + "overplay", + "overpower", + "overprice", + "overrate", + "overreach", + "overreact", + "override", + "overripe", + "overrule", + "overrun", + "overshoot", + "overshot", + "oversight", + "oversized", + "oversleep", + "oversold", + "overspend", + "overstate", + "overstay", + "overstep", + "overstock", + "overstuff", + "oversweet", + "overtake", + "overthrow", + "overtime", + "overtly", + "overtone", + "overture", + "overturn", + "overuse", + "overvalue", + "overview", + "overwrite", + "owl", + "oxford", + "oxidant", + "oxidation", + "oxidize", + "oxidizing", + "oxygen", + "oxymoron", + "oyster", + "ozone", + "paced", + "pacemaker", + "pacific", + "pacifier", + "pacifism", + "pacifist", + "pacify", + "padded", + "padding", + "paddle", + "paddling", + "padlock", + "pagan", + "pager", + "paging", + "pajamas", + "palace", + "palatable", + "palm", + "palpable", + "palpitate", + "paltry", + "pampered", + "pamperer", + "pampers", + "pamphlet", + "panama", + "pancake", + "pancreas", + "panda", + "pandemic", + "pang", + "panhandle", + "panic", + "panning", + "panorama", + "panoramic", + "panther", + "pantomime", + "pantry", + "pants", + "pantyhose", + "paparazzi", + "papaya", + "paper", + "paprika", + "papyrus", + "parabola", + "parachute", + "parade", + "paradox", + "paragraph", + "parakeet", + "paralegal", + "paralyses", + "paralysis", + "paralyze", + "paramedic", + "parameter", + "paramount", + "parasail", + "parasite", + "parasitic", + "parcel", + "parched", + "parchment", + "pardon", + "parish", + "parka", + "parking", + "parkway", + "parlor", + "parmesan", + "parole", + "parrot", + "parsley", + "parsnip", + "partake", + "parted", + "parting", + "partition", + "partly", + "partner", + "partridge", + "party", + "passable", + "passably", + "passage", + "passcode", + "passenger", + "passerby", + "passing", + "passion", + "passive", + "passivism", + "passover", + "passport", + "password", + "pasta", + "pasted", + "pastel", + "pastime", + "pastor", + "pastrami", + "pasture", + "pasty", + "patchwork", + "patchy", + "paternal", + "paternity", + "path", + "patience", + "patient", + "patio", + "patriarch", + "patriot", + "patrol", + "patronage", + "patronize", + "pauper", + "pavement", + "paver", + "pavestone", + "pavilion", + "paving", + "pawing", + "payable", + "payback", + "paycheck", + "payday", + "payee", + "payer", + "paying", + "payment", + "payphone", + "payroll", + "pebble", + "pebbly", + "pecan", + "pectin", + "peculiar", + "peddling", + "pediatric", + "pedicure", + "pedigree", + "pedometer", + "pegboard", + "pelican", + "pellet", + "pelt", + "pelvis", + "penalize", + "penalty", + "pencil", + "pendant", + "pending", + "penholder", + "penknife", + "pennant", + "penniless", + "penny", + "penpal", + "pension", + "pentagon", + "pentagram", + "pep", + "perceive", + "percent", + "perch", + "percolate", + "perennial", + "perfected", + "perfectly", + "perfume", + "periscope", + "perish", + "perjurer", + "perjury", + "perkiness", + "perky", + "perm", + "peroxide", + "perpetual", + "perplexed", + "persecute", + "persevere", + "persuaded", + "persuader", + "pesky", + "peso", + "pessimism", + "pessimist", + "pester", + "pesticide", + "petal", + "petite", + "petition", + "petri", + "petroleum", + "petted", + "petticoat", + "pettiness", + "petty", + "petunia", + "phantom", + "phobia", + "phoenix", + "phonebook", + "phoney", + "phonics", + "phoniness", + "phony", + "phosphate", + "photo", + "phrase", + "phrasing", + "placard", + "placate", + "placidly", + "plank", + "planner", + "plant", + "plasma", + "plaster", + "plastic", + "plated", + "platform", + "plating", + "platinum", + "platonic", + "platter", + "platypus", + "plausible", + "plausibly", + "playable", + "playback", + "player", + "playful", + "playgroup", + "playhouse", + "playing", + "playlist", + "playmaker", + "playmate", + "playoff", + "playpen", + "playroom", + "playset", + "plaything", + "playtime", + "plaza", + "pleading", + "pleat", + "pledge", + "plentiful", + "plenty", + "plethora", + "plexiglas", + "pliable", + "plod", + "plop", + "plot", + "plow", + "ploy", + "pluck", + "plug", + "plunder", + "plunging", + "plural", + "plus", + "plutonium", + "plywood", + "poach", + "pod", + "poem", + "poet", + "pogo", + "pointed", + "pointer", + "pointing", + "pointless", + "pointy", + "poise", + "poison", + "poker", + "poking", + "polar", + "police", + "policy", + "polio", + "polish", + "politely", + "polka", + "polo", + "polyester", + "polygon", + "polygraph", + "polymer", + "poncho", + "pond", + "pony", + "popcorn", + "pope", + "poplar", + "popper", + "poppy", + "popsicle", + "populace", + "popular", + "populate", + "porcupine", + "pork", + "porous", + "porridge", + "portable", + "portal", + "portfolio", + "porthole", + "portion", + "portly", + "portside", + "poser", + "posh", + "posing", + "possible", + "possibly", + "possum", + "postage", + "postal", + "postbox", + "postcard", + "posted", + "poster", + "posting", + "postnasal", + "posture", + "postwar", + "pouch", + "pounce", + "pouncing", + "pound", + "pouring", + "pout", + "powdered", + "powdering", + "powdery", + "power", + "powwow", + "pox", + "praising", + "prance", + "prancing", + "pranker", + "prankish", + "prankster", + "prayer", + "praying", + "preacher", + "preaching", + "preachy", + "preamble", + "precinct", + "precise", + "precision", + "precook", + "precut", + "predator", + "predefine", + "predict", + "preface", + "prefix", + "preflight", + "preformed", + "pregame", + "pregnancy", + "pregnant", + "preheated", + "prelaunch", + "prelaw", + "prelude", + "premiere", + "premises", + "premium", + "prenatal", + "preoccupy", + "preorder", + "prepaid", + "prepay", + "preplan", + "preppy", + "preschool", + "prescribe", + "preseason", + "preset", + "preshow", + "president", + "presoak", + "press", + "presume", + "presuming", + "preteen", + "pretended", + "pretender", + "pretense", + "pretext", + "pretty", + "pretzel", + "prevail", + "prevalent", + "prevent", + "preview", + "previous", + "prewar", + "prewashed", + "prideful", + "pried", + "primal", + "primarily", + "primary", + "primate", + "primer", + "primp", + "princess", + "print", + "prior", + "prism", + "prison", + "prissy", + "pristine", + "privacy", + "private", + "privatize", + "prize", + "proactive", + "probable", + "probably", + "probation", + "probe", + "probing", + "probiotic", + "problem", + "procedure", + "process", + "proclaim", + "procreate", + "procurer", + "prodigal", + "prodigy", + "produce", + "product", + "profane", + "profanity", + "professed", + "professor", + "profile", + "profound", + "profusely", + "progeny", + "prognosis", + "program", + "progress", + "projector", + "prologue", + "prolonged", + "promenade", + "prominent", + "promoter", + "promotion", + "prompter", + "promptly", + "prone", + "prong", + "pronounce", + "pronto", + "proofing", + "proofread", + "proofs", + "propeller", + "properly", + "property", + "proponent", + "proposal", + "propose", + "props", + "prorate", + "protector", + "protegee", + "proton", + "prototype", + "protozoan", + "protract", + "protrude", + "proud", + "provable", + "proved", + "proven", + "provided", + "provider", + "providing", + "province", + "proving", + "provoke", + "provoking", + "provolone", + "prowess", + "prowler", + "prowling", + "proximity", + "proxy", + "prozac", + "prude", + "prudishly", + "prune", + "pruning", + "pry", + "psychic", + "public", + "publisher", + "pucker", + "pueblo", + "pug", + "pull", + "pulmonary", + "pulp", + "pulsate", + "pulse", + "pulverize", + "puma", + "pumice", + "pummel", + "punch", + "punctual", + "punctuate", + "punctured", + "pungent", + "punisher", + "punk", + "pupil", + "puppet", + "puppy", + "purchase", + "pureblood", + "purebred", + "purely", + "pureness", + "purgatory", + "purge", + "purging", + "purifier", + "purify", + "purist", + "puritan", + "purity", + "purple", + "purplish", + "purposely", + "purr", + "purse", + "pursuable", + "pursuant", + "pursuit", + "purveyor", + "pushcart", + "pushchair", + "pusher", + "pushiness", + "pushing", + "pushover", + "pushpin", + "pushup", + "pushy", + "putdown", + "putt", + "puzzle", + "puzzling", + "pyramid", + "pyromania", + "python", + "quack", + "quadrant", + "quail", + "quaintly", + "quake", + "quaking", + "qualified", + "qualifier", + "qualify", + "quality", + "qualm", + "quantum", + "quarrel", + "quarry", + "quartered", + "quarterly", + "quarters", + "quartet", + "quench", + "query", + "quicken", + "quickly", + "quickness", + "quicksand", + "quickstep", + "quiet", + "quill", + "quilt", + "quintet", + "quintuple", + "quirk", + "quit", + "quiver", + "quizzical", + "quotable", + "quotation", + "quote", + "rabid", + "race", + "racing", + "racism", + "rack", + "racoon", + "radar", + "radial", + "radiance", + "radiantly", + "radiated", + "radiation", + "radiator", + "radio", + "radish", + "raffle", + "raft", + "rage", + "ragged", + "raging", + "ragweed", + "raider", + "railcar", + "railing", + "railroad", + "railway", + "raisin", + "rake", + "raking", + "rally", + "ramble", + "rambling", + "ramp", + "ramrod", + "ranch", + "rancidity", + "random", + "ranged", + "ranger", + "ranging", + "ranked", + "ranking", + "ransack", + "ranting", + "rants", + "rare", + "rarity", + "rascal", + "rash", + "rasping", + "ravage", + "raven", + "ravine", + "raving", + "ravioli", + "ravishing", + "reabsorb", + "reach", + "reacquire", + "reaction", + "reactive", + "reactor", + "reaffirm", + "ream", + "reanalyze", + "reappear", + "reapply", + "reappoint", + "reapprove", + "rearrange", + "rearview", + "reason", + "reassign", + "reassure", + "reattach", + "reawake", + "rebalance", + "rebate", + "rebel", + "rebirth", + "reboot", + "reborn", + "rebound", + "rebuff", + "rebuild", + "rebuilt", + "reburial", + "rebuttal", + "recall", + "recant", + "recapture", + "recast", + "recede", + "recent", + "recess", + "recharger", + "recipient", + "recital", + "recite", + "reckless", + "reclaim", + "recliner", + "reclining", + "recluse", + "reclusive", + "recognize", + "recoil", + "recollect", + "recolor", + "reconcile", + "reconfirm", + "reconvene", + "recopy", + "record", + "recount", + "recoup", + "recovery", + "recreate", + "rectal", + "rectangle", + "rectified", + "rectify", + "recycled", + "recycler", + "recycling", + "reemerge", + "reenact", + "reenter", + "reentry", + "reexamine", + "referable", + "referee", + "reference", + "refill", + "refinance", + "refined", + "refinery", + "refining", + "refinish", + "reflected", + "reflector", + "reflex", + "reflux", + "refocus", + "refold", + "reforest", + "reformat", + "reformed", + "reformer", + "reformist", + "refract", + "refrain", + "refreeze", + "refresh", + "refried", + "refueling", + "refund", + "refurbish", + "refurnish", + "refusal", + "refuse", + "refusing", + "refutable", + "refute", + "regain", + "regalia", + "regally", + "reggae", + "regime", + "region", + "register", + "registrar", + "registry", + "regress", + "regretful", + "regroup", + "regular", + "regulate", + "regulator", + "rehab", + "reheat", + "rehire", + "rehydrate", + "reimburse", + "reissue", + "reiterate", + "rejoice", + "rejoicing", + "rejoin", + "rekindle", + "relapse", + "relapsing", + "relatable", + "related", + "relation", + "relative", + "relax", + "relay", + "relearn", + "release", + "relenting", + "reliable", + "reliably", + "reliance", + "reliant", + "relic", + "relieve", + "relieving", + "relight", + "relish", + "relive", + "reload", + "relocate", + "relock", + "reluctant", + "rely", + "remake", + "remark", + "remarry", + "rematch", + "remedial", + "remedy", + "remember", + "reminder", + "remindful", + "remission", + "remix", + "remnant", + "remodeler", + "remold", + "remorse", + "remote", + "removable", + "removal", + "removed", + "remover", + "removing", + "rename", + "renderer", + "rendering", + "rendition", + "renegade", + "renewable", + "renewably", + "renewal", + "renewed", + "renounce", + "renovate", + "renovator", + "rentable", + "rental", + "rented", + "renter", + "reoccupy", + "reoccur", + "reopen", + "reorder", + "repackage", + "repacking", + "repaint", + "repair", + "repave", + "repaying", + "repayment", + "repeal", + "repeated", + "repeater", + "repent", + "rephrase", + "replace", + "replay", + "replica", + "reply", + "reporter", + "repose", + "repossess", + "repost", + "repressed", + "reprimand", + "reprint", + "reprise", + "reproach", + "reprocess", + "reproduce", + "reprogram", + "reps", + "reptile", + "reptilian", + "repugnant", + "repulsion", + "repulsive", + "repurpose", + "reputable", + "reputably", + "request", + "require", + "requisite", + "reroute", + "rerun", + "resale", + "resample", + "rescuer", + "reseal", + "research", + "reselect", + "reseller", + "resemble", + "resend", + "resent", + "reset", + "reshape", + "reshoot", + "reshuffle", + "residence", + "residency", + "resident", + "residual", + "residue", + "resigned", + "resilient", + "resistant", + "resisting", + "resize", + "resolute", + "resolved", + "resonant", + "resonate", + "resort", + "resource", + "respect", + "resubmit", + "result", + "resume", + "resupply", + "resurface", + "resurrect", + "retail", + "retainer", + "retaining", + "retake", + "retaliate", + "retention", + "rethink", + "retinal", + "retired", + "retiree", + "retiring", + "retold", + "retool", + "retorted", + "retouch", + "retrace", + "retract", + "retrain", + "retread", + "retreat", + "retrial", + "retrieval", + "retriever", + "retry", + "return", + "retying", + "retype", + "reunion", + "reunite", + "reusable", + "reuse", + "reveal", + "reveler", + "revenge", + "revenue", + "reverb", + "revered", + "reverence", + "reverend", + "reversal", + "reverse", + "reversing", + "reversion", + "revert", + "revisable", + "revise", + "revision", + "revisit", + "revivable", + "revival", + "reviver", + "reviving", + "revocable", + "revoke", + "revolt", + "revolver", + "revolving", + "reward", + "rewash", + "rewind", + "rewire", + "reword", + "rework", + "rewrap", + "rewrite", + "rhyme", + "ribbon", + "ribcage", + "rice", + "riches", + "richly", + "richness", + "rickety", + "ricotta", + "riddance", + "ridden", + "ride", + "riding", + "rifling", + "rift", + "rigging", + "rigid", + "rigor", + "rimless", + "rimmed", + "rind", + "rink", + "rinse", + "rinsing", + "riot", + "ripcord", + "ripeness", + "ripening", + "ripping", + "ripple", + "rippling", + "riptide", + "rise", + "rising", + "risk", + "risotto", + "ritalin", + "ritzy", + "rival", + "riverbank", + "riverbed", + "riverboat", + "riverside", + "riveter", + "riveting", + "roamer", + "roaming", + "roast", + "robbing", + "robe", + "robin", + "robotics", + "robust", + "rockband", + "rocker", + "rocket", + "rockfish", + "rockiness", + "rocking", + "rocklike", + "rockslide", + "rockstar", + "rocky", + "rogue", + "roman", + "romp", + "rope", + "roping", + "roster", + "rosy", + "rotten", + "rotting", + "rotunda", + "roulette", + "rounding", + "roundish", + "roundness", + "roundup", + "roundworm", + "routine", + "routing", + "rover", + "roving", + "royal", + "rubbed", + "rubber", + "rubbing", + "rubble", + "rubdown", + "ruby", + "ruckus", + "rudder", + "rug", + "ruined", + "rule", + "rumble", + "rumbling", + "rummage", + "rumor", + "runaround", + "rundown", + "runner", + "running", + "runny", + "runt", + "runway", + "rupture", + "rural", + "ruse", + "rush", + "rust", + "rut", + "sabbath", + "sabotage", + "sacrament", + "sacred", + "sacrifice", + "sadden", + "saddlebag", + "saddled", + "saddling", + "sadly", + "sadness", + "safari", + "safeguard", + "safehouse", + "safely", + "safeness", + "saffron", + "saga", + "sage", + "sagging", + "saggy", + "said", + "saint", + "sake", + "salad", + "salami", + "salaried", + "salary", + "saline", + "salon", + "saloon", + "salsa", + "salt", + "salutary", + "salute", + "salvage", + "salvaging", + "salvation", + "same", + "sample", + "sampling", + "sanction", + "sanctity", + "sanctuary", + "sandal", + "sandbag", + "sandbank", + "sandbar", + "sandblast", + "sandbox", + "sanded", + "sandfish", + "sanding", + "sandlot", + "sandpaper", + "sandpit", + "sandstone", + "sandstorm", + "sandworm", + "sandy", + "sanitary", + "sanitizer", + "sank", + "santa", + "sapling", + "sappiness", + "sappy", + "sarcasm", + "sarcastic", + "sardine", + "sash", + "sasquatch", + "sassy", + "satchel", + "satiable", + "satin", + "satirical", + "satisfied", + "satisfy", + "saturate", + "saturday", + "sauciness", + "saucy", + "sauna", + "savage", + "savanna", + "saved", + "savings", + "savior", + "savor", + "saxophone", + "say", + "scabbed", + "scabby", + "scalded", + "scalding", + "scale", + "scaling", + "scallion", + "scallop", + "scalping", + "scam", + "scandal", + "scanner", + "scanning", + "scant", + "scapegoat", + "scarce", + "scarcity", + "scarecrow", + "scared", + "scarf", + "scarily", + "scariness", + "scarring", + "scary", + "scavenger", + "scenic", + "schedule", + "schematic", + "scheme", + "scheming", + "schilling", + "schnapps", + "scholar", + "science", + "scientist", + "scion", + "scoff", + "scolding", + "scone", + "scoop", + "scooter", + "scope", + "scorch", + "scorebook", + "scorecard", + "scored", + "scoreless", + "scorer", + "scoring", + "scorn", + "scorpion", + "scotch", + "scoundrel", + "scoured", + "scouring", + "scouting", + "scouts", + "scowling", + "scrabble", + "scraggly", + "scrambled", + "scrambler", + "scrap", + "scratch", + "scrawny", + "screen", + "scribble", + "scribe", + "scribing", + "scrimmage", + "script", + "scroll", + "scrooge", + "scrounger", + "scrubbed", + "scrubber", + "scruffy", + "scrunch", + "scrutiny", + "scuba", + "scuff", + "sculptor", + "sculpture", + "scurvy", + "scuttle", + "secluded", + "secluding", + "seclusion", + "second", + "secrecy", + "secret", + "sectional", + "sector", + "secular", + "securely", + "security", + "sedan", + "sedate", + "sedation", + "sedative", + "sediment", + "seduce", + "seducing", + "segment", + "seismic", + "seizing", + "seldom", + "selected", + "selection", + "selective", + "selector", + "self", + "seltzer", + "semantic", + "semester", + "semicolon", + "semifinal", + "seminar", + "semisoft", + "semisweet", + "senate", + "senator", + "send", + "senior", + "senorita", + "sensation", + "sensitive", + "sensitize", + "sensually", + "sensuous", + "sepia", + "september", + "septic", + "septum", + "sequel", + "sequence", + "sequester", + "series", + "sermon", + "serotonin", + "serpent", + "serrated", + "serve", + "service", + "serving", + "sesame", + "sessions", + "setback", + "setting", + "settle", + "settling", + "setup", + "sevenfold", + "seventeen", + "seventh", + "seventy", + "severity", + "shabby", + "shack", + "shaded", + "shadily", + "shadiness", + "shading", + "shadow", + "shady", + "shaft", + "shakable", + "shakily", + "shakiness", + "shaking", + "shaky", + "shale", + "shallot", + "shallow", + "shame", + "shampoo", + "shamrock", + "shank", + "shanty", + "shape", + "shaping", + "share", + "sharpener", + "sharper", + "sharpie", + "sharply", + "sharpness", + "shawl", + "sheath", + "shed", + "sheep", + "sheet", + "shelf", + "shell", + "shelter", + "shelve", + "shelving", + "sherry", + "shield", + "shifter", + "shifting", + "shiftless", + "shifty", + "shimmer", + "shimmy", + "shindig", + "shine", + "shingle", + "shininess", + "shining", + "shiny", + "ship", + "shirt", + "shivering", + "shock", + "shone", + "shoplift", + "shopper", + "shopping", + "shoptalk", + "shore", + "shortage", + "shortcake", + "shortcut", + "shorten", + "shorter", + "shorthand", + "shortlist", + "shortly", + "shortness", + "shorts", + "shortwave", + "shorty", + "shout", + "shove", + "showbiz", + "showcase", + "showdown", + "shower", + "showgirl", + "showing", + "showman", + "shown", + "showoff", + "showpiece", + "showplace", + "showroom", + "showy", + "shrank", + "shrapnel", + "shredder", + "shredding", + "shrewdly", + "shriek", + "shrill", + "shrimp", + "shrine", + "shrink", + "shrivel", + "shrouded", + "shrubbery", + "shrubs", + "shrug", + "shrunk", + "shucking", + "shudder", + "shuffle", + "shuffling", + "shun", + "shush", + "shut", + "shy", + "siamese", + "siberian", + "sibling", + "siding", + "sierra", + "siesta", + "sift", + "sighing", + "silenced", + "silencer", + "silent", + "silica", + "silicon", + "silk", + "silliness", + "silly", + "silo", + "silt", + "silver", + "similarly", + "simile", + "simmering", + "simple", + "simplify", + "simply", + "sincere", + "sincerity", + "singer", + "singing", + "single", + "singular", + "sinister", + "sinless", + "sinner", + "sinuous", + "sip", + "siren", + "sister", + "sitcom", + "sitter", + "sitting", + "situated", + "situation", + "sixfold", + "sixteen", + "sixth", + "sixties", + "sixtieth", + "sixtyfold", + "sizable", + "sizably", + "size", + "sizing", + "sizzle", + "sizzling", + "skater", + "skating", + "skedaddle", + "skeletal", + "skeleton", + "skeptic", + "sketch", + "skewed", + "skewer", + "skid", + "skied", + "skier", + "skies", + "skiing", + "skilled", + "skillet", + "skillful", + "skimmed", + "skimmer", + "skimming", + "skimpily", + "skincare", + "skinhead", + "skinless", + "skinning", + "skinny", + "skintight", + "skipper", + "skipping", + "skirmish", + "skirt", + "skittle", + "skydiver", + "skylight", + "skyline", + "skype", + "skyrocket", + "skyward", + "slab", + "slacked", + "slacker", + "slacking", + "slackness", + "slacks", + "slain", + "slam", + "slander", + "slang", + "slapping", + "slapstick", + "slashed", + "slashing", + "slate", + "slather", + "slaw", + "sled", + "sleek", + "sleep", + "sleet", + "sleeve", + "slept", + "sliceable", + "sliced", + "slicer", + "slicing", + "slick", + "slider", + "slideshow", + "sliding", + "slighted", + "slighting", + "slightly", + "slimness", + "slimy", + "slinging", + "slingshot", + "slinky", + "slip", + "slit", + "sliver", + "slobbery", + "slogan", + "sloped", + "sloping", + "sloppily", + "sloppy", + "slot", + "slouching", + "slouchy", + "sludge", + "slug", + "slum", + "slurp", + "slush", + "sly", + "small", + "smartly", + "smartness", + "smasher", + "smashing", + "smashup", + "smell", + "smelting", + "smile", + "smilingly", + "smirk", + "smite", + "smith", + "smitten", + "smock", + "smog", + "smoked", + "smokeless", + "smokiness", + "smoking", + "smoky", + "smolder", + "smooth", + "smother", + "smudge", + "smudgy", + "smuggler", + "smuggling", + "smugly", + "smugness", + "snack", + "snagged", + "snaking", + "snap", + "snare", + "snarl", + "snazzy", + "sneak", + "sneer", + "sneeze", + "sneezing", + "snide", + "sniff", + "snippet", + "snipping", + "snitch", + "snooper", + "snooze", + "snore", + "snoring", + "snorkel", + "snort", + "snout", + "snowbird", + "snowboard", + "snowbound", + "snowcap", + "snowdrift", + "snowdrop", + "snowfall", + "snowfield", + "snowflake", + "snowiness", + "snowless", + "snowman", + "snowplow", + "snowshoe", + "snowstorm", + "snowsuit", + "snowy", + "snub", + "snuff", + "snuggle", + "snugly", + "snugness", + "speak", + "spearfish", + "spearhead", + "spearman", + "spearmint", + "species", + "specimen", + "specked", + "speckled", + "specks", + "spectacle", + "spectator", + "spectrum", + "speculate", + "speech", + "speed", + "spellbind", + "speller", + "spelling", + "spendable", + "spender", + "spending", + "spent", + "spew", + "sphere", + "spherical", + "sphinx", + "spider", + "spied", + "spiffy", + "spill", + "spilt", + "spinach", + "spinal", + "spindle", + "spinner", + "spinning", + "spinout", + "spinster", + "spiny", + "spiral", + "spirited", + "spiritism", + "spirits", + "spiritual", + "splashed", + "splashing", + "splashy", + "splatter", + "spleen", + "splendid", + "splendor", + "splice", + "splicing", + "splinter", + "splotchy", + "splurge", + "spoilage", + "spoiled", + "spoiler", + "spoiling", + "spoils", + "spoken", + "spokesman", + "sponge", + "spongy", + "sponsor", + "spoof", + "spookily", + "spooky", + "spool", + "spoon", + "spore", + "sporting", + "sports", + "sporty", + "spotless", + "spotlight", + "spotted", + "spotter", + "spotting", + "spotty", + "spousal", + "spouse", + "spout", + "sprain", + "sprang", + "sprawl", + "spray", + "spree", + "sprig", + "spring", + "sprinkled", + "sprinkler", + "sprint", + "sprite", + "sprout", + "spruce", + "sprung", + "spry", + "spud", + "spur", + "sputter", + "spyglass", + "squabble", + "squad", + "squall", + "squander", + "squash", + "squatted", + "squatter", + "squatting", + "squeak", + "squealer", + "squealing", + "squeamish", + "squeegee", + "squeeze", + "squeezing", + "squid", + "squiggle", + "squiggly", + "squint", + "squire", + "squirt", + "squishier", + "squishy", + "stability", + "stabilize", + "stable", + "stack", + "stadium", + "staff", + "stage", + "staging", + "stagnant", + "stagnate", + "stainable", + "stained", + "staining", + "stainless", + "stalemate", + "staleness", + "stalling", + "stallion", + "stamina", + "stammer", + "stamp", + "stand", + "stank", + "staple", + "stapling", + "starboard", + "starch", + "stardom", + "stardust", + "starfish", + "stargazer", + "staring", + "stark", + "starless", + "starlet", + "starlight", + "starlit", + "starring", + "starry", + "starship", + "starter", + "starting", + "startle", + "startling", + "startup", + "starved", + "starving", + "stash", + "state", + "static", + "statistic", + "statue", + "stature", + "status", + "statute", + "statutory", + "staunch", + "stays", + "steadfast", + "steadier", + "steadily", + "steadying", + "steam", + "steed", + "steep", + "steerable", + "steering", + "steersman", + "stegosaur", + "stellar", + "stem", + "stench", + "stencil", + "step", + "stereo", + "sterile", + "sterility", + "sterilize", + "sterling", + "sternness", + "sternum", + "stew", + "stick", + "stiffen", + "stiffly", + "stiffness", + "stifle", + "stifling", + "stillness", + "stilt", + "stimulant", + "stimulate", + "stimuli", + "stimulus", + "stinger", + "stingily", + "stinging", + "stingray", + "stingy", + "stinking", + "stinky", + "stipend", + "stipulate", + "stir", + "stitch", + "stock", + "stoic", + "stoke", + "stole", + "stomp", + "stonewall", + "stoneware", + "stonework", + "stoning", + "stony", + "stood", + "stooge", + "stool", + "stoop", + "stoplight", + "stoppable", + "stoppage", + "stopped", + "stopper", + "stopping", + "stopwatch", + "storable", + "storage", + "storeroom", + "storewide", + "storm", + "stout", + "stove", + "stowaway", + "stowing", + "straddle", + "straggler", + "strained", + "strainer", + "straining", + "strangely", + "stranger", + "strangle", + "strategic", + "strategy", + "stratus", + "straw", + "stray", + "streak", + "stream", + "street", + "strength", + "strenuous", + "strep", + "stress", + "stretch", + "strewn", + "stricken", + "strict", + "stride", + "strife", + "strike", + "striking", + "strive", + "striving", + "strobe", + "strode", + "stroller", + "strongbox", + "strongly", + "strongman", + "struck", + "structure", + "strudel", + "struggle", + "strum", + "strung", + "strut", + "stubbed", + "stubble", + "stubbly", + "stubborn", + "stucco", + "stuck", + "student", + "studied", + "studio", + "study", + "stuffed", + "stuffing", + "stuffy", + "stumble", + "stumbling", + "stump", + "stung", + "stunned", + "stunner", + "stunning", + "stunt", + "stupor", + "sturdily", + "sturdy", + "styling", + "stylishly", + "stylist", + "stylized", + "stylus", + "suave", + "subarctic", + "subatomic", + "subdivide", + "subdued", + "subduing", + "subfloor", + "subgroup", + "subheader", + "subject", + "sublease", + "sublet", + "sublevel", + "sublime", + "submarine", + "submerge", + "submersed", + "submitter", + "subpanel", + "subpar", + "subplot", + "subprime", + "subscribe", + "subscript", + "subsector", + "subside", + "subsiding", + "subsidize", + "subsidy", + "subsoil", + "subsonic", + "substance", + "subsystem", + "subtext", + "subtitle", + "subtly", + "subtotal", + "subtract", + "subtype", + "suburb", + "subway", + "subwoofer", + "subzero", + "succulent", + "such", + "suction", + "sudden", + "sudoku", + "suds", + "sufferer", + "suffering", + "suffice", + "suffix", + "suffocate", + "suffrage", + "sugar", + "suggest", + "suing", + "suitable", + "suitably", + "suitcase", + "suitor", + "sulfate", + "sulfide", + "sulfite", + "sulfur", + "sulk", + "sullen", + "sulphate", + "sulphuric", + "sultry", + "superbowl", + "superglue", + "superhero", + "superior", + "superjet", + "superman", + "supermom", + "supernova", + "supervise", + "supper", + "supplier", + "supply", + "support", + "supremacy", + "supreme", + "surcharge", + "surely", + "sureness", + "surface", + "surfacing", + "surfboard", + "surfer", + "surgery", + "surgical", + "surging", + "surname", + "surpass", + "surplus", + "surprise", + "surreal", + "surrender", + "surrogate", + "surround", + "survey", + "survival", + "survive", + "surviving", + "survivor", + "sushi", + "suspect", + "suspend", + "suspense", + "sustained", + "sustainer", + "swab", + "swaddling", + "swagger", + "swampland", + "swan", + "swapping", + "swarm", + "sway", + "swear", + "sweat", + "sweep", + "swell", + "swept", + "swerve", + "swifter", + "swiftly", + "swiftness", + "swimmable", + "swimmer", + "swimming", + "swimsuit", + "swimwear", + "swinger", + "swinging", + "swipe", + "swirl", + "switch", + "swivel", + "swizzle", + "swooned", + "swoop", + "swoosh", + "swore", + "sworn", + "swung", + "sycamore", + "sympathy", + "symphonic", + "symphony", + "symptom", + "synapse", + "syndrome", + "synergy", + "synopses", + "synopsis", + "synthesis", + "synthetic", + "syrup", + "system", + "t-shirt", + "tabasco", + "tabby", + "tableful", + "tables", + "tablet", + "tableware", + "tabloid", + "tackiness", + "tacking", + "tackle", + "tackling", + "tacky", + "taco", + "tactful", + "tactical", + "tactics", + "tactile", + "tactless", + "tadpole", + "taekwondo", + "tag", + "tainted", + "take", + "taking", + "talcum", + "talisman", + "tall", + "talon", + "tamale", + "tameness", + "tamer", + "tamper", + "tank", + "tanned", + "tannery", + "tanning", + "tantrum", + "tapeless", + "tapered", + "tapering", + "tapestry", + "tapioca", + "tapping", + "taps", + "tarantula", + "target", + "tarmac", + "tarnish", + "tarot", + "tartar", + "tartly", + "tartness", + "task", + "tassel", + "taste", + "tastiness", + "tasting", + "tasty", + "tattered", + "tattle", + "tattling", + "tattoo", + "taunt", + "tavern", + "thank", + "that", + "thaw", + "theater", + "theatrics", + "thee", + "theft", + "theme", + "theology", + "theorize", + "thermal", + "thermos", + "thesaurus", + "these", + "thesis", + "thespian", + "thicken", + "thicket", + "thickness", + "thieving", + "thievish", + "thigh", + "thimble", + "thing", + "think", + "thinly", + "thinner", + "thinness", + "thinning", + "thirstily", + "thirsting", + "thirsty", + "thirteen", + "thirty", + "thong", + "thorn", + "those", + "thousand", + "thrash", + "thread", + "threaten", + "threefold", + "thrift", + "thrill", + "thrive", + "thriving", + "throat", + "throbbing", + "throng", + "throttle", + "throwaway", + "throwback", + "thrower", + "throwing", + "thud", + "thumb", + "thumping", + "thursday", + "thus", + "thwarting", + "thyself", + "tiara", + "tibia", + "tidal", + "tidbit", + "tidiness", + "tidings", + "tidy", + "tiger", + "tighten", + "tightly", + "tightness", + "tightrope", + "tightwad", + "tigress", + "tile", + "tiling", + "till", + "tilt", + "timid", + "timing", + "timothy", + "tinderbox", + "tinfoil", + "tingle", + "tingling", + "tingly", + "tinker", + "tinkling", + "tinsel", + "tinsmith", + "tint", + "tinwork", + "tiny", + "tipoff", + "tipped", + "tipper", + "tipping", + "tiptoeing", + "tiptop", + "tiring", + "tissue", + "trace", + "tracing", + "track", + "traction", + "tractor", + "trade", + "trading", + "tradition", + "traffic", + "tragedy", + "trailing", + "trailside", + "train", + "traitor", + "trance", + "tranquil", + "transfer", + "transform", + "translate", + "transpire", + "transport", + "transpose", + "trapdoor", + "trapeze", + "trapezoid", + "trapped", + "trapper", + "trapping", + "traps", + "trash", + "travel", + "traverse", + "travesty", + "tray", + "treachery", + "treading", + "treadmill", + "treason", + "treat", + "treble", + "tree", + "trekker", + "tremble", + "trembling", + "tremor", + "trench", + "trend", + "trespass", + "triage", + "trial", + "triangle", + "tribesman", + "tribunal", + "tribune", + "tributary", + "tribute", + "triceps", + "trickery", + "trickily", + "tricking", + "trickle", + "trickster", + "tricky", + "tricolor", + "tricycle", + "trident", + "tried", + "trifle", + "trifocals", + "trillion", + "trilogy", + "trimester", + "trimmer", + "trimming", + "trimness", + "trinity", + "trio", + "tripod", + "tripping", + "triumph", + "trivial", + "trodden", + "trolling", + "trombone", + "trophy", + "tropical", + "tropics", + "trouble", + "troubling", + "trough", + "trousers", + "trout", + "trowel", + "truce", + "truck", + "truffle", + "trump", + "trunks", + "trustable", + "trustee", + "trustful", + "trusting", + "trustless", + "truth", + "try", + "tubby", + "tubeless", + "tubular", + "tucking", + "tuesday", + "tug", + "tuition", + "tulip", + "tumble", + "tumbling", + "tummy", + "turban", + "turbine", + "turbofan", + "turbojet", + "turbulent", + "turf", + "turkey", + "turmoil", + "turret", + "turtle", + "tusk", + "tutor", + "tutu", + "tux", + "tweak", + "tweed", + "tweet", + "tweezers", + "twelve", + "twentieth", + "twenty", + "twerp", + "twice", + "twiddle", + "twiddling", + "twig", + "twilight", + "twine", + "twins", + "twirl", + "twistable", + "twisted", + "twister", + "twisting", + "twisty", + "twitch", + "twitter", + "tycoon", + "tying", + "tyke", + "udder", + "ultimate", + "ultimatum", + "ultra", + "umbilical", + "umbrella", + "umpire", + "unabashed", + "unable", + "unadorned", + "unadvised", + "unafraid", + "unaired", + "unaligned", + "unaltered", + "unarmored", + "unashamed", + "unaudited", + "unawake", + "unaware", + "unbaked", + "unbalance", + "unbeaten", + "unbend", + "unbent", + "unbiased", + "unbitten", + "unblended", + "unblessed", + "unblock", + "unbolted", + "unbounded", + "unboxed", + "unbraided", + "unbridle", + "unbroken", + "unbuckled", + "unbundle", + "unburned", + "unbutton", + "uncanny", + "uncapped", + "uncaring", + "uncertain", + "unchain", + "unchanged", + "uncharted", + "uncheck", + "uncivil", + "unclad", + "unclaimed", + "unclamped", + "unclasp", + "uncle", + "unclip", + "uncloak", + "unclog", + "unclothed", + "uncoated", + "uncoiled", + "uncolored", + "uncombed", + "uncommon", + "uncooked", + "uncork", + "uncorrupt", + "uncounted", + "uncouple", + "uncouth", + "uncover", + "uncross", + "uncrown", + "uncrushed", + "uncured", + "uncurious", + "uncurled", + "uncut", + "undamaged", + "undated", + "undaunted", + "undead", + "undecided", + "undefined", + "underage", + "underarm", + "undercoat", + "undercook", + "undercut", + "underdog", + "underdone", + "underfed", + "underfeed", + "underfoot", + "undergo", + "undergrad", + "underhand", + "underline", + "underling", + "undermine", + "undermost", + "underpaid", + "underpass", + "underpay", + "underrate", + "undertake", + "undertone", + "undertook", + "undertow", + "underuse", + "underwear", + "underwent", + "underwire", + "undesired", + "undiluted", + "undivided", + "undocked", + "undoing", + "undone", + "undrafted", + "undress", + "undrilled", + "undusted", + "undying", + "unearned", + "unearth", + "unease", + "uneasily", + "uneasy", + "uneatable", + "uneaten", + "unedited", + "unelected", + "unending", + "unengaged", + "unenvied", + "unequal", + "unethical", + "uneven", + "unexpired", + "unexposed", + "unfailing", + "unfair", + "unfasten", + "unfazed", + "unfeeling", + "unfiled", + "unfilled", + "unfitted", + "unfitting", + "unfixable", + "unfixed", + "unflawed", + "unfocused", + "unfold", + "unfounded", + "unframed", + "unfreeze", + "unfrosted", + "unfrozen", + "unfunded", + "unglazed", + "ungloved", + "unglue", + "ungodly", + "ungraded", + "ungreased", + "unguarded", + "unguided", + "unhappily", + "unhappy", + "unharmed", + "unhealthy", + "unheard", + "unhearing", + "unheated", + "unhelpful", + "unhidden", + "unhinge", + "unhitched", + "unholy", + "unhook", + "unicorn", + "unicycle", + "unified", + "unifier", + "uniformed", + "uniformly", + "unify", + "unimpeded", + "uninjured", + "uninstall", + "uninsured", + "uninvited", + "union", + "uniquely", + "unisexual", + "unison", + "unissued", + "unit", + "universal", + "universe", + "unjustly", + "unkempt", + "unkind", + "unknotted", + "unknowing", + "unknown", + "unlaced", + "unlatch", + "unlawful", + "unleaded", + "unlearned", + "unleash", + "unless", + "unleveled", + "unlighted", + "unlikable", + "unlimited", + "unlined", + "unlinked", + "unlisted", + "unlit", + "unlivable", + "unloaded", + "unloader", + "unlocked", + "unlocking", + "unlovable", + "unloved", + "unlovely", + "unloving", + "unluckily", + "unlucky", + "unmade", + "unmanaged", + "unmanned", + "unmapped", + "unmarked", + "unmasked", + "unmasking", + "unmatched", + "unmindful", + "unmixable", + "unmixed", + "unmolded", + "unmoral", + "unmovable", + "unmoved", + "unmoving", + "unnamable", + "unnamed", + "unnatural", + "unneeded", + "unnerve", + "unnerving", + "unnoticed", + "unopened", + "unopposed", + "unpack", + "unpadded", + "unpaid", + "unpainted", + "unpaired", + "unpaved", + "unpeeled", + "unpicked", + "unpiloted", + "unpinned", + "unplanned", + "unplanted", + "unpleased", + "unpledged", + "unplowed", + "unplug", + "unpopular", + "unproven", + "unquote", + "unranked", + "unrated", + "unraveled", + "unreached", + "unread", + "unreal", + "unreeling", + "unrefined", + "unrelated", + "unrented", + "unrest", + "unretired", + "unrevised", + "unrigged", + "unripe", + "unrivaled", + "unroasted", + "unrobed", + "unroll", + "unruffled", + "unruly", + "unrushed", + "unsaddle", + "unsafe", + "unsaid", + "unsalted", + "unsaved", + "unsavory", + "unscathed", + "unscented", + "unscrew", + "unsealed", + "unseated", + "unsecured", + "unseeing", + "unseemly", + "unseen", + "unselect", + "unselfish", + "unsent", + "unsettled", + "unshackle", + "unshaken", + "unshaved", + "unshaven", + "unsheathe", + "unshipped", + "unsightly", + "unsigned", + "unskilled", + "unsliced", + "unsmooth", + "unsnap", + "unsocial", + "unsoiled", + "unsold", + "unsolved", + "unsorted", + "unspoiled", + "unspoken", + "unstable", + "unstaffed", + "unstamped", + "unsteady", + "unsterile", + "unstirred", + "unstitch", + "unstopped", + "unstuck", + "unstuffed", + "unstylish", + "unsubtle", + "unsubtly", + "unsuited", + "unsure", + "unsworn", + "untagged", + "untainted", + "untaken", + "untamed", + "untangled", + "untapped", + "untaxed", + "unthawed", + "unthread", + "untidy", + "untie", + "until", + "untimed", + "untimely", + "untitled", + "untoasted", + "untold", + "untouched", + "untracked", + "untrained", + "untreated", + "untried", + "untrimmed", + "untrue", + "untruth", + "unturned", + "untwist", + "untying", + "unusable", + "unused", + "unusual", + "unvalued", + "unvaried", + "unvarying", + "unveiled", + "unveiling", + "unvented", + "unviable", + "unvisited", + "unvocal", + "unwanted", + "unwarlike", + "unwary", + "unwashed", + "unwatched", + "unweave", + "unwed", + "unwelcome", + "unwell", + "unwieldy", + "unwilling", + "unwind", + "unwired", + "unwitting", + "unwomanly", + "unworldly", + "unworn", + "unworried", + "unworthy", + "unwound", + "unwoven", + "unwrapped", + "unwritten", + "unzip", + "upbeat", + "upchuck", + "upcoming", + "upcountry", + "update", + "upfront", + "upgrade", + "upheaval", + "upheld", + "uphill", + "uphold", + "uplifted", + "uplifting", + "upload", + "upon", + "upper", + "upright", + "uprising", + "upriver", + "uproar", + "uproot", + "upscale", + "upside", + "upstage", + "upstairs", + "upstart", + "upstate", + "upstream", + "upstroke", + "upswing", + "uptake", + "uptight", + "uptown", + "upturned", + "upward", + "upwind", + "uranium", + "urban", + "urchin", + "urethane", + "urgency", + "urgent", + "urging", + "urologist", + "urology", + "usable", + "usage", + "useable", + "used", + "uselessly", + "user", + "usher", + "usual", + "utensil", + "utility", + "utilize", + "utmost", + "utopia", + "utter", + "vacancy", + "vacant", + "vacate", + "vacation", + "vagabond", + "vagrancy", + "vagrantly", + "vaguely", + "vagueness", + "valiant", + "valid", + "valium", + "valley", + "valuables", + "value", + "vanilla", + "vanish", + "vanity", + "vanquish", + "vantage", + "vaporizer", + "variable", + "variably", + "varied", + "variety", + "various", + "varmint", + "varnish", + "varsity", + "varying", + "vascular", + "vaseline", + "vastly", + "vastness", + "veal", + "vegan", + "veggie", + "vehicular", + "velcro", + "velocity", + "velvet", + "vendetta", + "vending", + "vendor", + "veneering", + "vengeful", + "venomous", + "ventricle", + "venture", + "venue", + "venus", + "verbalize", + "verbally", + "verbose", + "verdict", + "verify", + "verse", + "version", + "versus", + "vertebrae", + "vertical", + "vertigo", + "very", + "vessel", + "vest", + "veteran", + "veto", + "vexingly", + "viability", + "viable", + "vibes", + "vice", + "vicinity", + "victory", + "video", + "viewable", + "viewer", + "viewing", + "viewless", + "viewpoint", + "vigorous", + "village", + "villain", + "vindicate", + "vineyard", + "vintage", + "violate", + "violation", + "violator", + "violet", + "violin", + "viper", + "viral", + "virtual", + "virtuous", + "virus", + "visa", + "viscosity", + "viscous", + "viselike", + "visible", + "visibly", + "vision", + "visiting", + "visitor", + "visor", + "vista", + "vitality", + "vitalize", + "vitally", + "vitamins", + "vivacious", + "vividly", + "vividness", + "vixen", + "vocalist", + "vocalize", + "vocally", + "vocation", + "voice", + "voicing", + "void", + "volatile", + "volley", + "voltage", + "volumes", + "voter", + "voting", + "voucher", + "vowed", + "vowel", + "voyage", + "wackiness", + "wad", + "wafer", + "waffle", + "waged", + "wager", + "wages", + "waggle", + "wagon", + "wake", + "waking", + "walk", + "walmart", + "walnut", + "walrus", + "waltz", + "wand", + "wannabe", + "wanted", + "wanting", + "wasabi", + "washable", + "washbasin", + "washboard", + "washbowl", + "washcloth", + "washday", + "washed", + "washer", + "washhouse", + "washing", + "washout", + "washroom", + "washstand", + "washtub", + "wasp", + "wasting", + "watch", + "water", + "waviness", + "waving", + "wavy", + "whacking", + "whacky", + "wham", + "wharf", + "wheat", + "whenever", + "whiff", + "whimsical", + "whinny", + "whiny", + "whisking", + "whoever", + "whole", + "whomever", + "whoopee", + "whooping", + "whoops", + "why", + "wick", + "widely", + "widen", + "widget", + "widow", + "width", + "wieldable", + "wielder", + "wife", + "wifi", + "wikipedia", + "wildcard", + "wildcat", + "wilder", + "wildfire", + "wildfowl", + "wildland", + "wildlife", + "wildly", + "wildness", + "willed", + "willfully", + "willing", + "willow", + "willpower", + "wilt", + "wimp", + "wince", + "wincing", + "wind", + "wing", + "winking", + "winner", + "winnings", + "winter", + "wipe", + "wired", + "wireless", + "wiring", + "wiry", + "wisdom", + "wise", + "wish", + "wisplike", + "wispy", + "wistful", + "wizard", + "wobble", + "wobbling", + "wobbly", + "wok", + "wolf", + "wolverine", + "womanhood", + "womankind", + "womanless", + "womanlike", + "womanly", + "womb", + "woof", + "wooing", + "wool", + "woozy", + "word", + "work", + "worried", + "worrier", + "worrisome", + "worry", + "worsening", + "worshiper", + "worst", + "wound", + "woven", + "wow", + "wrangle", + "wrath", + "wreath", + "wreckage", + "wrecker", + "wrecking", + "wrench", + "wriggle", + "wriggly", + "wrinkle", + "wrinkly", + "wrist", + "writing", + "written", + "wrongdoer", + "wronged", + "wrongful", + "wrongly", + "wrongness", + "wrought", + "xbox", + "xerox", + "yahoo", + "yam", + "yanking", + "yapping", + "yard", + "yarn", + "yeah", + "yearbook", + "yearling", + "yearly", + "yearning", + "yeast", + "yelling", + "yelp", + "yen", + "yesterday", + "yiddish", + "yield", + "yin", + "yippee", + "yo-yo", + "yodel", + "yoga", + "yogurt", + "yonder", + "yoyo", + "yummy", + "zap", + "zealous", + "zebra", + "zen", + "zeppelin", + "zero", + "zestfully", + "zesty", + "zigzagged", + "zipfile", + "zipping", + "zippy", + "zips", + "zit", + "zodiac", + "zombie", + "zone", + "zoning", + "zookeeper", + "zoologist", + "zoology", + "zoom", +]; + +export const BIP39_WORDS: readonly string[] = [ + "abandon", + "ability", + "able", + "about", + "above", + "absent", + "absorb", + "abstract", + "absurd", + "abuse", + "access", + "accident", + "account", + "accuse", + "achieve", + "acid", + "acoustic", + "acquire", + "across", + "act", + "action", + "actor", + "actress", + "actual", + "adapt", + "add", + "addict", + "address", + "adjust", + "admit", + "adult", + "advance", + "advice", + "aerobic", + "affair", + "afford", + "afraid", + "again", + "age", + "agent", + "agree", + "ahead", + "aim", + "air", + "airport", + "aisle", + "alarm", + "album", + "alcohol", + "alert", + "alien", + "all", + "alley", + "allow", + "almost", + "alone", + "alpha", + "already", + "also", + "alter", + "always", + "amateur", + "amazing", + "among", + "amount", + "amused", + "analyst", + "anchor", + "ancient", + "anger", + "angle", + "angry", + "animal", + "ankle", + "announce", + "annual", + "another", + "answer", + "antenna", + "antique", + "anxiety", + "any", + "apart", + "apology", + "appear", + "apple", + "approve", + "april", + "arch", + "arctic", + "area", + "arena", + "argue", + "arm", + "armed", + "armor", + "army", + "around", + "arrange", + "arrest", + "arrive", + "arrow", + "art", + "artefact", + "artist", + "artwork", + "ask", + "aspect", + "assault", + "asset", + "assist", + "assume", + "asthma", + "athlete", + "atom", + "attack", + "attend", + "attitude", + "attract", + "auction", + "audit", + "august", + "aunt", + "author", + "auto", + "autumn", + "average", + "avocado", + "avoid", + "awake", + "aware", + "away", + "awesome", + "awful", + "awkward", + "axis", + "baby", + "bachelor", + "bacon", + "badge", + "bag", + "balance", + "balcony", + "ball", + "bamboo", + "banana", + "banner", + "bar", + "barely", + "bargain", + "barrel", + "base", + "basic", + "basket", + "battle", + "beach", + "bean", + "beauty", + "because", + "become", + "beef", + "before", + "begin", + "behave", + "behind", + "believe", + "below", + "belt", + "bench", + "benefit", + "best", + "betray", + "better", + "between", + "beyond", + "bicycle", + "bid", + "bike", + "bind", + "biology", + "bird", + "birth", + "bitter", + "black", + "blade", + "blame", + "blanket", + "blast", + "bleak", + "bless", + "blind", + "blood", + "blossom", + "blouse", + "blue", + "blur", + "blush", + "board", + "boat", + "body", + "boil", + "bomb", + "bone", + "bonus", + "book", + "boost", + "border", + "boring", + "borrow", + "boss", + "bottom", + "bounce", + "box", + "boy", + "bracket", + "brain", + "brand", + "brass", + "brave", + "bread", + "breeze", + "brick", + "bridge", + "brief", + "bright", + "bring", + "brisk", + "broccoli", + "broken", + "bronze", + "broom", + "brother", + "brown", + "brush", + "bubble", + "buddy", + "budget", + "buffalo", + "build", + "bulb", + "bulk", + "bullet", + "bundle", + "bunker", + "burden", + "burger", + "burst", + "bus", + "business", + "busy", + "butter", + "buyer", + "buzz", + "cabbage", + "cabin", + "cable", + "cactus", + "cage", + "cake", + "call", + "calm", + "camera", + "camp", + "can", + "canal", + "cancel", + "candy", + "cannon", + "canoe", + "canvas", + "canyon", + "capable", + "capital", + "captain", + "car", + "carbon", + "card", + "cargo", + "carpet", + "carry", + "cart", + "case", + "cash", + "casino", + "castle", + "casual", + "cat", + "catalog", + "catch", + "category", + "cattle", + "caught", + "cause", + "caution", + "cave", + "ceiling", + "celery", + "cement", + "census", + "century", + "cereal", + "certain", + "chair", + "chalk", + "champion", + "change", + "chaos", + "chapter", + "charge", + "chase", + "chat", + "cheap", + "check", + "cheese", + "chef", + "cherry", + "chest", + "chicken", + "chief", + "child", + "chimney", + "choice", + "choose", + "chronic", + "chuckle", + "chunk", + "churn", + "cigar", + "cinnamon", + "circle", + "citizen", + "city", + "civil", + "claim", + "clap", + "clarify", + "claw", + "clay", + "clean", + "clerk", + "clever", + "click", + "client", + "cliff", + "climb", + "clinic", + "clip", + "clock", + "clog", + "close", + "cloth", + "cloud", + "clown", + "club", + "clump", + "cluster", + "clutch", + "coach", + "coast", + "coconut", + "code", + "coffee", + "coil", + "coin", + "collect", + "color", + "column", + "combine", + "come", + "comfort", + "comic", + "common", + "company", + "concert", + "conduct", + "confirm", + "congress", + "connect", + "consider", + "control", + "convince", + "cook", + "cool", + "copper", + "copy", + "coral", + "core", + "corn", + "correct", + "cost", + "cotton", + "couch", + "country", + "couple", + "course", + "cousin", + "cover", + "coyote", + "crack", + "cradle", + "craft", + "cram", + "crane", + "crash", + "crater", + "crawl", + "crazy", + "cream", + "credit", + "creek", + "crew", + "cricket", + "crime", + "crisp", + "critic", + "crop", + "cross", + "crouch", + "crowd", + "crucial", + "cruel", + "cruise", + "crumble", + "crunch", + "crush", + "cry", + "crystal", + "cube", + "culture", + "cup", + "cupboard", + "curious", + "current", + "curtain", + "curve", + "cushion", + "custom", + "cute", + "cycle", + "dad", + "damage", + "damp", + "dance", + "danger", + "daring", + "dash", + "daughter", + "dawn", + "day", + "deal", + "debate", + "debris", + "decade", + "december", + "decide", + "decline", + "decorate", + "decrease", + "deer", + "defense", + "define", + "defy", + "degree", + "delay", + "deliver", + "demand", + "demise", + "denial", + "dentist", + "deny", + "depart", + "depend", + "deposit", + "depth", + "deputy", + "derive", + "describe", + "desert", + "design", + "desk", + "despair", + "destroy", + "detail", + "detect", + "develop", + "device", + "devote", + "diagram", + "dial", + "diamond", + "diary", + "dice", + "diesel", + "diet", + "differ", + "digital", + "dignity", + "dilemma", + "dinner", + "dinosaur", + "direct", + "dirt", + "disagree", + "discover", + "disease", + "dish", + "dismiss", + "disorder", + "display", + "distance", + "divert", + "divide", + "divorce", + "dizzy", + "doctor", + "document", + "dog", + "doll", + "dolphin", + "domain", + "donate", + "donkey", + "donor", + "door", + "dose", + "double", + "dove", + "draft", + "dragon", + "drama", + "drastic", + "draw", + "dream", + "dress", + "drift", + "drill", + "drink", + "drip", + "drive", + "drop", + "drum", + "dry", + "duck", + "dumb", + "dune", + "during", + "dust", + "dutch", + "duty", + "dwarf", + "dynamic", + "eager", + "eagle", + "early", + "earn", + "earth", + "easily", + "east", + "easy", + "echo", + "ecology", + "economy", + "edge", + "edit", + "educate", + "effort", + "egg", + "eight", + "either", + "elbow", + "elder", + "electric", + "elegant", + "element", + "elephant", + "elevator", + "elite", + "else", + "embark", + "embody", + "embrace", + "emerge", + "emotion", + "employ", + "empower", + "empty", + "enable", + "enact", + "end", + "endless", + "endorse", + "enemy", + "energy", + "enforce", + "engage", + "engine", + "enhance", + "enjoy", + "enlist", + "enough", + "enrich", + "enroll", + "ensure", + "enter", + "entire", + "entry", + "envelope", + "episode", + "equal", + "equip", + "era", + "erase", + "erode", + "erosion", + "error", + "erupt", + "escape", + "essay", + "essence", + "estate", + "eternal", + "ethics", + "evidence", + "evil", + "evoke", + "evolve", + "exact", + "example", + "excess", + "exchange", + "excite", + "exclude", + "excuse", + "execute", + "exercise", + "exhaust", + "exhibit", + "exile", + "exist", + "exit", + "exotic", + "expand", + "expect", + "expire", + "explain", + "expose", + "express", + "extend", + "extra", + "eye", + "eyebrow", + "fabric", + "face", + "faculty", + "fade", + "faint", + "faith", + "fall", + "false", + "fame", + "family", + "famous", + "fan", + "fancy", + "fantasy", + "farm", + "fashion", + "fat", + "fatal", + "father", + "fatigue", + "fault", + "favorite", + "feature", + "february", + "federal", + "fee", + "feed", + "feel", + "female", + "fence", + "festival", + "fetch", + "fever", + "few", + "fiber", + "fiction", + "field", + "figure", + "file", + "film", + "filter", + "final", + "find", + "fine", + "finger", + "finish", + "fire", + "firm", + "first", + "fiscal", + "fish", + "fit", + "fitness", + "fix", + "flag", + "flame", + "flash", + "flat", + "flavor", + "flee", + "flight", + "flip", + "float", + "flock", + "floor", + "flower", + "fluid", + "flush", + "fly", + "foam", + "focus", + "fog", + "foil", + "fold", + "follow", + "food", + "foot", + "force", + "forest", + "forget", + "fork", + "fortune", + "forum", + "forward", + "fossil", + "foster", + "found", + "fox", + "fragile", + "frame", + "frequent", + "fresh", + "friend", + "fringe", + "frog", + "front", + "frost", + "frown", + "frozen", + "fruit", + "fuel", + "fun", + "funny", + "furnace", + "fury", + "future", + "gadget", + "gain", + "galaxy", + "gallery", + "game", + "gap", + "garage", + "garbage", + "garden", + "garlic", + "garment", + "gas", + "gasp", + "gate", + "gather", + "gauge", + "gaze", + "general", + "genius", + "genre", + "gentle", + "genuine", + "gesture", + "ghost", + "giant", + "gift", + "giggle", + "ginger", + "giraffe", + "girl", + "give", + "glad", + "glance", + "glare", + "glass", + "glide", + "glimpse", + "globe", + "gloom", + "glory", + "glove", + "glow", + "glue", + "goat", + "goddess", + "gold", + "good", + "goose", + "gorilla", + "gospel", + "gossip", + "govern", + "gown", + "grab", + "grace", + "grain", + "grant", + "grape", + "grass", + "gravity", + "great", + "green", + "grid", + "grief", + "grit", + "grocery", + "group", + "grow", + "grunt", + "guard", + "guess", + "guide", + "guilt", + "guitar", + "gun", + "gym", + "habit", + "hair", + "half", + "hammer", + "hamster", + "hand", + "happy", + "harbor", + "hard", + "harsh", + "harvest", + "hat", + "have", + "hawk", + "hazard", + "head", + "health", + "heart", + "heavy", + "hedgehog", + "height", + "hello", + "helmet", + "help", + "hen", + "hero", + "hidden", + "high", + "hill", + "hint", + "hip", + "hire", + "history", + "hobby", + "hockey", + "hold", + "hole", + "holiday", + "hollow", + "home", + "honey", + "hood", + "hope", + "horn", + "horror", + "horse", + "hospital", + "host", + "hotel", + "hour", + "hover", + "hub", + "huge", + "human", + "humble", + "humor", + "hundred", + "hungry", + "hunt", + "hurdle", + "hurry", + "hurt", + "husband", + "hybrid", + "ice", + "icon", + "idea", + "identify", + "idle", + "ignore", + "ill", + "illegal", + "illness", + "image", + "imitate", + "immense", + "immune", + "impact", + "impose", + "improve", + "impulse", + "inch", + "include", + "income", + "increase", + "index", + "indicate", + "indoor", + "industry", + "infant", + "inflict", + "inform", + "inhale", + "inherit", + "initial", + "inject", + "injury", + "inmate", + "inner", + "innocent", + "input", + "inquiry", + "insane", + "insect", + "inside", + "inspire", + "install", + "intact", + "interest", + "into", + "invest", + "invite", + "involve", + "iron", + "island", + "isolate", + "issue", + "item", + "ivory", + "jacket", + "jaguar", + "jar", + "jazz", + "jealous", + "jeans", + "jelly", + "jewel", + "job", + "join", + "joke", + "journey", + "joy", + "judge", + "juice", + "jump", + "jungle", + "junior", + "junk", + "just", + "kangaroo", + "keen", + "keep", + "ketchup", + "key", + "kick", + "kid", + "kidney", + "kind", + "kingdom", + "kiss", + "kit", + "kitchen", + "kite", + "kitten", + "kiwi", + "knee", + "knife", + "knock", + "know", + "lab", + "label", + "labor", + "ladder", + "lady", + "lake", + "lamp", + "language", + "laptop", + "large", + "later", + "latin", + "laugh", + "laundry", + "lava", + "law", + "lawn", + "lawsuit", + "layer", + "lazy", + "leader", + "leaf", + "learn", + "leave", + "lecture", + "left", + "leg", + "legal", + "legend", + "leisure", + "lemon", + "lend", + "length", + "lens", + "leopard", + "lesson", + "letter", + "level", + "liar", + "liberty", + "library", + "license", + "life", + "lift", + "light", + "like", + "limb", + "limit", + "link", + "lion", + "liquid", + "list", + "little", + "live", + "lizard", + "load", + "loan", + "lobster", + "local", + "lock", + "logic", + "lonely", + "long", + "loop", + "lottery", + "loud", + "lounge", + "love", + "loyal", + "lucky", + "luggage", + "lumber", + "lunar", + "lunch", + "luxury", + "lyrics", + "machine", + "mad", + "magic", + "magnet", + "maid", + "mail", + "main", + "major", + "make", + "mammal", + "man", + "manage", + "mandate", + "mango", + "mansion", + "manual", + "maple", + "marble", + "march", + "margin", + "marine", + "market", + "marriage", + "mask", + "mass", + "master", + "match", + "material", + "math", + "matrix", + "matter", + "maximum", + "maze", + "meadow", + "mean", + "measure", + "meat", + "mechanic", + "medal", + "media", + "melody", + "melt", + "member", + "memory", + "mention", + "menu", + "mercy", + "merge", + "merit", + "merry", + "mesh", + "message", + "metal", + "method", + "middle", + "midnight", + "milk", + "million", + "mimic", + "mind", + "minimum", + "minor", + "minute", + "miracle", + "mirror", + "misery", + "miss", + "mistake", + "mix", + "mixed", + "mixture", + "mobile", + "model", + "modify", + "mom", + "moment", + "monitor", + "monkey", + "monster", + "month", + "moon", + "moral", + "more", + "morning", + "mosquito", + "mother", + "motion", + "motor", + "mountain", + "mouse", + "move", + "movie", + "much", + "muffin", + "mule", + "multiply", + "muscle", + "museum", + "mushroom", + "music", + "must", + "mutual", + "myself", + "mystery", + "myth", + "naive", + "name", + "napkin", + "narrow", + "nasty", + "nation", + "nature", + "near", + "neck", + "need", + "negative", + "neglect", + "neither", + "nephew", + "nerve", + "nest", + "net", + "network", + "neutral", + "never", + "news", + "next", + "nice", + "night", + "noble", + "noise", + "nominee", + "noodle", + "normal", + "north", + "nose", + "notable", + "note", + "nothing", + "notice", + "novel", + "now", + "nuclear", + "number", + "nurse", + "nut", + "oak", + "obey", + "object", + "oblige", + "obscure", + "observe", + "obtain", + "obvious", + "occur", + "ocean", + "october", + "odor", + "off", + "offer", + "office", + "often", + "oil", + "okay", + "old", + "olive", + "olympic", + "omit", + "once", + "one", + "onion", + "online", + "only", + "open", + "opera", + "opinion", + "oppose", + "option", + "orange", + "orbit", + "orchard", + "order", + "ordinary", + "organ", + "orient", + "original", + "orphan", + "ostrich", + "other", + "outdoor", + "outer", + "output", + "outside", + "oval", + "oven", + "over", + "own", + "owner", + "oxygen", + "oyster", + "ozone", + "pact", + "paddle", + "page", + "pair", + "palace", + "palm", + "panda", + "panel", + "panic", + "panther", + "paper", + "parade", + "parent", + "park", + "parrot", + "party", + "pass", + "patch", + "path", + "patient", + "patrol", + "pattern", + "pause", + "pave", + "payment", + "peace", + "peanut", + "pear", + "peasant", + "pelican", + "pen", + "penalty", + "pencil", + "people", + "pepper", + "perfect", + "permit", + "person", + "pet", + "phone", + "photo", + "phrase", + "physical", + "piano", + "picnic", + "picture", + "piece", + "pig", + "pigeon", + "pill", + "pilot", + "pink", + "pioneer", + "pipe", + "pistol", + "pitch", + "pizza", + "place", + "planet", + "plastic", + "plate", + "play", + "please", + "pledge", + "pluck", + "plug", + "plunge", + "poem", + "poet", + "point", + "polar", + "pole", + "police", + "pond", + "pony", + "pool", + "popular", + "portion", + "position", + "possible", + "post", + "potato", + "pottery", + "poverty", + "powder", + "power", + "practice", + "praise", + "predict", + "prefer", + "prepare", + "present", + "pretty", + "prevent", + "price", + "pride", + "primary", + "print", + "priority", + "prison", + "private", + "prize", + "problem", + "process", + "produce", + "profit", + "program", + "project", + "promote", + "proof", + "property", + "prosper", + "protect", + "proud", + "provide", + "public", + "pudding", + "pull", + "pulp", + "pulse", + "pumpkin", + "punch", + "pupil", + "puppy", + "purchase", + "purity", + "purpose", + "purse", + "push", + "put", + "puzzle", + "pyramid", + "quality", + "quantum", + "quarter", + "question", + "quick", + "quit", + "quiz", + "quote", + "rabbit", + "raccoon", + "race", + "rack", + "radar", + "radio", + "rail", + "rain", + "raise", + "rally", + "ramp", + "ranch", + "random", + "range", + "rapid", + "rare", + "rate", + "rather", + "raven", + "raw", + "razor", + "ready", + "real", + "reason", + "rebel", + "rebuild", + "recall", + "receive", + "recipe", + "record", + "recycle", + "reduce", + "reflect", + "reform", + "refuse", + "region", + "regret", + "regular", + "reject", + "relax", + "release", + "relief", + "rely", + "remain", + "remember", + "remind", + "remove", + "render", + "renew", + "rent", + "reopen", + "repair", + "repeat", + "replace", + "report", + "require", + "rescue", + "resemble", + "resist", + "resource", + "response", + "result", + "retire", + "retreat", + "return", + "reunion", + "reveal", + "review", + "reward", + "rhythm", + "rib", + "ribbon", + "rice", + "rich", + "ride", + "ridge", + "rifle", + "right", + "rigid", + "ring", + "riot", + "ripple", + "risk", + "ritual", + "rival", + "river", + "road", + "roast", + "robot", + "robust", + "rocket", + "romance", + "roof", + "rookie", + "room", + "rose", + "rotate", + "rough", + "round", + "route", + "royal", + "rubber", + "rude", + "rug", + "rule", + "run", + "runway", + "rural", + "sad", + "saddle", + "sadness", + "safe", + "sail", + "salad", + "salmon", + "salon", + "salt", + "salute", + "same", + "sample", + "sand", + "satisfy", + "satoshi", + "sauce", + "sausage", + "save", + "say", + "scale", + "scan", + "scare", + "scatter", + "scene", + "scheme", + "school", + "science", + "scissors", + "scorpion", + "scout", + "scrap", + "screen", + "script", + "scrub", + "sea", + "search", + "season", + "seat", + "second", + "secret", + "section", + "security", + "seed", + "seek", + "segment", + "select", + "sell", + "seminar", + "senior", + "sense", + "sentence", + "series", + "service", + "session", + "settle", + "setup", + "seven", + "shadow", + "shaft", + "shallow", + "share", + "shed", + "shell", + "sheriff", + "shield", + "shift", + "shine", + "ship", + "shiver", + "shock", + "shoe", + "shoot", + "shop", + "short", + "shoulder", + "shove", + "shrimp", + "shrug", + "shuffle", + "shy", + "sibling", + "sick", + "side", + "siege", + "sight", + "sign", + "silent", + "silk", + "silly", + "silver", + "similar", + "simple", + "since", + "sing", + "siren", + "sister", + "situate", + "six", + "size", + "skate", + "sketch", + "ski", + "skill", + "skin", + "skirt", + "skull", + "slab", + "slam", + "sleep", + "slender", + "slice", + "slide", + "slight", + "slim", + "slogan", + "slot", + "slow", + "slush", + "small", + "smart", + "smile", + "smoke", + "smooth", + "snack", + "snake", + "snap", + "sniff", + "snow", + "soap", + "soccer", + "social", + "sock", + "soda", + "soft", + "solar", + "soldier", + "solid", + "solution", + "solve", + "someone", + "song", + "soon", + "sorry", + "sort", + "soul", + "sound", + "soup", + "source", + "south", + "space", + "spare", + "spatial", + "spawn", + "speak", + "special", + "speed", + "spell", + "spend", + "sphere", + "spice", + "spider", + "spike", + "spin", + "spirit", + "split", + "spoil", + "sponsor", + "spoon", + "sport", + "spot", + "spray", + "spread", + "spring", + "spy", + "square", + "squeeze", + "squirrel", + "stable", + "stadium", + "staff", + "stage", + "stairs", + "stamp", + "stand", + "start", + "state", + "stay", + "steak", + "steel", + "stem", + "step", + "stereo", + "stick", + "still", + "sting", + "stock", + "stomach", + "stone", + "stool", + "story", + "stove", + "strategy", + "street", + "strike", + "strong", + "struggle", + "student", + "stuff", + "stumble", + "style", + "subject", + "submit", + "subway", + "success", + "such", + "sudden", + "suffer", + "sugar", + "suggest", + "suit", + "summer", + "sun", + "sunny", + "sunset", + "super", + "supply", + "supreme", + "sure", + "surface", + "surge", + "surprise", + "surround", + "survey", + "suspect", + "sustain", + "swallow", + "swamp", + "swap", + "swarm", + "swear", + "sweet", + "swift", + "swim", + "swing", + "switch", + "sword", + "symbol", + "symptom", + "syrup", + "system", + "table", + "tackle", + "tag", + "tail", + "talent", + "talk", + "tank", + "tape", + "target", + "task", + "taste", + "tattoo", + "taxi", + "teach", + "team", + "tell", + "ten", + "tenant", + "tennis", + "tent", + "term", + "test", + "text", + "thank", + "that", + "theme", + "then", + "theory", + "there", + "they", + "thing", + "this", + "thought", + "three", + "thrive", + "throw", + "thumb", + "thunder", + "ticket", + "tide", + "tiger", + "tilt", + "timber", + "time", + "tiny", + "tip", + "tired", + "tissue", + "title", + "toast", + "tobacco", + "today", + "toddler", + "toe", + "together", + "toilet", + "token", + "tomato", + "tomorrow", + "tone", + "tongue", + "tonight", + "tool", + "tooth", + "top", + "topic", + "topple", + "torch", + "tornado", + "tortoise", + "toss", + "total", + "tourist", + "toward", + "tower", + "town", + "toy", + "track", + "trade", + "traffic", + "tragic", + "train", + "transfer", + "trap", + "trash", + "travel", + "tray", + "treat", + "tree", + "trend", + "trial", + "tribe", + "trick", + "trigger", + "trim", + "trip", + "trophy", + "trouble", + "truck", + "true", + "truly", + "trumpet", + "trust", + "truth", + "try", + "tube", + "tuition", + "tumble", + "tuna", + "tunnel", + "turkey", + "turn", + "turtle", + "twelve", + "twenty", + "twice", + "twin", + "twist", + "two", + "type", + "typical", + "ugly", + "umbrella", + "unable", + "unaware", + "uncle", + "uncover", + "under", + "undo", + "unfair", + "unfold", + "unhappy", + "uniform", + "unique", + "unit", + "universe", + "unknown", + "unlock", + "until", + "unusual", + "unveil", + "update", + "upgrade", + "uphold", + "upon", + "upper", + "upset", + "urban", + "urge", + "usage", + "use", + "used", + "useful", + "useless", + "usual", + "utility", + "vacant", + "vacuum", + "vague", + "valid", + "valley", + "valve", + "van", + "vanish", + "vapor", + "various", + "vast", + "vault", + "vehicle", + "velvet", + "vendor", + "venture", + "venue", + "verb", + "verify", + "version", + "very", + "vessel", + "veteran", + "viable", + "vibrant", + "vicious", + "victory", + "video", + "view", + "village", + "vintage", + "violin", + "virtual", + "virus", + "visa", + "visit", + "visual", + "vital", + "vivid", + "vocal", + "voice", + "void", + "volcano", + "volume", + "vote", + "voyage", + "wage", + "wagon", + "wait", + "walk", + "wall", + "walnut", + "want", + "warfare", + "warm", + "warrior", + "wash", + "wasp", + "waste", + "water", + "wave", + "way", + "wealth", + "weapon", + "wear", + "weasel", + "weather", + "web", + "wedding", + "weekend", + "weird", + "welcome", + "west", + "wet", + "whale", + "what", + "wheat", + "wheel", + "when", + "where", + "whip", + "whisper", + "wide", + "width", + "wife", + "wild", + "will", + "win", + "window", + "wine", + "wing", + "wink", + "winner", + "winter", + "wire", + "wisdom", + "wise", + "wish", + "witness", + "wolf", + "woman", + "wonder", + "wood", + "wool", + "word", + "work", + "world", + "worry", + "worth", + "wrap", + "wreck", + "wrestle", + "wrist", + "write", + "wrong", + "yard", + "year", + "yellow", + "you", + "young", + "youth", + "zebra", + "zero", + "zone", + "zoo", +]; diff --git a/KeeperSdk/src/utils/types.ts b/KeeperSdk/src/utils/types.ts index 02113ea6..fde5ac3d 100644 --- a/KeeperSdk/src/utils/types.ts +++ b/KeeperSdk/src/utils/types.ts @@ -1,11 +1,13 @@ /** A value of type T or null. Use this for caches and computed-but-empty results. */ -export type Nullable = T | null +export type Nullable = T | null; /** A value of type T or undefined. Use this for "not provided" inputs. */ -export type Optional = T | undefined +export type Optional = T | undefined; /** Recursively makes every property optional. */ -export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T +export type DeepPartial = T extends object + ? { [K in keyof T]?: DeepPartial } + : T; /** Helper to mark properties as readonly. */ -export type Immutable = { readonly [K in keyof T]: T[K] } +export type Immutable = { readonly [K in keyof T]: T[K] }; diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 815ecd3d..4782ae66 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -1,933 +1,1613 @@ import { - Auth, - KeeperEnvironment, - syncDown, - DRecord, - DRecordMetadata, - DSharedFolder, - DTeam, - DUserFolder, - Authentication, -} from '@keeper-security/keeperapi' -import type { SyncResult, SyncLogFormat, VaultStorage, SessionStorage, AuthUI3 } from '@keeper-security/keeperapi' -import { InMemoryStorage } from '../storage/InMemoryStorage' -import { SessionManager } from '../auth/SessionManager' -import { ConsoleAuthUI } from '../auth/ConsoleAuthUI' -import { searchRecords, formatRecord, getRecordTitle, getRecordType } from '../records/RecordUtils' -import { - addRecord as addRecordOp, - updateRecord as updateRecordOp, - deleteRecord as deleteRecordOp, - getRecordHistory as getRecordHistoryOp, - moveRecord as moveRecordOp, -} from '../records/RecordOperations' + Auth, + KeeperEnvironment, + syncDown, + DRecord, + DRecordMetadata, + DSharedFolder, + DTeam, + DUserFolder, + Authentication, + normal64Bytes, + platform, +} from "@keeper-security/keeperapi"; import type { - NewRecordInput, - TypedRecordData, - AddRecordResult, - UpdateRecordResult, - DeleteRecordResult, - RecordHistoryResult, - MoveRecordInput, - MoveRecordResult, -} from '../records/RecordOperations' + SyncResult, + SyncLogFormat, + VaultStorage, + SessionStorage, + AuthUI3, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +import { SessionManager } from "../auth/SessionManager"; +import { getSdkPlatform } from "../platform"; import { - shareRecord as shareRecordOp, - removeRecordShare as removeRecordShareOp, - getRecordShareInfo as getRecordShareInfoOp, -} from '../sharing/Sharing' -import type { - ShareRecordInput, - ShareRecordResult, - RemoveShareInput, - RemoveShareResult, - RecordShareInfo, -} from '../sharing/Sharing' -import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' -import { FolderKind, VaultObjectKind } from '../folders/folderHelpers' -import type { ChangeDirectoryResult, VaultFolderSession } from '../folders/changeDirectory' -import type { AddFolderInput, AddFolderResult, MkdirOptions } from '../folders/addFolder' -import type { UpdateFolderInput, UpdateFolderResult, RenameFolderResult } from '../folders/updateFolder' -import type { DeleteFolderResult, RmdirOptions } from '../folders/deleteFolder' -import type { FolderTreeBuildOptions } from '../folders/folderTree' -import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' -import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' -import type { ShareFolderInput, ShareFolderResult } from '../sharedFolders/shareFolder' -import { FolderManager } from '../folders/FolderManager' -import type { SharedFolderPermissionsInput } from '../folders/FolderManager' -import { SharedFolderManager } from '../sharedFolders/SharedFolderManager' -import { TeamManager } from '../teams/TeamManager' -import type { ListTeamRow, ListTeamsOptions } from '../teams/listTeams' -import type { TeamView } from '../teams/viewTeam' -import type { AddTeamInput, AddTeamResult } from '../teams/addTeam' -import type { UpdateTeamInput, UpdateTeamResult } from '../teams/updateTeam' -import type { DeleteTeamInput, DeleteTeamResult } from '../teams/deleteTeam' + toSessionParams, + type SessionRestoreInput, +} from "../auth/sessionRestore"; import { - RoleManager, - type AddRoleInput, - type AddRoleResult, - type DeleteRoleInput, - type DeleteRoleResult, - type ListRoleRow, - type ListRolesOptions, - type RoleView, - type UpdateRoleInput, - type UpdateRoleResult, -} from '../roles' + searchRecords, + formatRecord, + getRecordTitle, + getRecordType, +} from "../records/RecordUtils"; import { - EnterpriseReportManager, - runPasswordReport, - type AuditReportOptions, - type AuditReportResult, - type ActionReportOptions, - type ActionReportResult, - type PasswordReportOptions, - type PasswordReportResult, -} from '../enterpriseReport' -import { UserManager } from '../users/UserManager' -import { NestedShareFolderManager } from '../nestedShareFolders/NestedShareFolderManager' -import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' + addRecord as addRecordOp, + updateRecord as updateRecordOp, + deleteRecord as deleteRecordOp, + getRecordHistory as getRecordHistoryOp, + moveRecord as moveRecordOp, +} from "../records/RecordOperations"; +import { + EnterpriseReportManager, + runPasswordReport, + type AuditReportOptions, + type AuditReportResult, + type ActionReportOptions, + type ActionReportResult, + type PasswordReportOptions, + type PasswordReportResult, +} from "../enterpriseReport"; +import type { + NewRecordInput, + TypedRecordData, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from "../records/RecordOperations"; import { - formatListNsfTable, - renderListNsfAsciiTable, - formatListNsfOutput, -} from '../nestedShareFolders/listNsf' -import { formatNsfDetail } from '../nestedShareFolders/getNsf' -import { formatRemoveNsfPreview } from '../nestedShareFolders/removeNsfRecord' + shareRecord as shareRecordOp, + removeRecordShare as removeRecordShareOp, + getRecordShareInfo as getRecordShareInfoOp, +} from "../sharing/Sharing"; +import type { + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, + RecordShareInfo, +} from "../sharing/Sharing"; +import type { + ListFolderOptions, + ListFolderResult, +} from "../folders/listFolder"; +import { FolderKind, VaultObjectKind } from "../folders/folderHelpers"; +import type { + ChangeDirectoryResult, + TryResolvePathResult, + VaultFolderSession, +} from "../folders/changeDirectory"; +import type { + AddFolderInput, + AddFolderResult, + MkdirOptions, +} from "../folders/addFolder"; +import type { + UpdateFolderInput, + UpdateFolderResult, + RenameFolderResult, +} from "../folders/updateFolder"; +import type { DeleteFolderResult, RmdirOptions } from "../folders/deleteFolder"; +import type { FolderTreeBuildOptions } from "../folders/folderTree"; +import type { GetFolderOptions, GetFolderResult } from "../folders/getFolder"; +import type { + ListSharedFolderRow, + ListSharedFoldersOptions, +} from "../sharedFolders/listSharedFolders"; +import type { + ShareFolderInput, + ShareFolderResult, +} from "../sharedFolders/shareFolder"; +import type { + DownloadMembershipOptions, + MembershipData, +} from "../sharedFolders/downloadMembership"; +import type { + ApplyMembershipInput, + ApplyMembershipOptions, + ApplyMembershipResult, +} from "../sharedFolders/applyMembership"; +import { FolderManager } from "../folders/FolderManager"; +import type { SharedFolderPermissionsInput } from "../folders/FolderManager"; +import { SharedFolderManager } from "../sharedFolders/SharedFolderManager"; +import { TeamManager } from "../teams/TeamManager"; +import type { ListTeamRow, ListTeamsOptions } from "../teams/listTeams"; +import type { TeamView } from "../teams/viewTeam"; +import type { AddTeamInput, AddTeamResult } from "../teams/addTeam"; +import type { UpdateTeamInput, UpdateTeamResult } from "../teams/updateTeam"; +import type { DeleteTeamInput, DeleteTeamResult } from "../teams/deleteTeam"; +import type { ChangeTeamRolesInput, TeamRoleResult } from "../teams/teamRole"; +import { + RoleManager, + type AddRoleInput, + type AddRoleResult, + type DeleteRoleInput, + type DeleteRoleResult, + type ListRoleRow, + type ListRolesOptions, + type RoleView, + type UpdateRoleInput, + type UpdateRoleResult, + type SetRoleEnforcementsInput, + type SetRoleEnforcementsResult, + type FormattedRoleEnforcementTable, + type CopyRoleInput, + type CopyRoleResult, + type FormattedCopyRoleTable, + type AddUsersToRolesInput, + type RemoveUsersFromRolesInput, + type RoleUserResult, + type FormattedRoleUserTable, + type ManageRoleNodesInput, + type ManageRoleNodesResult, + type FormattedManageRoleNodesTable, + type ChangeRolePrivilegesInput, + type ChangeRolePrivilegesResult, + type FormattedRolePrivilegeTable, +} from "../roles"; +import { UserManager } from "../users/UserManager"; +import { NestedShareFolderManager } from "../nestedShareFolders/NestedShareFolderManager"; +import { isNestedShareFolder } from "../nestedShareFolders/nsfHelpers"; +import type { + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + FormattedListNsfTable, + GetNsfOptions, + GetNsfResult, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + KeepNsfShortcutInput, + KeepNsfShortcutResult, + LinkNsfRecordResult, + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + ListNsfShortcutsOptions, + MkdirNsfInput, + MkdirNsfResult, + NsfShortcutRow, + RemoveNsfFolderInput, + RemoveNsfFolderResult, + RemoveNsfRecordInput, + RemoveNsfRecordResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, +} from "../nestedShareFolders/nsfTypes"; import type { - AddNsfRecordInput, - AddNsfRecordResult, - AddNsfRecordsInput, - AddNsfRecordsResult, - GetNsfOptions, - GetNsfResult, - GetNsfRecordDetailsInput, - GetNsfRecordDetailsResult, - LinkNsfRecordResult, - ListNsfFormatInput, - ListNsfOptions, - ListNsfRow, - FormattedListNsfTable, - MkdirNsfInput, - MkdirNsfResult, - RemoveNsfFolderInput, - RemoveNsfFolderResult, - RemoveNsfRecordInput, - RemoveNsfRecordResult, - UpdateNsfRecordInput, - UpdateNsfRecordItemInput, - UpdateNsfRecordsInput, - UpdateNsfRecordResult, - UpdateNsfRecordResultItem, -} from '../nestedShareFolders/nsfTypes' + UpdateNsfFolderBatchItem, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + UpdateNsfFoldersResult, +} from "../nestedShareFolders/updateNsfFolder"; import type { - ListUserRow, - ListUsersOptions, - UserView, - AddUserInput, - AddUserResult, - FormatAddUserResultOptions, - FormattedAddUserTable, - UpdateUserInput, - UpdateUserResult, - FormattedUpdateUserTable, - DeleteUserInput, - DeleteUserResult, - FormattedDeleteUserTable, - UserActionInput, - UserActionResult, - FormattedUserActionTable, - AliasUserInput, - AliasUserResult, - AddUsersToTeamsInput, - RemoveUsersFromTeamsInput, - TeamUserResult, - FormattedTeamUserTable, -} from '../users/userTypes' -import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' -import type { ILogger } from '../utils' + ListUserRow, + ListUsersOptions, + UserView, + AddUserInput, + AddUserResult, + FormatAddUserResultOptions, + FormattedAddUserTable, + UpdateUserInput, + UpdateUserResult, + FormattedUpdateUserTable, + DeleteUserInput, + DeleteUserResult, + FormattedDeleteUserTable, + UserActionInput, + UserActionResult, + FormattedUserActionTable, + AliasUserInput, + AliasUserResult, + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserResult, + FormattedTeamUserTable, +} from "../users/userTypes"; +import type { + UpdateUsersOnTeamsInput, + UpdateUsersOnTeamsResult, + FormattedUpdateTeamUserTable, +} from "../users/updateTeamUser"; +import { buildWhoamiInfo, type WhoamiInfo } from "../account/whoamiInfo"; +import { + ConsoleLogger, + LogLevel, + KeeperSdkError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + ResultCodes, +} from "../utils"; +import type { ILogger } from "../utils"; enum VaultStatus { - RecordNotFound = 'RECORD_NOT_FOUND', - RecordKeyNotFound = 'RECORD_KEY_NOT_FOUND', + RecordNotFound = "RECORD_NOT_FOUND", + RecordKeyNotFound = "RECORD_KEY_NOT_FOUND", } export type KeeperVaultConfig = { - host?: string - clientVersion?: string - configDir?: string - useConsoleAuth?: boolean - logFormat?: SyncLogFormat - logLevel?: LogLevel - autoSync?: boolean - storage?: InMemoryStorage - sessionStorage?: SessionManager - authUI?: AuthUI3 -} + host?: string; + clientVersion?: string; + configDir?: string; + useConsoleAuth?: boolean; + logFormat?: SyncLogFormat; + logLevel?: LogLevel; + autoSync?: boolean; + storage?: InMemoryStorage; + sessionStorage?: SessionManager; + authUI?: AuthUI3; +}; export type VaultSummary = { - recordCount: number - sharedFolderCount: number - teamCount: number - folderCount: number -} + recordCount: number; + sharedFolderCount: number; + teamCount: number; + folderCount: number; +}; export class KeeperVault { - private auth: Auth | null = null - private readonly storage: InMemoryStorage - private readonly sessionManager: SessionManager - private readonly authUI: AuthUI3 - private readonly config: Required> - private readonly log: ILogger - private synced = false - private batchDepth = 0 - private readonly folderSession: VaultFolderSession = FolderManager.createSession() - private readonly folderManager: FolderManager - private readonly sharedFolderManager: SharedFolderManager - private readonly teamManager: TeamManager - private readonly roleManager: RoleManager - private readonly enterpriseReportManager: EnterpriseReportManager - private readonly userManager: UserManager - private readonly nestedShareFolderManager: NestedShareFolderManager - - constructor(config?: KeeperVaultConfig) { - this.config = { - host: config?.host || KeeperEnvironment.Prod, - clientVersion: config?.clientVersion || SdkDefaults.CLIENT_VERSION, - configDir: config?.configDir ?? '', - useConsoleAuth: config?.useConsoleAuth !== false, - logFormat: config?.logFormat || SdkDefaults.LOG_FORMAT, - logLevel: config?.logLevel ?? LogLevel.INFO, - autoSync: config?.autoSync !== false, - } - - this.log = new ConsoleLogger(this.config.logLevel) - this.storage = config?.storage || new InMemoryStorage() - this.sessionManager = config?.sessionStorage || new SessionManager(this.config.configDir || undefined) - this.authUI = config?.authUI || new ConsoleAuthUI() - - const authProvider = () => this.getAuthOrThrow() - this.folderManager = new FolderManager(this.storage, this.folderSession, authProvider) - this.sharedFolderManager = new SharedFolderManager(this.storage, authProvider) - this.teamManager = new TeamManager(authProvider) - this.roleManager = new RoleManager(authProvider) - this.enterpriseReportManager = new EnterpriseReportManager(authProvider) - this.userManager = new UserManager(authProvider) - this.nestedShareFolderManager = new NestedShareFolderManager(this.storage, authProvider) - } - - public getNestedShareFolderManager(): NestedShareFolderManager { - return this.nestedShareFolderManager - } - - public getFolderManager(): FolderManager { - return this.folderManager - } - - public getSharedFolderManager(): SharedFolderManager { - return this.sharedFolderManager - } - - public getTeamManager(): TeamManager { - return this.teamManager - } - - public getRoleManager(): RoleManager { - return this.roleManager - } - - public getEnterpriseReportManager(): EnterpriseReportManager { - return this.enterpriseReportManager - } - - /** @deprecated Use getEnterpriseReportManager() */ - public getAuditReportManager(): EnterpriseReportManager { - return this.enterpriseReportManager - } - - /** @deprecated Use getEnterpriseReportManager() */ - public getActionReportManager(): EnterpriseReportManager { - return this.enterpriseReportManager - } - - private async createAuth(options?: { useSessionResumption?: boolean }): Promise { - const host = this.config.host - const baseDeviceConfig = await this.sessionManager.getDeviceConfig(host) - const deviceConfig = { - ...baseDeviceConfig, - deviceName: baseDeviceConfig.deviceName || SdkDefaults.DEVICE_NAME, - } - - const sessionStorage: SessionStorage = - options?.useSessionResumption === false - ? { - getCloneCode: async () => null, - saveCloneCode: (h, u, c) => this.sessionManager.saveCloneCode(h, u, c), - getSessionParameters: () => this.sessionManager.getSessionParameters(), - saveSessionParameters: (p) => this.sessionManager.saveSessionParameters(p), - } - : this.sessionManager - - return new Auth({ - host, - clientVersion: this.config.clientVersion, - deviceConfig, - authUI3: this.config.useConsoleAuth ? this.authUI : undefined, - sessionStorage, - onDeviceConfig: this.sessionManager.createOnDeviceConfig(host), - useSessionResumption: options?.useSessionResumption, - }) - } - - private getAuthOrThrow(): Auth { - if (!this.auth || !this.auth.sessionToken) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } - return this.auth - } - - public async login(username: string, password: string): Promise { - this.auth = await this.createAuth({ useSessionResumption: false }) - this.sessionManager.setLastUsername(username) - - await this.auth.loginV3({ - username, - password, - loginType: Authentication.LoginType.NORMAL, - loginMethod: Authentication.LoginMethod.EXISTING_ACCOUNT, - }) - - this.synced = false - this.log.info(`Logged in as ${username}`) - } - - public async loginWithSessionToken(username: string, sessionToken: string): Promise { - const deviceConfig = await this.sessionManager.getDeviceConfig(this.config.host) - - if (!deviceConfig.deviceToken || !deviceConfig.privateKey) { - throw new KeeperSdkError( - 'Device is not registered for this host. Perform a normal login first to register the device before using session token login.', - ResultCodes.DEVICE_NOT_REGISTERED - ) - } - - this.auth = await this.createAuth() - this.sessionManager.setLastUsername(username) - - await this.auth.loginV3({ - username, - givenSessionToken: sessionToken, - loginType: Authentication.LoginType.NORMAL, - loginMethod: Authentication.LoginMethod.EXISTING_ACCOUNT, - }) - - if (!this.auth.sessionToken) { - throw new KeeperSdkError( - 'Session token login failed — token may be expired or invalid.', - ResultCodes.SESSION_TOKEN_EXPIRED - ) - } - - this.synced = false - this.log.info(`Logged in as ${username} (via session token)`) - } - - public getSessionToken(): string | undefined { - return this.auth?.sessionToken || undefined - } - - public async resumeSession(): Promise { - const username = await this.sessionManager.getLastUsername() - if (!username) { - throw new KeeperSdkError( - 'No previous login found. Perform a normal login first.', - ResultCodes.NO_PREVIOUS_LOGIN - ) - } - - this.sessionManager.setLastUsername(username) - - const deviceConfig = await this.sessionManager.getDeviceConfig(this.config.host) - if (!deviceConfig.deviceToken || !deviceConfig.privateKey) { - throw new KeeperSdkError( - 'Device is not registered for this host. Perform a normal login first.', - ResultCodes.DEVICE_NOT_REGISTERED - ) - } - - const cloneCode = await this.sessionManager.getCloneCode(this.config.host, username) - if (!cloneCode) { - throw new KeeperSdkError( - 'No clone code found. Persistent login not enabled or clone code expired. Perform a normal login.', - ResultCodes.NO_CLONE_CODE - ) - } - - this.auth = await this.createAuth({ useSessionResumption: true }) - - await this.auth.loginV3({ - loginType: Authentication.LoginType.NORMAL, - resumeSessionOnly: true, - }) - - if (!this.auth.sessionToken) { - throw new KeeperSdkError( - 'Persistent login failed — clone code may be expired or persistent login not enabled. Perform a normal login.', - ResultCodes.PERSISTENT_LOGIN_FAILED - ) - } - - this.synced = false - this.log.info(`Session resumed for ${username} (persistent login)`) - } - - public async sync(): Promise { - const auth = this.getAuthOrThrow() - - const result = await syncDown({ - auth, - storage: this.storage, - logFormat: this.config.logFormat, - }) - - this.synced = true - return result - } - - public async batch(fn: () => Promise): Promise { - this.batchDepth++ - try { - await fn() - } finally { - this.batchDepth-- - if (this.batchDepth === 0 && this.config.autoSync) { - await this.sync() - } - } - } - - private async syncIfNeeded(): Promise { - if (this.batchDepth > 0) { - this.synced = false - return - } - if (this.config.autoSync) { - await this.sync() - } else { - this.synced = false - } - } - - public getRecords(): DRecord[] { - return this.storage.getRecords() - } - - public getRecordByUid(uid: string): DRecord | undefined { - return this.storage.getByUid(VaultObjectKind.Record, uid) - } - - public findRecord(uidOrTitle: string): DRecord | undefined { - const byUid = this.getRecordByUid(uidOrTitle) - if (byUid) return byUid - - const lowerUidOrTitle = uidOrTitle.toLowerCase() - return this.getRecords().find((record) => getRecordTitle(record).toLowerCase() === lowerUidOrTitle) - } - - public findRecords(criteria: string): DRecord[] { - return searchRecords(this.getRecords(), criteria) - } - - public getRecordsByVersion(version: number): DRecord[] { - return this.getRecords().filter((record) => record.version === version) - } - - public getRecordsByType(recordType: string): DRecord[] { - return this.getRecords().filter((record) => getRecordType(record) === recordType) - } - - public getRecordMetadata(): DRecordMetadata[] { - return this.storage.getAll(VaultObjectKind.Metadata) - } - - public getRecordMetadataByUid(uid: string): DRecordMetadata | undefined { - return this.storage.getByUid(VaultObjectKind.Metadata, uid) - } - - public getSharedFolders(): DSharedFolder[] { - return this.storage.getAll(FolderKind.SharedFolder) - } - - public getTeams(): DTeam[] { - return this.storage.getAll(VaultObjectKind.Team) - } - - public getUserFolders(): DUserFolder[] { - return this.storage.getAll(FolderKind.UserFolder) - } - - public async listFolder(options?: ListFolderOptions): Promise { - return this.folderManager.listFolder(options ?? {}) - } - - public listSharedFolders(options?: ListSharedFoldersOptions): ListSharedFolderRow[] { - return this.sharedFolderManager.listSharedFolders(options ?? {}) - } - - public async listTeams(options?: ListTeamsOptions): Promise { - return this.teamManager.listTeams(options ?? {}) - } - - public async listUsers(options?: ListUsersOptions): Promise { - return this.userManager.listUsers(options ?? {}) - } - - public async viewUser(identifier: string): Promise { - return this.userManager.viewUser(identifier) - } - - public async addUsers(input: AddUserInput): Promise { - return this.userManager.addUsers(input) - } - - public formatAddUserResult(result: AddUserResult, options: FormatAddUserResultOptions = {}): FormattedAddUserTable { - return this.userManager.formatAddUserResult(result, options) - } - - public async updateUsers(input: UpdateUserInput): Promise { - return this.userManager.updateUsers(input) - } - - public formatUpdateUserResult(result: UpdateUserResult): FormattedUpdateUserTable { - return this.userManager.formatUpdateUserResult(result) - } - - public async deleteUsers(input: DeleteUserInput): Promise { - return this.userManager.deleteUsers(input) - } - - public formatDeleteUserResult(result: DeleteUserResult): FormattedDeleteUserTable { - return this.userManager.formatDeleteUserResult(result) - } - - public async actionUsers(input: UserActionInput): Promise { - return this.userManager.actionUsers(input) - } - - public formatUserActionResult(result: UserActionResult): FormattedUserActionTable { - return this.userManager.formatUserActionResult(result) - } - - public async aliasUser(input: AliasUserInput): Promise { - return this.userManager.aliasUser(input) - } - - public async addUsersToTeams(input: AddUsersToTeamsInput): Promise { - return this.userManager.addUsersToTeams(input) - } - - public async removeUsersFromTeams(input: RemoveUsersFromTeamsInput): Promise { - return this.userManager.removeUsersFromTeams(input) - } - - public formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { - return this.userManager.formatTeamUserResult(result) - } - - public async viewTeam(identifier: string): Promise { - return this.teamManager.viewTeam(identifier) - } - - public async addTeams(input: AddTeamInput): Promise { - const result = await this.teamManager.addTeams(input) - if (result.created > 0) await this.syncIfNeeded() - return result - } - - public async updateTeams(input: UpdateTeamInput): Promise { - const result = await this.teamManager.updateTeams(input) - if (result.updated > 0) await this.syncIfNeeded() - return result - } - - public async deleteTeams(input: DeleteTeamInput): Promise { - const result = await this.teamManager.deleteTeams(input) - if (result.deleted > 0) await this.syncIfNeeded() - return result - } - - public async listRoles(options?: ListRolesOptions): Promise { - return this.roleManager.listRoles(options ?? {}) - } - - public async viewRole(identifier: string): Promise { - return this.roleManager.viewRole(identifier) - } - - public async addRoles(input: AddRoleInput): Promise { - const result = await this.roleManager.addRoles(input) - if (result.created > 0) await this.syncIfNeeded() - return result - } - - public async updateRoles(input: UpdateRoleInput): Promise { - const result = await this.roleManager.updateRoles(input) - if (result.updated > 0) await this.syncIfNeeded() - return result - } - - public async deleteRoles(input: DeleteRoleInput): Promise { - const result = await this.roleManager.deleteRoles(input) - if (result.deleted > 0) await this.syncIfNeeded() - return result - } - - public async runAuditReport(options?: AuditReportOptions): Promise { - return this.enterpriseReportManager.runAuditReport(options ?? {}) - } - - public async runActionReport(options?: ActionReportOptions): Promise { - return this.enterpriseReportManager.runActionReport(options ?? {}) - } - - public async runPasswordReport(options?: PasswordReportOptions): Promise { - return runPasswordReport(this.storage, this.folderSession, options ?? {}) - } - - public async changeDirectory(path: string): Promise { - return this.folderManager.changeDirectory(path) - } - - public getCurrentFolderUid(): string | null { - return this.folderManager.getCurrentFolderUid() - } - - public getWorkingFolderDisplayName(): string { - return this.folderManager.getWorkingFolderDisplayName() - } - - public async getFolder(uidOrName: string, options?: GetFolderOptions): Promise { - return this.folderManager.getFolder(uidOrName, options ?? {}) - } - - public async addFolder(input: AddFolderInput): Promise { - const result = await this.folderManager.addFolder(input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async mkdir( - path: string, - options?: MkdirOptions - ): Promise<{ folderUid: string; success: boolean; message?: string }> { - const result = await this.folderManager.mkdir(path, options ?? {}) - if (result.success) await this.syncIfNeeded() - return result - } - - public async updateFolder(input: UpdateFolderInput): Promise { - const result = await this.folderManager.updateFolder(input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async renameFolder(folderPath: string, newName: string): Promise { - const result = await this.folderManager.renameFolder(folderPath, newName) - if (result.success) await this.syncIfNeeded() - return result - } - - public async updateSharedFolderPermissions( - sharedFolderUid: string, - permissions: SharedFolderPermissionsInput - ): Promise { - const result = await this.folderManager.updateSharedFolderPermissions(sharedFolderUid, permissions) - if (result.success) await this.syncIfNeeded() - return result - } - - public async deleteFolder( - folderRefs: string[], - confirm?: (summary: string) => boolean | Promise - ): Promise { - const result = await this.folderManager.deleteFolder(folderRefs, confirm) - if (result.success) await this.syncIfNeeded() - return result - } - - public async rmdir(patterns: string[], options?: RmdirOptions): Promise { - const result = await this.folderManager.rmdir(patterns, options ?? {}) - if (result.success) await this.syncIfNeeded() - return result - } - - public async tree(options?: FolderTreeBuildOptions): Promise { - this.getAuthOrThrow() - return this.folderManager.folderTreeAscii(options ?? {}) - } - - public getSummary(): VaultSummary { - return { - recordCount: this.storage.getCount(VaultObjectKind.Record), - sharedFolderCount: this.storage.getCount(FolderKind.SharedFolder), - teamCount: this.storage.getCount(VaultObjectKind.Team), - folderCount: this.storage.getCount(FolderKind.UserFolder), - } - } - - public printRecords(showDetails = false): void { - const records = this.getRecords() - if (records.length === 0) { - this.log.info('No records found in vault.') - return - } - this.log.info(`\n=== Vault Records (${records.length}) ===\n`) - for (const record of records) { - this.log.info(formatRecord(record, showDetails)) - } - } - - public async addRecord(input: NewRecordInput): Promise { - const auth = this.getAuthOrThrow() - const result = await addRecordOp(auth, input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async updateRecord(recordUid: string, data: TypedRecordData): Promise { - const auth = this.getAuthOrThrow() - - const record = this.getRecordByUid(recordUid) - if (!record) { - return { recordUid, success: false, status: VaultStatus.RecordNotFound } - } - - const keyBytes = await this.storage.getKeyBytes(recordUid) - if (!keyBytes) { - return { - recordUid, - success: false, - status: VaultStatus.RecordKeyNotFound, - } - } - - const result = await updateRecordOp(auth, recordUid, data, record.revision, keyBytes) - if (result.success) await this.syncIfNeeded() - return result - } - - public async deleteRecord(recordUid: string): Promise { - const auth = this.getAuthOrThrow() - const result = await deleteRecordOp(auth, recordUid) - if (result.success) await this.syncIfNeeded() - return result - } - - public async moveRecord(input: MoveRecordInput): Promise { - const auth = this.getAuthOrThrow() - const result = await moveRecordOp(auth, this.storage, input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async shareRecord(input: ShareRecordInput): Promise { - const auth = this.getAuthOrThrow() - - const record = this.getRecordByUid(input.recordUid) || this.findRecord(input.recordUid) - if (!record) { - return { - recordUid: input.recordUid, - email: input.email, - success: false, - status: VaultStatus.RecordNotFound, - message: `Record "${input.recordUid}" not found`, - } - } - - const keyBytes = await this.storage.getKeyBytes(record.uid) - if (!keyBytes) { - return { - recordUid: record.uid, - email: input.email, - success: false, - status: VaultStatus.RecordKeyNotFound, - message: 'Record key not available', - } - } - - const result = await shareRecordOp(auth, keyBytes, { - ...input, - recordUid: record.uid, - }) - if (result.success) await this.syncIfNeeded() - return result - } - - public async removeRecordShare(input: RemoveShareInput): Promise { - const auth = this.getAuthOrThrow() - const result = await removeRecordShareOp(auth, input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async getRecordShareInfo(recordUid: string): Promise { - const auth = this.getAuthOrThrow() - return getRecordShareInfoOp(auth, recordUid) - } - - public listNestedShareFolders(options?: ListNsfOptions): ListNsfRow[] { - this.getAuthOrThrow() - return this.nestedShareFolderManager.listNestedShareFolders(options ?? {}) - } - - public formatListNsfTable(rows: ListNsfRow[], options?: { columnWidth?: number }): FormattedListNsfTable { - return formatListNsfTable(rows, options ?? {}) - } - - public renderListNsfAsciiTable(table: FormattedListNsfTable, options?: { minColWidth?: number }): string { - return renderListNsfAsciiTable(table, options ?? {}) - } - - public formatListNsfOutput(rows: ListNsfRow[], format?: ListNsfFormatInput): string { - return formatListNsfOutput(rows, format) - } - - public async getNestedShareFolder(identifier: string, options?: GetNsfOptions): Promise { - return this.nestedShareFolderManager.getNestedShareFolder(identifier, options ?? {}) - } - - public formatNsfDetail(result: GetNsfResult, verbose?: boolean): string { - return formatNsfDetail(result, verbose ?? false) - } - - public async linkNestedShareRecord( - recordIdentifier: string, - folderIdentifier: string - ): Promise { - const result = await this.nestedShareFolderManager.linkNestedShareRecord(recordIdentifier, folderIdentifier) - if (result.success) await this.syncIfNeeded() - return result - } - - public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { - const result = await this.nestedShareFolderManager.removeNestedShareRecords(input) - if (result.confirmed) await this.syncIfNeeded() - return result - } - - public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { - return formatRemoveNsfPreview(preview) - } - - public async mkdirNestedShareFolder(input: Omit): Promise { - const current = this.folderSession.currentFolderUid - const baseFolderUid = - current && isNestedShareFolder(this.storage, current) ? current : null - const result = await this.nestedShareFolderManager.mkdirNestedShareFolder({ - ...input, - baseFolderUid, - }) - if (result.created) await this.syncIfNeeded() - return result - } - - public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { - const result = await this.nestedShareFolderManager.removeNestedShareFolders(input) - if (result.confirmed) await this.syncIfNeeded() - return result - } - - public async getNestedShareRecordDetails( - input: GetNsfRecordDetailsInput - ): Promise { - return this.nestedShareFolderManager.getNestedShareRecordDetails(input) - } - - public async updateNestedShareRecords( - input: UpdateNsfRecordInput | UpdateNsfRecordsInput - ): Promise { - const result = await this.nestedShareFolderManager.updateNestedShareRecords(input) - if (result.updated.some((item) => item.success)) await this.syncIfNeeded() - return result - } - - public async updateNestedShareRecord(input: UpdateNsfRecordItemInput): Promise { - const result = await this.nestedShareFolderManager.updateNestedShareRecord(input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async addNestedShareRecords(input: AddNsfRecordsInput): Promise { - const result = await this.nestedShareFolderManager.addNestedShareRecords(input) - if (result.added.some((item) => item.success)) await this.syncIfNeeded() - return result - } - - public async addNestedShareRecord(input: AddNsfRecordInput): Promise { - const result = await this.nestedShareFolderManager.addNestedShareRecord(input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async shareFolder(input: ShareFolderInput): Promise { - const result = await this.sharedFolderManager.shareFolder(input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async getRecordHistory(recordUid: string): Promise { - const auth = this.getAuthOrThrow() - - const keyBytes = await this.storage.getKeyBytes(recordUid) - if (!keyBytes) { - return { recordUid, history: [] } - } - - return getRecordHistoryOp(auth, recordUid, keyBytes) - } - - public getStorage(): VaultStorage { - return this.storage - } - - public getAuth(): Auth { - return this.getAuthOrThrow() - } - - public disconnect(): void { - if (this.auth) { - try { - this.auth.disconnect() - } catch (err) { - this.log.debug('disconnect error:', extractErrorMessage(err)) - } - this.auth = null - } - this.synced = false - this.folderSession.currentFolderUid = null - } - - public async logout(): Promise { - if (this.auth) { - try { - await this.auth.logout() - } catch (err) { - this.log.debug('logout error:', extractErrorMessage(err)) - } - } - this.disconnect() - this.log.info('Logged out.') - } - - public get host(): string { - return this.config.host - } - - public get isLoggedIn(): boolean { - return this.auth !== null && !!this.auth.sessionToken - } - - public get isSynced(): boolean { - return this.synced - } + private auth: Auth | null = null; + private readonly storage: InMemoryStorage; + private readonly sessionManager: SessionManager; + private readonly authUI: AuthUI3; + private readonly config: Required< + Omit + >; + private readonly log: ILogger; + private synced = false; + private batchDepth = 0; + private restoredAccountUid: string | null = null; + private readonly folderSession: VaultFolderSession = + FolderManager.createSession(); + private readonly folderManager: FolderManager; + private readonly sharedFolderManager: SharedFolderManager; + private readonly teamManager: TeamManager; + private readonly roleManager: RoleManager; + private readonly enterpriseReportManager: EnterpriseReportManager; + private readonly userManager: UserManager; + private readonly nestedShareFolderManager: NestedShareFolderManager; + + constructor(config?: KeeperVaultConfig) { + this.config = { + host: config?.host || KeeperEnvironment.Prod, + clientVersion: config?.clientVersion || SdkDefaults.CLIENT_VERSION, + configDir: config?.configDir ?? "", + useConsoleAuth: config?.useConsoleAuth !== false, + logFormat: config?.logFormat || SdkDefaults.LOG_FORMAT, + logLevel: config?.logLevel ?? LogLevel.INFO, + autoSync: config?.autoSync !== false, + }; + + this.log = new ConsoleLogger(this.config.logLevel); + this.storage = config?.storage || new InMemoryStorage(); + this.sessionManager = + config?.sessionStorage || + new SessionManager(this.config.configDir || undefined); + this.authUI = + config?.authUI ?? + getSdkPlatform().createAuthUI(this.config.useConsoleAuth); + + const authProvider = () => this.getAuthOrThrow(); + this.folderManager = new FolderManager( + this.storage, + this.folderSession, + authProvider, + ); + this.sharedFolderManager = new SharedFolderManager( + this.storage, + authProvider, + ); + this.teamManager = new TeamManager(authProvider); + this.roleManager = new RoleManager(authProvider); + this.enterpriseReportManager = new EnterpriseReportManager(authProvider); + this.userManager = new UserManager(authProvider); + this.nestedShareFolderManager = new NestedShareFolderManager( + this.storage, + authProvider, + ); + } + + public getNestedShareFolderManager(): NestedShareFolderManager { + return this.nestedShareFolderManager; + } + + public getFolderManager(): FolderManager { + return this.folderManager; + } + + public getSharedFolderManager(): SharedFolderManager { + return this.sharedFolderManager; + } + + public getTeamManager(): TeamManager { + return this.teamManager; + } + + public getRoleManager(): RoleManager { + return this.roleManager; + } + + public getEnterpriseReportManager(): EnterpriseReportManager { + return this.enterpriseReportManager; + } + + /** @deprecated Use getEnterpriseReportManager() */ + public getAuditReportManager(): EnterpriseReportManager { + return this.enterpriseReportManager; + } + + /** @deprecated Use getEnterpriseReportManager() */ + public getActionReportManager(): EnterpriseReportManager { + return this.enterpriseReportManager; + } + + private async createAuth(options?: { + useSessionResumption?: boolean; + }): Promise { + const host = this.config.host; + const baseDeviceConfig = await this.sessionManager.getDeviceConfig(host); + const deviceConfig = { + ...baseDeviceConfig, + deviceName: baseDeviceConfig.deviceName || SdkDefaults.DEVICE_NAME, + }; + + const sessionStorage: SessionStorage = + options?.useSessionResumption === false + ? { + getCloneCode: async () => null, + saveCloneCode: (h, u, c) => + this.sessionManager.saveCloneCode(h, u, c), + getSessionParameters: () => + this.sessionManager.getSessionParameters(), + saveSessionParameters: (p) => + this.sessionManager.saveSessionParameters(p), + } + : this.sessionManager; + + return new Auth({ + host, + clientVersion: this.config.clientVersion, + deviceConfig, + authUI3: this.config.useConsoleAuth ? this.authUI : undefined, + sessionStorage, + onDeviceConfig: this.sessionManager.createOnDeviceConfig(host), + useSessionResumption: options?.useSessionResumption, + }); + } + + private getAuthOrThrow(): Auth { + if (!this.auth || !this.auth.sessionToken) { + throw new KeeperSdkError( + "Not logged in. Call login() first.", + ResultCodes.NOT_LOGGED_IN, + ); + } + return this.auth; + } + + public async login(username: string, password: string): Promise { + this.auth = await this.createAuth({ useSessionResumption: false }); + this.sessionManager.setLastUsername(username); + + await this.auth.loginV3({ + username, + password, + loginType: Authentication.LoginType.NORMAL, + loginMethod: Authentication.LoginMethod.EXISTING_ACCOUNT, + }); + + this.synced = false; + this.log.info(`Logged in as ${username}`); + } + + public async loginWithSessionToken( + username: string, + sessionToken: string, + ): Promise { + const deviceConfig = await this.sessionManager.getDeviceConfig( + this.config.host, + ); + + if (!deviceConfig.deviceToken || !deviceConfig.privateKey) { + throw new KeeperSdkError( + "Device is not registered for this host. Perform a normal login first to register the device before using session token login.", + ResultCodes.DEVICE_NOT_REGISTERED, + ); + } + + this.auth = await this.createAuth(); + this.sessionManager.setLastUsername(username); + + await this.auth.loginV3({ + username, + givenSessionToken: sessionToken, + loginType: Authentication.LoginType.NORMAL, + loginMethod: Authentication.LoginMethod.EXISTING_ACCOUNT, + }); + + if (!this.auth.sessionToken) { + throw new KeeperSdkError( + "Session token login failed — token may be expired or invalid.", + ResultCodes.SESSION_TOKEN_EXPIRED, + ); + } + + this.synced = false; + this.log.info(`Logged in as ${username} (via session token)`); + } + + /** + * Persist device token and private key for this vault host in session storage so + * {@link loginWithSessionToken} and {@link resumeSession} work without a prior password login on this machine. + * Values use the same base64 / base64url decoding as {@link SessionManager} (`normal64Bytes`). + */ + public async registerDevice( + deviceToken: string, + privateKey: string, + options?: { username?: string }, + ): Promise { + const host = this.config.host; + const save = this.sessionManager.createOnDeviceConfig(host); + await save({ + deviceToken: normal64Bytes(deviceToken), + privateKey: normal64Bytes(privateKey), + }); + if (options?.username) { + this.sessionManager.setLastUsername(options.username); + } + this.log.info(`Device credentials stored for host ${host}`); + } + + public getSessionToken(): string | undefined { + return this.auth?.sessionToken || undefined; + } + + /** + * Resume a session from extension-exported {@link SessionRestoreInput}. + * Verifies the token with a lightweight server call so an expired session + * fails here, not later from `sync()`. + */ + public async restoreSession(input: SessionRestoreInput): Promise { + const params = toSessionParams(input); + await this.sessionManager.saveSessionParameters(params); + this.sessionManager.setLastUsername(params.username); + + this.auth = await this.createAuth(); + await this.auth.continueSession(); + + if (!this.auth.sessionToken) { + throw new KeeperSdkError( + "Session restore failed — session token may be expired or invalid.", + ResultCodes.SESSION_TOKEN_EXPIRED, + ); + } + + try { + await this.auth.loadAccountSummary(); + } catch (err) { + const code = extractResultCode(err); + const msg = extractErrorMessage(err); + this.disconnect(); + const isExpired = code === ResultCodes.SESSION_TOKEN_EXPIRED; + throw new KeeperSdkError( + isExpired + ? `Session token rejected by server (${code}): ${msg}. Re-export session JSON and try again.` + : `Session restore failed: ${msg}`, + code ?? ResultCodes.SESSION_TOKEN_EXPIRED, + ); + } + + const accountUid = input.accountUid.trim(); + if ( + this.restoredAccountUid && + accountUid && + this.restoredAccountUid !== accountUid + ) { + await this.storage.clear(); + platform.unloadKeys(); + } else { + // Preserve vault data and continuation token for incremental sync on the same account. + platform.unloadNonUserKeys(); + } + if (accountUid) { + this.restoredAccountUid = accountUid; + } + this.synced = false; + this.log.info(`Session restored for ${params.username}`); + } + + public async getAccountUsername(): Promise { + return ( + this.sessionManager.getLastUsername() ?? this.auth?.username ?? undefined + ); + } + + public async getWhoamiInfo(options?: { + includeVaultCounts?: boolean; + }): Promise { + const auth = this.getAuthOrThrow(); + if (!auth.accountSummary) { + await auth.loadAccountSummary(); + } + const summary = auth.accountSummary; + if (!summary) { + throw new KeeperSdkError( + "Account summary is unavailable.", + ResultCodes.SYNC_FAILED, + ); + } + + const username = auth.username || (await this.getAccountUsername()) || ""; + + return buildWhoamiInfo({ + username, + host: this.host, + accountSummary: summary, + vaultSummary: options?.includeVaultCounts ? this.getSummary() : undefined, + }); + } + + public async resumeSession(): Promise { + const username = await this.sessionManager.getLastUsername(); + if (!username) { + throw new KeeperSdkError( + "No previous login found. Perform a normal login first.", + ResultCodes.NO_PREVIOUS_LOGIN, + ); + } + + this.sessionManager.setLastUsername(username); + + const deviceConfig = await this.sessionManager.getDeviceConfig( + this.config.host, + ); + if (!deviceConfig.deviceToken || !deviceConfig.privateKey) { + throw new KeeperSdkError( + "Device is not registered for this host. Perform a normal login first.", + ResultCodes.DEVICE_NOT_REGISTERED, + ); + } + + const cloneCode = await this.sessionManager.getCloneCode( + this.config.host, + username, + ); + if (!cloneCode) { + throw new KeeperSdkError( + "No clone code found. Persistent login not enabled or clone code expired. Perform a normal login.", + ResultCodes.NO_CLONE_CODE, + ); + } + + this.auth = await this.createAuth({ useSessionResumption: true }); + + await this.auth.loginV3({ + loginType: Authentication.LoginType.NORMAL, + resumeSessionOnly: true, + }); + + if (!this.auth.sessionToken) { + throw new KeeperSdkError( + "Persistent login failed — clone code may be expired or persistent login not enabled. Perform a normal login.", + ResultCodes.PERSISTENT_LOGIN_FAILED, + ); + } + + this.synced = false; + this.log.info(`Session resumed for ${username} (persistent login)`); + } + + public async sync(): Promise { + const auth = this.getAuthOrThrow(); + + try { + const result = await syncDown({ + auth, + storage: this.storage, + logFormat: this.config.logFormat, + }); + if (result.error) { + this.log.error("Sync error:", result.error); + throw new KeeperSdkError( + `Sync failed: ${result.error}`, + ResultCodes.SYNC_FAILED, + ); + } + this.synced = true; + return result; + } catch (e) { + this.log.error("Sync failed:", extractErrorMessage(e)); + throw e; + } + } + + /** Drop incremental sync cursor and crypto caches so the next sync can restart cleanly. */ + public async clearSyncCheckpoint(): Promise { + const checkpoint = await this.storage.get("continuationToken"); + if (checkpoint?.token) { + await this.storage.delete("continuationToken", checkpoint.token); + } + platform.unloadNonUserKeys(); + this.synced = false; + } + + public async batch(fn: () => Promise): Promise { + this.batchDepth++; + try { + await fn(); + } finally { + this.batchDepth--; + if (this.batchDepth === 0 && this.config.autoSync) { + await this.sync(); + } + } + } + + private async syncIfNeeded(): Promise { + if (this.batchDepth > 0) { + this.synced = false; + return; + } + if (this.config.autoSync) { + await this.sync(); + } else { + this.synced = false; + } + } + + public getRecords(): DRecord[] { + return this.storage.getRecords(); + } + + public getRecordByUid(uid: string): DRecord | undefined { + const direct = this.storage.getByUid(VaultObjectKind.Record, uid); + if (direct) return direct; + const lower = uid.toLowerCase(); + return this.getRecords().find( + (record) => record.uid?.toLowerCase() === lower, + ); + } + + public findRecord(uidOrTitle: string): DRecord | undefined { + const byUid = this.getRecordByUid(uidOrTitle); + if (byUid) return byUid; + + const lowerUidOrTitle = uidOrTitle.toLowerCase(); + return this.getRecords().find( + (record) => getRecordTitle(record).toLowerCase() === lowerUidOrTitle, + ); + } + + public findRecords(criteria: string): DRecord[] { + return searchRecords(this.getRecords(), criteria); + } + + public getRecordsByVersion(version: number): DRecord[] { + return this.getRecords().filter((record) => record.version === version); + } + + public getRecordsByType(recordType: string): DRecord[] { + return this.getRecords().filter( + (record) => getRecordType(record) === recordType, + ); + } + + public getRecordMetadata(): DRecordMetadata[] { + return this.storage.getAll(VaultObjectKind.Metadata); + } + + public getRecordMetadataByUid(uid: string): DRecordMetadata | undefined { + return this.storage.getByUid( + VaultObjectKind.Metadata, + uid, + ); + } + + public getSharedFolders(): DSharedFolder[] { + return this.storage.getAll(FolderKind.SharedFolder); + } + + public getTeams(): DTeam[] { + return this.storage.getAll(VaultObjectKind.Team); + } + + public getUserFolders(): DUserFolder[] { + return this.storage.getAll(FolderKind.UserFolder); + } + + public async listFolder( + options?: ListFolderOptions, + ): Promise { + return this.folderManager.listFolder(options ?? {}); + } + + public listSharedFolders( + options?: ListSharedFoldersOptions, + ): ListSharedFolderRow[] { + return this.sharedFolderManager.listSharedFolders(options ?? {}); + } + + public async listTeams(options?: ListTeamsOptions): Promise { + return this.teamManager.listTeams(options ?? {}); + } + + public async listUsers(options?: ListUsersOptions): Promise { + return this.userManager.listUsers(options ?? {}); + } + + public async viewUser(identifier: string): Promise { + return this.userManager.viewUser(identifier); + } + + public async addUsers(input: AddUserInput): Promise { + return this.userManager.addUsers(input); + } + + public formatAddUserResult( + result: AddUserResult, + options: FormatAddUserResultOptions = {}, + ): FormattedAddUserTable { + return this.userManager.formatAddUserResult(result, options); + } + + public async updateUsers(input: UpdateUserInput): Promise { + return this.userManager.updateUsers(input); + } + + public formatUpdateUserResult( + result: UpdateUserResult, + ): FormattedUpdateUserTable { + return this.userManager.formatUpdateUserResult(result); + } + + public async deleteUsers(input: DeleteUserInput): Promise { + return this.userManager.deleteUsers(input); + } + + public formatDeleteUserResult( + result: DeleteUserResult, + ): FormattedDeleteUserTable { + return this.userManager.formatDeleteUserResult(result); + } + + public async actionUsers(input: UserActionInput): Promise { + return this.userManager.actionUsers(input); + } + + public formatUserActionResult( + result: UserActionResult, + ): FormattedUserActionTable { + return this.userManager.formatUserActionResult(result); + } + + public async aliasUser(input: AliasUserInput): Promise { + return this.userManager.aliasUser(input); + } + + public async addUsersToTeams( + input: AddUsersToTeamsInput, + ): Promise { + return this.userManager.addUsersToTeams(input); + } + + public async removeUsersFromTeams( + input: RemoveUsersFromTeamsInput, + ): Promise { + return this.userManager.removeUsersFromTeams(input); + } + + public async updateUsersOnTeams( + input: UpdateUsersOnTeamsInput, + ): Promise { + return this.userManager.updateUsersOnTeams(input); + } + + public formatUpdateTeamUserResult( + result: UpdateUsersOnTeamsResult, + ): FormattedUpdateTeamUserTable { + return this.userManager.formatUpdateTeamUserResult(result); + } + + public renderUpdateTeamUserAsciiTable( + table: FormattedUpdateTeamUserTable, + ): string { + return this.userManager.renderUpdateTeamUserAsciiTable(table); + } + + public async changeTeamRoles( + input: ChangeTeamRolesInput, + ): Promise { + const result = await this.teamManager.changeTeamRoles(input); + if (result.succeeded > 0) await this.syncIfNeeded(); + return result; + } + + public formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { + return this.userManager.formatTeamUserResult(result); + } + + public async viewTeam(identifier: string): Promise { + return this.teamManager.viewTeam(identifier); + } + + public async addTeams(input: AddTeamInput): Promise { + const result = await this.teamManager.addTeams(input); + if (result.created > 0) await this.syncIfNeeded(); + return result; + } + + public async updateTeams(input: UpdateTeamInput): Promise { + const result = await this.teamManager.updateTeams(input); + if (result.updated > 0) await this.syncIfNeeded(); + return result; + } + + public async deleteTeams(input: DeleteTeamInput): Promise { + const result = await this.teamManager.deleteTeams(input); + if (result.deleted > 0) await this.syncIfNeeded(); + return result; + } + + public async listRoles(options?: ListRolesOptions): Promise { + return this.roleManager.listRoles(options ?? {}); + } + + public async viewRole(identifier: string): Promise { + return this.roleManager.viewRole(identifier); + } + + public async addRoles(input: AddRoleInput): Promise { + const result = await this.roleManager.addRoles(input); + if (result.created > 0) await this.syncIfNeeded(); + return result; + } + + public async updateRoles(input: UpdateRoleInput): Promise { + const result = await this.roleManager.updateRoles(input); + if (result.updated > 0) await this.syncIfNeeded(); + return result; + } + + public async deleteRoles(input: DeleteRoleInput): Promise { + const result = await this.roleManager.deleteRoles(input); + if (result.deleted > 0) await this.syncIfNeeded(); + return result; + } + + public async setRoleEnforcements( + input: SetRoleEnforcementsInput, + ): Promise { + return this.roleManager.setRoleEnforcements(input); + } + + public formatSetRoleEnforcementsResult( + result: SetRoleEnforcementsResult, + ): FormattedRoleEnforcementTable { + return this.roleManager.formatSetRoleEnforcementsResult(result); + } + + public renderRoleEnforcementAsciiTable( + table: FormattedRoleEnforcementTable, + ): string { + return this.roleManager.renderRoleEnforcementAsciiTable(table); + } + + public async copyRoles(input: CopyRoleInput): Promise { + const result = await this.roleManager.copyRoles(input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public formatCopyRoleResult(result: CopyRoleResult): FormattedCopyRoleTable { + return this.roleManager.formatCopyRoleResult(result); + } + + public renderCopyRoleAsciiTable(table: FormattedCopyRoleTable): string { + return this.roleManager.renderCopyRoleAsciiTable(table); + } + + public async addUsersToRoles( + input: AddUsersToRolesInput, + ): Promise { + return this.roleManager.addUsersToRoles(input); + } + + public async removeUsersFromRoles( + input: RemoveUsersFromRolesInput, + ): Promise { + return this.roleManager.removeUsersFromRoles(input); + } + + public formatRoleUserResult(result: RoleUserResult): FormattedRoleUserTable { + return this.roleManager.formatRoleUserResult(result); + } + + public renderRoleUserAsciiTable(table: FormattedRoleUserTable): string { + return this.roleManager.renderRoleUserAsciiTable(table); + } + + public async manageRoleNodes( + input: ManageRoleNodesInput, + ): Promise { + return this.roleManager.manageRoleNodes(input); + } + + public formatManageRoleNodesResult( + result: ManageRoleNodesResult, + ): FormattedManageRoleNodesTable { + return this.roleManager.formatManageRoleNodesResult(result); + } + + public renderManageRoleNodesAsciiTable( + table: FormattedManageRoleNodesTable, + ): string { + return this.roleManager.renderManageRoleNodesAsciiTable(table); + } + + public async changeRolePrivileges( + input: ChangeRolePrivilegesInput, + ): Promise { + return this.roleManager.changeRolePrivileges(input); + } + + public formatChangeRolePrivilegesResult( + result: ChangeRolePrivilegesResult, + ): FormattedRolePrivilegeTable { + return this.roleManager.formatChangeRolePrivilegesResult(result); + } + + public renderChangeRolePrivilegesAsciiTable( + table: FormattedRolePrivilegeTable, + ): string { + return this.roleManager.renderChangeRolePrivilegesAsciiTable(table); + } + + public async runAuditReport( + options?: AuditReportOptions, + ): Promise { + return this.enterpriseReportManager.runAuditReport(options ?? {}); + } + + public async runActionReport( + options?: ActionReportOptions, + ): Promise { + return this.enterpriseReportManager.runActionReport(options ?? {}); + } + + public async runPasswordReport( + options?: PasswordReportOptions, + ): Promise { + return runPasswordReport(this.storage, this.folderSession, options ?? {}); + } + + public async changeDirectory(path: string): Promise { + return this.folderManager.changeDirectory(path); + } + + public async tryResolvePath(path: string): Promise { + return this.folderManager.tryResolvePath(path); + } + + public getCurrentFolderUid(): string | null { + return this.folderManager.getCurrentFolderUid(); + } + + public getWorkingFolderDisplayName(): string { + return this.folderManager.getWorkingFolderDisplayName(); + } + + public async getFolder( + uidOrName: string, + options?: GetFolderOptions, + ): Promise { + return this.folderManager.getFolder(uidOrName, options ?? {}); + } + + public async addFolder(input: AddFolderInput): Promise { + const result = await this.folderManager.addFolder(input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async mkdir( + path: string, + options?: MkdirOptions, + ): Promise<{ folderUid: string; success: boolean; message?: string }> { + const result = await this.folderManager.mkdir(path, options ?? {}); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async updateFolder( + input: UpdateFolderInput, + ): Promise { + const result = await this.folderManager.updateFolder(input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async renameFolder( + folderPath: string, + newName: string, + ): Promise { + const result = await this.folderManager.renameFolder(folderPath, newName); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async updateSharedFolderPermissions( + sharedFolderUid: string, + permissions: SharedFolderPermissionsInput, + ): Promise { + const result = await this.folderManager.updateSharedFolderPermissions( + sharedFolderUid, + permissions, + ); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async deleteFolder( + folderRefs: string[], + confirm?: (summary: string) => boolean | Promise, + ): Promise { + const result = await this.folderManager.deleteFolder(folderRefs, confirm); + if (result.success) { + await this.clearSyncCheckpoint(); + await this.syncIfNeeded(); + } + return result; + } + + public async rmdir( + patterns: string[], + options?: RmdirOptions, + ): Promise { + const result = await this.folderManager.rmdir(patterns, options ?? {}); + if (result.success) { + await this.clearSyncCheckpoint(); + await this.syncIfNeeded(); + } + return result; + } + + public async tree(options?: FolderTreeBuildOptions): Promise { + this.getAuthOrThrow(); + return this.folderManager.folderTreeAscii(options ?? {}); + } + + public getSummary(): VaultSummary { + return { + recordCount: this.storage.getCount(VaultObjectKind.Record), + sharedFolderCount: this.storage.getCount(FolderKind.SharedFolder), + teamCount: this.storage.getCount(VaultObjectKind.Team), + folderCount: this.storage.getCount(FolderKind.UserFolder), + }; + } + + public printRecords(showDetails = false): void { + const records = this.getRecords(); + if (records.length === 0) { + this.log.info("No records found in vault."); + return; + } + this.log.info(`\n=== Vault Records (${records.length}) ===\n`); + for (const record of records) { + this.log.info(formatRecord(record, showDetails)); + } + } + + public async addRecord(input: NewRecordInput): Promise { + const auth = this.getAuthOrThrow(); + const result = await addRecordOp(auth, input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async updateRecord( + recordUid: string, + data: TypedRecordData, + ): Promise { + const auth = this.getAuthOrThrow(); + + const record = this.getRecordByUid(recordUid); + if (!record) { + return { recordUid, success: false, status: VaultStatus.RecordNotFound }; + } + + const keyBytes = await this.storage.getKeyBytes(recordUid); + if (!keyBytes) { + return { + recordUid, + success: false, + status: VaultStatus.RecordKeyNotFound, + }; + } + + const result = await updateRecordOp( + auth, + recordUid, + data, + record.revision, + keyBytes, + ); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async deleteRecord(uidOrTitle: string): Promise { + const auth = this.getAuthOrThrow(); + const record = + this.getRecordByUid(uidOrTitle) || this.findRecord(uidOrTitle); + if (!record?.uid) { + return { + recordUid: uidOrTitle, + success: false, + message: `Record "${uidOrTitle}" not found`, + }; + } + const result = await deleteRecordOp(auth, this.storage, record.uid); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async moveRecord(input: MoveRecordInput): Promise { + const auth = this.getAuthOrThrow(); + const result = await moveRecordOp(auth, this.storage, input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async shareRecord( + input: ShareRecordInput, + ): Promise { + const auth = this.getAuthOrThrow(); + + const record = + this.getRecordByUid(input.recordUid) || this.findRecord(input.recordUid); + if (!record) { + return { + recordUid: input.recordUid, + email: input.email, + success: false, + status: VaultStatus.RecordNotFound, + message: `Record "${input.recordUid}" not found`, + }; + } + + const keyBytes = await this.storage.getKeyBytes(record.uid); + if (!keyBytes) { + return { + recordUid: record.uid, + email: input.email, + success: false, + status: VaultStatus.RecordKeyNotFound, + message: "Record key not available", + }; + } + + const result = await shareRecordOp(auth, keyBytes, { + ...input, + recordUid: record.uid, + }); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async removeRecordShare( + input: RemoveShareInput, + ): Promise { + const auth = this.getAuthOrThrow(); + const result = await removeRecordShareOp(auth, input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async getRecordShareInfo( + recordUid: string, + ): Promise { + const auth = this.getAuthOrThrow(); + return getRecordShareInfoOp(auth, recordUid); + } + + public listNestedShareFolders(options?: ListNsfOptions): ListNsfRow[] { + this.getAuthOrThrow(); + return this.nestedShareFolderManager.listNestedShareFolders(options ?? {}); + } + + public formatListNsfTable( + rows: ListNsfRow[], + options?: { columnWidth?: number }, + ): FormattedListNsfTable { + return this.nestedShareFolderManager.formatListNsfTable( + rows, + options ?? {}, + ); + } + + public renderListNsfAsciiTable( + table: FormattedListNsfTable, + options?: { minColWidth?: number }, + ): string { + return this.nestedShareFolderManager.renderListNsfAsciiTable( + table, + options ?? {}, + ); + } + + public formatListNsfOutput( + rows: ListNsfRow[], + format?: ListNsfFormatInput, + ): string { + return this.nestedShareFolderManager.formatListNsfOutput(rows, format); + } + + public async getNestedShareFolder( + identifier: string, + options?: GetNsfOptions, + ): Promise { + return this.nestedShareFolderManager.getNestedShareFolder( + identifier, + options ?? {}, + ); + } + + public formatNsfDetail(result: GetNsfResult, verbose?: boolean): string { + return this.nestedShareFolderManager.formatNsfDetail( + result, + verbose ?? false, + ); + } + + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string, + ): Promise { + const result = await this.nestedShareFolderManager.linkNestedShareRecord( + recordIdentifier, + folderIdentifier, + ); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async removeNestedShareRecords( + input: RemoveNsfRecordInput, + ): Promise { + const result = + await this.nestedShareFolderManager.removeNestedShareRecords(input); + if (result.confirmed) await this.syncIfNeeded(); + return result; + } + + public formatRemoveNsfPreview( + preview: RemoveNsfRecordResult["preview"], + ): string { + return this.nestedShareFolderManager.formatRemoveNsfPreview(preview); + } + + public async mkdirNestedShareFolder( + input: Omit, + ): Promise { + const current = this.folderSession.currentFolderUid; + const baseFolderUid = + current && isNestedShareFolder(this.storage, current) ? current : null; + const result = await this.nestedShareFolderManager.mkdirNestedShareFolder({ + ...input, + baseFolderUid, + }); + if (result.created) await this.syncIfNeeded(); + return result; + } + + public async updateNestedShareFolder( + input: UpdateNsfFolderInput, + ): Promise { + const result = + await this.nestedShareFolderManager.updateNestedShareFolder(input); + if (result.updated) await this.syncIfNeeded(); + return result; + } + + public async updateNestedShareFolders( + updates: UpdateNsfFolderBatchItem[], + ): Promise { + const result = + await this.nestedShareFolderManager.updateNestedShareFolders(updates); + if (result.anyUpdated) await this.syncIfNeeded(); + return result; + } + + public async shareNestedShareFolder( + input: ShareNestedShareFolderInput, + ): Promise { + const result = + await this.nestedShareFolderManager.shareNestedShareFolder(input); + if (result.results.some((item) => item.success)) await this.syncIfNeeded(); + return result; + } + + public async shareNestedShareRecord( + input: ShareNestedShareRecordInput, + ): Promise { + const result = + await this.nestedShareFolderManager.shareNestedShareRecord(input); + if ( + !result.dryRun && + result.results.some((item) => item.success) + ) { + await this.syncIfNeeded(); + } + return result; + } + + public formatNsfRecordSharePlan( + result: ShareNestedShareRecordResult, + ): string { + return this.nestedShareFolderManager.formatNsfRecordSharePlan(result); + } + + public formatNsfRecordShareResults( + results: ShareNestedShareRecordResult["results"], + ): string { + return this.nestedShareFolderManager.formatNsfRecordShareResults(results); + } + + public listNsfShortcuts( + options: ListNsfShortcutsOptions = {}, + ): NsfShortcutRow[] { + return this.nestedShareFolderManager.listNsfShortcuts(options); + } + + public formatNsfShortcutOutput( + rows: NsfShortcutRow[], + format?: ListNsfShortcutsOptions["format"], + ): string { + return this.nestedShareFolderManager.formatNsfShortcutOutput(rows, format); + } + + public async keepNsfShortcut( + input: KeepNsfShortcutInput, + ): Promise { + const current = this.folderSession.currentFolderUid; + const defaultFolderUid = + current && isNestedShareFolder(this.storage, current) + ? current + : undefined; + const result = await this.nestedShareFolderManager.keepNsfShortcut( + input, + defaultFolderUid, + ); + if ( + !result.dryRun && + !result.nothingToDo && + result.results.some((item) => item.success) + ) { + await this.syncIfNeeded(); + } + return result; + } + + public formatKeepNsfShortcutPlan( + result: KeepNsfShortcutResult, + ): string { + return this.nestedShareFolderManager.formatKeepNsfShortcutPlan(result); + } + + public async transferNestedShareRecords( + input: TransferNestedShareRecordInput, + ): Promise { + const result = + await this.nestedShareFolderManager.transferNestedShareRecords(input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public formatTransferNestedShareRecordResults( + results: TransferNestedShareRecordResult["results"], + ): string { + return this.nestedShareFolderManager.formatTransferNestedShareRecordResults( + results, + ); + } + + public async removeNestedShareFolders( + input: RemoveNsfFolderInput, + ): Promise { + const result = + await this.nestedShareFolderManager.removeNestedShareFolders(input); + if (result.confirmed) await this.syncIfNeeded(); + return result; + } + + public formatRemoveNsfFolderPreview( + preview: RemoveNsfFolderResult["preview"], + operation: RemoveNsfFolderResult["operation"], + quiet?: boolean, + ): string { + return this.nestedShareFolderManager.formatRemoveNsfFolderPreview( + preview, + operation, + quiet, + ); + } + + public async getNestedShareRecordDetails( + input: GetNsfRecordDetailsInput, + ): Promise { + return this.nestedShareFolderManager.getNestedShareRecordDetails(input); + } + + public formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format?: GetNsfRecordDetailsInput["format"], + ): string { + return this.nestedShareFolderManager.formatNsfRecordDetailsOutput( + result, + format, + ); + } + + public async updateNestedShareRecords( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput, + ): Promise { + const result = + await this.nestedShareFolderManager.updateNestedShareRecords(input); + if (result.updated.some((item) => item.success)) await this.syncIfNeeded(); + return result; + } + + public async updateNestedShareRecord( + input: UpdateNsfRecordItemInput, + ): Promise { + const result = + await this.nestedShareFolderManager.updateNestedShareRecord(input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async addNestedShareRecords( + input: AddNsfRecordsInput, + ): Promise { + const result = + await this.nestedShareFolderManager.addNestedShareRecords(input); + if (result.added.some((item) => item.success)) await this.syncIfNeeded(); + return result; + } + + public async addNestedShareRecord( + input: AddNsfRecordInput, + ): Promise { + const result = + await this.nestedShareFolderManager.addNestedShareRecord(input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async updateNestedShareRecordPermissions( + input: UpdateNsfRecordPermissionInput, + ): Promise { + const result = + await this.nestedShareFolderManager.updateNestedShareRecordPermissions( + input, + ); + if (result.confirmed) await this.syncIfNeeded(); + return result; + } + + public formatNsfRecordPermissionPlan( + result: UpdateNsfRecordPermissionResult, + ): string { + return this.nestedShareFolderManager.formatNsfRecordPermissionPlan(result); + } + + public formatNsfRecordPermissionFailures( + failures: UpdateNsfRecordPermissionResult["grantFailures"], + kind: "GRANT" | "REVOKE", + ): string { + return this.nestedShareFolderManager.formatNsfRecordPermissionFailures( + failures, + kind, + ); + } + + public async shareFolder( + input: ShareFolderInput, + ): Promise { + const result = await this.sharedFolderManager.shareFolder(input); + if (result.success) await this.syncIfNeeded(); + return result; + } + + public async downloadMembership( + options: DownloadMembershipOptions = {}, + ): Promise { + return this.sharedFolderManager.downloadMembership(options); + } + + public async applyMembership( + data: ApplyMembershipInput, + options: ApplyMembershipOptions = {}, + ): Promise { + const result = await this.sharedFolderManager.applyMembership( + data, + options, + ); + await this.syncIfNeeded(); + return result; + } + + public async getRecordHistory( + recordUid: string, + ): Promise { + const auth = this.getAuthOrThrow(); + + const keyBytes = await this.storage.getKeyBytes(recordUid); + if (!keyBytes) { + return { recordUid, history: [] }; + } + + return getRecordHistoryOp(auth, recordUid, keyBytes); + } + + public getStorage(): VaultStorage { + return this.storage; + } + + public getAuth(): Auth { + return this.getAuthOrThrow(); + } + + public disconnect(): void { + if (this.auth) { + try { + this.auth.disconnect(); + } catch (err) { + this.log.debug("disconnect error:", extractErrorMessage(err)); + } + this.auth = null; + } + this.synced = false; + this.folderSession.currentFolderUid = null; + } + + public async logout(): Promise { + if (this.auth) { + try { + await this.auth.logout(); + } catch (err) { + this.log.debug("logout error:", extractErrorMessage(err)); + } + } + this.disconnect(); + this.log.info("Logged out."); + } + + public get host(): string { + return this.config.host; + } + + public get isLoggedIn(): boolean { + return this.auth !== null && !!this.auth.sessionToken; + } + + public get isSynced(): boolean { + return this.synced; + } } diff --git a/KeeperSdk/tsconfig.json b/KeeperSdk/tsconfig.json index 74c99318..04586731 100644 --- a/KeeperSdk/tsconfig.json +++ b/KeeperSdk/tsconfig.json @@ -1,16 +1,16 @@ { - "compilerOptions": { - "module": "commonjs", - "target": "es2018", - "sourceMap": true, - "strict": false, - "skipLibCheck": true, - "esModuleInterop": true, - "declaration": true, - "rootDir": ".", - "outDir": "dist", - "types": ["node"] - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] + "compilerOptions": { + "module": "commonjs", + "target": "es2018", + "sourceMap": true, + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true, + "declaration": true, + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] } diff --git a/README.md b/README.md index 0732d0a1..cce3ad21 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ This repository contains npm packages for interacting with the Keeper backend from JavaScript or TypeScript. -[`@keeper-security/keeper-sdk-javascript`](KeeperSdk) has an easy-to-use API with high-level helpers for auth, records, folders, sharing, and teams, plus runnable examples. - -[`@keeper-security/keeperapi`](keeperapi) is the underlying core API client. Direct use is not recommended. +| Package | Purpose | +|---------|---------| +| [`@keeper-security/keeper-sdk-javascript`](KeeperSdk) | High-level API + Commander-style in-process CLI (`dispatchCliLine`) | +| [`@keeper-security/keeperapi`](keeperapi) | Low-level REST/protobuf client (direct use not recommended) | [![keeper-sdk-javascript on npm](https://img.shields.io/npm/v/@keeper-security/keeper-sdk-javascript?label=%40keeper-security%2Fkeeper-sdk-javascript&style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeper-sdk-javascript) [![keeperapi on npm](https://img.shields.io/npm/v/@keeper-security/keeperapi?label=%40keeper-security%2Fkeeperapi&style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeperapi) @@ -14,20 +15,45 @@ This repository contains npm packages for interacting with the Keeper backend fr ## Repository layout ``` -KeeperSdk/ # JS SDK (@keeper-security/keeper-sdk-javascript) -keeperapi/ # core API client (@keeper-security/keeperapi) -examples/ -└── sdk_example/ # runnable scripts demonstrating the SDK +keeper-sdk-javascript/ +├── KeeperSdk/ # @keeper-security/keeper-sdk-javascript +├── keeperapi/ # @keeper-security/keeperapi +└── examples/ + └── sdk_example/ # Runnable Node scripts (auth, records, folders, …) +``` + +**Start here for CLI / vault behavior:** [`KeeperSdk/README.md`](KeeperSdk/README.md) — built-in commands, `get` / `whoami` output, and `KeeperCliHost` adapter requirements. + +Browser embedders (e.g. `@keeper-security/keeper-shell-component`) consume this repo via npm or a local path. Fix CLI formatting and vault surface issues in **KeeperSdk first**, then rebuild the shell against the updated SDK. + +## Quick development + +Build **keeperapi** before **KeeperSdk** (SDK depends on keeperapi): + +```bash +cd keeperapi && npm install && npm run build +cd ../KeeperSdk && npm install && npm run link-local && npm run build +``` + +- **Node** entry: `KeeperSdk/dist/index.js` +- **Browser** entry: `KeeperSdk/dist/browser.js` (via `src/browser.ts` — no Node `readline` / console auth) + +Run SDK examples: + +```bash +cd examples/sdk_example && npm install +npm run auth:restore-session -- --from-json /path/to/session.json +npm run records:list:shell-cli -- --from-json /path/to/session.json ``` -See each package's README for installation and usage: +## Package docs -- [`KeeperSdk/README.md`](KeeperSdk/README.md) -- [`keeperapi/README.md`](keeperapi/README.md) +- [`KeeperSdk/README.md`](KeeperSdk/README.md) — API, CLI commands, host adapter +- [`keeperapi/README.md`](keeperapi/README.md) — core client ## Contributing -To ignore formatting-only commits in `git blame`, run: +To ignore formatting-only commits in `git blame`: ```bash git config blame.ignoreRevsFile .git-blame-ignore-revs diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 016577d4..75940ce3 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -24,11 +24,18 @@ "folders:tree": "ts-node src/folders/tree.ts", "shared-folders:list-sf": "ts-node src/sharedFolders/list_sf.ts", "shared-folders:share-folder": "ts-node src/sharedFolders/share_folder.ts", + "shared-folders:download-membership": "ts-node src/sharedFolders/download_membership.ts", + "shared-folders:apply-membership": "ts-node src/sharedFolders/apply_membership.ts", "roles:list": "ts-node src/roles/listRoles.ts", "roles:view": "ts-node src/roles/viewRole.ts", "roles:add": "ts-node src/roles/addRole.ts", "roles:update": "ts-node src/roles/updateRole.ts", "roles:delete": "ts-node src/roles/deleteRole.ts", + "roles:enforcements": "ts-node src/roles/roleEnforcements.ts", + "roles:copy": "ts-node src/roles/copyRole.ts", + "roles:role-users": "ts-node src/roles/roleUsers.ts", + "roles:manage-node": "ts-node src/roles/manageNode.ts", + "roles:privileges": "ts-node src/roles/rolePrivileges.ts", "nsf:list": "ts-node src/nestedShareFolders/list_nsf.ts", "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", @@ -51,12 +58,19 @@ "users:action": "ts-node src/users/action_user.ts", "users:alias": "ts-node src/users/alias_user.ts", "users:user-team": "ts-node src/users/team_user.ts", + "users:update-team-user": "ts-node src/users/update_team_user.ts", "reports:audit-report": "ts-node src/enterpriseReport/audit_report.ts", "reports:action-report": "ts-node src/enterpriseReport/action_report.ts", "reports:password-report": "ts-node src/enterpriseReport/password_report.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", - "types:ci": "tsc" + "types:ci": "tsc", + "nsf:folder": "ts-node src/nestedShareFolders/nsf_folder.ts", + "nsf:rndir": "ts-node src/nestedShareFolders/rndir_nsf.ts", + "nsf:share": "ts-node src/nestedShareFolders/share_nsf.ts", + "nsf:shortcut": "ts-node src/nestedShareFolders/shortcut_nsf.ts", + "nsf:transfer-record": "ts-node src/nestedShareFolders/transfer_nsf_record.ts", + "nsf:record-permission": "ts-node src/nestedShareFolders/nsf_record_permission.ts" }, "dependencies": { "@keeper-security/keeperapi": "17.1.0", diff --git a/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts b/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts new file mode 100644 index 00000000..30600390 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts @@ -0,0 +1,169 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_FOLDER_COLORS, + NsfRemoveFolderOperation, + prompt, + type MkdirNsfInput, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': 'mkdir', + '1': 'mkdir', + '2': 'rmdir', + mkdir: 'mkdir', + create: 'mkdir', + rmdir: 'rmdir', + remove: 'rmdir', +} + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveFolderOperation.FolderTrash, + '1': NsfRemoveFolderOperation.FolderTrash, + '2': NsfRemoveFolderOperation.DeletePermanent, + 'folder-trash': NsfRemoveFolderOperation.FolderTrash, + 'delete-permanent': NsfRemoveFolderOperation.DeletePermanent, +} + +const PERMANENT_DELETE_WARNING_LINES = [ + '*** WARNING ***', + 'delete-permanent is IRREVERSIBLE.', + 'All sub-folders and records inside will be permanently destroyed.', +] as const + +function parseAction(input: string): 'mkdir' | 'rmdir' { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? 'mkdir' +} + +function parseOperation(input: string): NsfRemoveFolderOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash +} + +function logPreview(vault: Vault, result: RemoveNsfFolderResult, quiet: boolean): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(vault.formatRemoveNsfFolderPreview(result.preview, result.operation, quiet)) + logger.info('') +} + +function logPreviewWarnings(result: RemoveNsfFolderResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function mkdirNsf(vault: Vault): Promise { + const folder = (await prompt('Folder name or path (e.g. Team/Projects/Q1): ')).trim() + if (!folder) { + logger.info('Folder name is required.') + return + } + + logger.info(`Colors: ${NSF_FOLDER_COLORS.join(', ')}`) + const colorInput = (await prompt('Color (optional, leaf only) [none]: ')).trim().toLowerCase() + const color = + colorInput && (NSF_FOLDER_COLORS as readonly string[]).includes(colorInput) + ? (colorInput as MkdirNsfInput['color']) + : undefined + const noInherit = (await prompt('Do not inherit parent permissions? [y/N]: ')).trim().toLowerCase() === 'y' + + const result = await withSuppressedLogs(() => + vault.mkdirNestedShareFolder({ + folder, + color, + noInheritPermissions: noInherit, + }) + ) + + logger.info('') + if (result.message) logger.info(result.message) + logger.info(`Folder UID: ${result.folderUid}`) + logger.info(`Created: ${result.created ? 'Yes' : 'No'}`) + logger.info('') +} + +async function rmdirNsf(vault: Vault): Promise { + const folders = splitCommaSeparated(await prompt('Folder UID(s) or name(s), comma-separated: ')) + if (folders.length === 0) { + logger.info('At least one folder is required.') + return + } + + logger.info('Operation: 1) folder-trash 2) delete-permanent') + const operation = parseOperation(await prompt('Choose [1]: ')) + const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) + const quiet = isYes(await prompt('Quiet (summary only)? [y/N]: ')) + const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + + if (operation === NsfRemoveFolderOperation.DeletePermanent && !force && !dryRun) { + logger.info('') + for (const line of PERMANENT_DELETE_WARNING_LINES) { + logger.info(line) + } + logger.info('') + } + + const baseInput: RemoveNsfFolderInput = { folders, operation, quiet } + + if (dryRun) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, dryRun: true })) + logPreview(vault, result, quiet) + logger.info('[Dry-run] No folders were deleted.') + return + } + + if (force) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + logPreview(vault, result, quiet) + if (result.confirmed && result.message) logger.info(result.message) + return + } + + const preview = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: false })) + logPreview(vault, preview, quiet) + logPreviewWarnings(preview) + + const confirmPrompt = + operation === NsfRemoveFolderOperation.DeletePermanent + ? 'Do you want to permanently delete the folder(s) and all their contents? [y/n]: ' + : 'Do you want to proceed with the folder deletion? [y/n]: ' + + if (!isYes(await prompt(confirmPrompt))) { + logger.info('Removal cancelled.') + return + } + + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + if (result.confirmed && result.message) logger.info(result.message) +} + +async function nsfFolder() { + const vault = await login() + + try { + logger.info('Action: 1) create folder 2) remove folder') + const action = parseAction(await prompt('Choose [1]: ')) + if (action === 'mkdir') { + await mkdirNsf(vault) + } else { + await rmdirNsf(vault) + } + } catch (err) { + logger.error(`Folder operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(nsfFolder) diff --git a/examples/sdk_example/src/nestedShareFolders/nsf_record_permission.ts b/examples/sdk_example/src/nestedShareFolders/nsf_record_permission.ts new file mode 100644 index 00000000..e3b15721 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/nsf_record_permission.ts @@ -0,0 +1,133 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfRecordPermissionAction, + NSF_RECORD_PERMISSION_ROLES, + prompt, + suppressLogs, + type UpdateNsfRecordPermissionInput, + type UpdateNsfRecordPermissionResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': NsfRecordPermissionAction.Grant, + '1': NsfRecordPermissionAction.Grant, + '2': NsfRecordPermissionAction.Revoke, + grant: NsfRecordPermissionAction.Grant, + revoke: NsfRecordPermissionAction.Revoke, +} + +function parseAction(input: string): NsfRecordPermissionAction { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRecordPermissionAction.Grant +} + +async function runPermissionUpdate( + vault: Vault, + input: UpdateNsfRecordPermissionInput +): Promise { + const restore = suppressLogs() + try { + return await vault.updateNestedShareRecordPermissions(input) + } finally { + restore() + } +} + +function printPlan(vault: Vault, result: UpdateNsfRecordPermissionResult): void { + logger.info('') + logger.info(vault.formatNsfRecordPermissionPlan(result)) + logger.info('') +} + +function printFailures(vault: Vault, result: UpdateNsfRecordPermissionResult): void { + const grantFailures = vault.formatNsfRecordPermissionFailures(result.grantFailures, 'GRANT') + const revokeFailures = vault.formatNsfRecordPermissionFailures(result.revokeFailures, 'REVOKE') + if (grantFailures) { + logger.info('') + logger.info(grantFailures) + logger.info('') + } + if (revokeFailures) { + logger.info('') + logger.info(revokeFailures) + logger.info('') + } +} + +async function nsfRecordPermission() { + const vault = await login() + + try { + logger.info('Action: 1) grant 2) revoke') + const action = parseAction(await prompt('Choose [1]: ')) + const folder = (await prompt('Folder path or UID (Enter for root-level records): ')).trim() + const recursive = isYes(await prompt('Include sub-folders? [y/N]: ')) + + let role: string | undefined + if (action === NsfRecordPermissionAction.Grant) { + logger.info(`Roles: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}`) + role = (await prompt('Role to grant: ')).trim() + if (!role) { + logger.info('Role is required for grant action.') + return + } + } else { + role = (await prompt('Role filter for revoke (Enter for all roles): ')).trim() || undefined + } + + const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) + const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + + const baseInput: UpdateNsfRecordPermissionInput = { + folder: folder || undefined, + action, + role, + recursive, + } + + if (dryRun) { + const result = await runPermissionUpdate(vault, { ...baseInput, dryRun: true }) + printPlan(vault, result) + if (result.message) logger.info(result.message) + logger.info('[Dry-run] No permission changes were applied.') + return + } + + if (force) { + const result = await runPermissionUpdate(vault, { ...baseInput, force: true }) + printPlan(vault, result) + printFailures(vault, result) + if (result.message) logger.info(result.message) + return + } + + const preview = await runPermissionUpdate(vault, { ...baseInput, force: false }) + printPlan(vault, preview) + if (preview.message && preview.plan.grants.length + preview.plan.revokes.length === 0) { + logger.info(preview.message) + return + } + + if (!isYes(await prompt('Do you want to proceed with these permission changes? [y/n]: '))) { + logger.info('Permission update cancelled.') + return + } + + const result = await runPermissionUpdate(vault, { ...baseInput, force: true }) + printFailures(vault, result) + if (result.message) logger.info(result.message) + } catch (err) { + logger.error(`Record permission update failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(nsfRecordPermission) diff --git a/examples/sdk_example/src/nestedShareFolders/rndir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/rndir_nsf.ts new file mode 100644 index 00000000..be8c7955 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/rndir_nsf.ts @@ -0,0 +1,52 @@ +import { + NSF_FOLDER_COLORS, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + type NsfFolderColorInput, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { withSuppressedLogs } from '../utils/format' + +async function rndirNsf() { + const vault = await login() + + try { + const folder = (await prompt('Folder path or UID: ')).trim() + if (!folder) { + logger.info('Enter the path or UID of an existing folder.') + return + } + + const name = (await prompt('New name (optional): ')).trim() + const colorInput = (await prompt(`New color (${NSF_FOLDER_COLORS.join(', ')}, optional): `)).trim() + const quietInput = (await prompt('Quiet mode? (y/N): ')).trim().toLowerCase() + + if (!name && !colorInput) { + logger.info('New folder name and/or color parameters are required.') + return + } + + const result = await withSuppressedLogs(() => + vault.updateNestedShareFolder({ + folder, + name: name || undefined, + color: colorInput ? (colorInput as NsfFolderColorInput) : undefined, + quiet: quietInput === 'y' || quietInput === 'yes', + }) + ) + + if (result.message) { + logger.info(result.message) + } + } catch (err) { + logger.error(`Folder update failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rndirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/share_nsf.ts b/examples/sdk_example/src/nestedShareFolders/share_nsf.ts new file mode 100644 index 00000000..b2d73ab5 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/share_nsf.ts @@ -0,0 +1,142 @@ +import { + NsfFolderShareAction, + NsfRecordShareAction, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + NSF_RECORD_PERMISSION_ROLES, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +const TARGET_BY_INPUT: Record = { + '': 'folder', + '1': 'folder', + '2': 'record', + folder: 'folder', + record: 'record', +} + +function parseTarget(input: string): 'folder' | 'record' { + return TARGET_BY_INPUT[input.trim().toLowerCase()] ?? 'folder' +} + +async function shareNsfFolder(vault: Vault): Promise { + const folders = splitCommaSeparated(await prompt('Folder path(s) or UID(s), comma-separated: ')) + if (folders.length === 0) { + logger.info('Folder path or UID is required.') + return + } + + const recipients = splitCommaSeparated( + await prompt('Recipient(s) — email, team name/UID, or @existing, comma-separated: ') + ) + if (recipients.length === 0) { + logger.info('At least one recipient is required.') + return + } + + const actionInput = (await prompt('Action (grant/remove) [grant]: ')).trim().toLowerCase() + const action = + actionInput === 'remove' ? NsfFolderShareAction.Remove : NsfFolderShareAction.Grant + + const role = + action === NsfFolderShareAction.Grant + ? (await prompt(`Role (${NSF_RECORD_PERMISSION_ROLES.join(', ')}) [viewer]: `)).trim() || + 'viewer' + : undefined + + const result = await withSuppressedLogs(() => + vault.shareNestedShareFolder({ + folders, + recipients, + action, + role, + }) + ) + + logger.info('') + for (const item of result.results) { + logger.info( + `${item.folderUid} ${item.recipient} ${item.actionTaken} ${item.success ? 'success' : 'failed'} ${item.message || ''}` + ) + } +} + +async function shareNsfRecord(vault: Vault): Promise { + const record = (await prompt('Record UID, title, or folder path: ')).trim() + if (!record) { + logger.info('Record path or UID is required.') + return + } + + const emails = splitCommaSeparated(await prompt('Recipient email(s), comma-separated: ')) + if (emails.length === 0) { + logger.info('At least one recipient email is required.') + return + } + + const actionInput = (await prompt('Action (grant/revoke/owner) [grant]: ')).trim().toLowerCase() + const action = + actionInput === 'revoke' + ? NsfRecordShareAction.Revoke + : actionInput === 'owner' + ? NsfRecordShareAction.Owner + : NsfRecordShareAction.Grant + + const role = + action === NsfRecordShareAction.Grant + ? (await prompt(`Role (${NSF_RECORD_PERMISSION_ROLES.join(', ')}) [viewer]: `)).trim() || + 'viewer' + : undefined + + const recursiveInput = (await prompt('Recursive when record is a folder? (y/N): ')) + .trim() + .toLowerCase() + const dryRunInput = (await prompt('Dry run? (y/N): ')).trim().toLowerCase() + + const result = await withSuppressedLogs(() => + vault.shareNestedShareRecord({ + record, + emails, + action, + role, + recursive: recursiveInput === 'y' || recursiveInput === 'yes', + dryRun: dryRunInput === 'y' || dryRunInput === 'yes', + }) + ) + + logger.info('') + logger.info(vault.formatNsfRecordSharePlan(result)) + if (!result.dryRun) { + logger.info('') + logger.info(vault.formatNsfRecordShareResults(result.results)) + } +} + +async function shareNsf() { + const vault = await login() + + try { + const target = parseTarget( + await prompt('Share target (1=folder, 2=record) [folder]: ') + ) + + if (target === 'record') { + await shareNsfRecord(vault) + } else { + await shareNsfFolder(vault) + } + } catch (err) { + logger.error(`NSF share failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(shareNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/shortcut_nsf.ts b/examples/sdk_example/src/nestedShareFolders/shortcut_nsf.ts new file mode 100644 index 00000000..ad597cca --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/shortcut_nsf.ts @@ -0,0 +1,126 @@ +import fs from 'fs/promises' +import { + cleanup, + extractErrorMessage, + ListNsfFormat, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': 'list', + '1': 'list', + '2': 'keep', + list: 'list', + keep: 'keep', +} + +function parseAction(input: string): 'list' | 'keep' { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? 'list' +} + +async function listShortcuts(vault: Vault): Promise { + const target = (await prompt('Filter by record or folder (Enter for all): ')).trim() + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const asCsv = !asJson && isYes(await prompt('Output as CSV? [y/N]: ')) + const outputPath = (await prompt('Output file path (Enter for stdout): ')).trim() + + const format = asJson ? ListNsfFormat.JSON : asCsv ? ListNsfFormat.CSV : ListNsfFormat.Table + const rows = vault.listNsfShortcuts({ target: target || undefined }) + + if (rows.length === 0) { + logger.info('No multi-folder records found.') + return + } + + const output = vault.formatNsfShortcutOutput(rows, format) + if (outputPath && format !== ListNsfFormat.Table) { + await fs.writeFile(outputPath, output, 'utf-8') + logger.info(`Wrote ${rows.length} row(s) to ${outputPath}`) + return + } + + logger.info('') + logger.info(output) + logger.info('') + logger.info(`Total: ${rows.length} shortcut record${rows.length === 1 ? '' : 's'}`) +} + +async function keepShortcut(vault: Vault): Promise { + const record = (await prompt('Record UID or title: ')).trim() + if (!record) { + logger.info('Record UID or title is required.') + return + } + + const folder = (await prompt('Folder to keep record in (Enter for current NSF folder): ')).trim() + const dryRun = isYes(await prompt('Dry run? [y/N]: ')) + + if (!dryRun) { + const preview = await withSuppressedLogs(() => + vault.keepNsfShortcut({ record, folder: folder || undefined, dryRun: true }) + ) + logger.info('') + logger.info(vault.formatKeepNsfShortcutPlan(preview)) + if (preview.nothingToDo) { + logger.info('') + logger.info('Nothing to do — record is already in only one folder.') + return + } + if (!isYes(await prompt('Proceed? [y/N]: '))) { + logger.info('Aborted.') + return + } + } + + const result = await withSuppressedLogs(() => + vault.keepNsfShortcut({ + record, + folder: folder || undefined, + dryRun, + }) + ) + + logger.info('') + logger.info(vault.formatKeepNsfShortcutPlan(result)) + if (result.nothingToDo) { + logger.info('') + logger.info('Nothing to do — record is already in only one folder.') + return + } + if (!result.dryRun) { + logger.info('') + for (const item of result.results) { + logger.info( + `${item.folderUid} ${item.success ? 'success' : 'failed'} ${item.message}` + ) + } + } +} + +async function nsfShortcut() { + const vault = await login() + + try { + logger.info('Action: 1) list 2) keep') + const action = parseAction(await prompt('Choose [1]: ')) + + if (action === 'keep') { + await keepShortcut(vault) + } else { + await listShortcuts(vault) + } + } catch (err) { + logger.error(`NSF shortcut failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(nsfShortcut) diff --git a/examples/sdk_example/src/nestedShareFolders/transfer_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/transfer_nsf_record.ts new file mode 100644 index 00000000..3df07700 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/transfer_nsf_record.ts @@ -0,0 +1,50 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +async function transferNsfRecord() { + const vault = await login() + + try { + const records = splitCommaSeparated( + await prompt('Record UID(s) or title(s), comma-separated: ') + ) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const newOwnerEmail = (await prompt('New owner email: ')).trim() + if (!newOwnerEmail) { + logger.info('New owner email is required.') + return + } + + const result = await withSuppressedLogs(() => + vault.transferNestedShareRecords({ + records, + newOwnerEmail, + }) + ) + + logger.info('') + logger.info(vault.formatTransferNestedShareRecordResults(result.results)) + if (result.success) { + logger.info('') + logger.info('Warning: You will no longer have access to transferred record(s).') + } + } catch (err) { + logger.error(`Record transfer failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(transferNsfRecord) diff --git a/examples/sdk_example/src/roles/copyRole.ts b/examples/sdk_example/src/roles/copyRole.ts new file mode 100644 index 00000000..e5f59aff --- /dev/null +++ b/examples/sdk_example/src/roles/copyRole.ts @@ -0,0 +1,59 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { CopyRoleResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function copyRoleExample() { + const vault = await login() + + try { + const source = (await prompt('Source role name or ID to copy: ')).trim() + if (!source) { + logger.error('A source role is required.') + process.exitCode = 1 + return + } + + const nameRaw = (await prompt('New role name (Enter for " Copy"): ')).trim() + const name = nameRaw || undefined + + const parentRaw = (await prompt('Parent node name or ID (Enter to reuse source role node): ')).trim() + const parent: string | null = parentRaw || null + + const clone = isYes( + await prompt('Also clone role users and teams onto the new role? [y/N]: ') + ) + + const restore = suppressLogs() + let result: CopyRoleResult + try { + result = await vault.copyRoles({ source, name, parent, clone }) + } finally { + restore() + } + + const table = vault.formatCopyRoleResult(result) + logger.info('') + logger.info(vault.renderCopyRoleAsciiTable(table)) + logger.info('') + logger.info(`Result: ${result.success ? 'success' : 'failed'}`) + + if (!result.success) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(copyRoleExample) diff --git a/examples/sdk_example/src/roles/manageNode.ts b/examples/sdk_example/src/roles/manageNode.ts new file mode 100644 index 00000000..5fa8f9e4 --- /dev/null +++ b/examples/sdk_example/src/roles/manageNode.ts @@ -0,0 +1,113 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { + ManageRoleNodesAction, + ManageRoleNodesResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +enum ManageNodeMenuChoice { + Add = '1', + Update = '2', + Remove = '3', +} + +const ACTION_BY_CHOICE: Record = { + [ManageNodeMenuChoice.Add]: 'add', + [ManageNodeMenuChoice.Update]: 'update', + [ManageNodeMenuChoice.Remove]: 'remove', +} + +function parseCascade(raw: string): 'on' | 'off' | null | undefined { + const value = raw.trim().toLowerCase() + if (value === '' || value === 'skip') return undefined + if (value === 'on') return 'on' + if (value === 'off') return 'off' + return null +} + +async function manageNodeExample() { + const vault = await login() + + try { + logger.info('') + logger.info('Select an action:') + logger.info(` ${ManageNodeMenuChoice.Add}) Add role(s) as manager of node(s)`) + logger.info(` ${ManageNodeMenuChoice.Update}) Update cascade setting for existing role/node link(s)`) + logger.info(` ${ManageNodeMenuChoice.Remove}) Remove role(s) as manager of node(s)`) + logger.info('') + + const choice = ( + await prompt(`Action [${ManageNodeMenuChoice.Add}-${ManageNodeMenuChoice.Remove}]: `) + ).trim() as ManageNodeMenuChoice + const action = ACTION_BY_CHOICE[choice] + if (!action) { + logger.error('Invalid choice.') + process.exitCode = 1 + return + } + + const rolesRaw = (await prompt('Role name(s) or ID(s) (comma-separated): ')).trim() + const roles = splitCommaSeparated(rolesRaw) + if (roles.length === 0) { + logger.error('At least one role name or ID is required.') + process.exitCode = 1 + return + } + + const nodesRaw = (await prompt('Node name(s) or ID(s) (comma-separated): ')).trim() + const nodes = splitCommaSeparated(nodesRaw) + if (nodes.length === 0) { + logger.error('At least one node name or ID is required.') + process.exitCode = 1 + return + } + + let cascade: 'on' | 'off' | null | undefined + if (action !== 'remove') { + cascade = parseCascade( + await prompt('Cascade node management to sub-nodes? [on/off, Enter to skip]: ') + ) + if (cascade === null) { + logger.error('Invalid cascade value. Use on, off, or leave blank.') + process.exitCode = 1 + return + } + } + + const restore = suppressLogs() + let result: ManageRoleNodesResult + try { + result = await vault.manageRoleNodes({ roles, nodes, action, cascade }) + } finally { + restore() + } + + const table = vault.formatManageRoleNodesResult(result) + logger.info('') + logger.info(vault.renderManageRoleNodesAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(succeeded=${result.succeeded}, skipped=${result.skipped}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.succeeded === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(manageNodeExample) diff --git a/examples/sdk_example/src/roles/roleEnforcements.ts b/examples/sdk_example/src/roles/roleEnforcements.ts new file mode 100644 index 00000000..5abc416d --- /dev/null +++ b/examples/sdk_example/src/roles/roleEnforcements.ts @@ -0,0 +1,63 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { SetRoleEnforcementsResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +async function roleEnforcementsExample() { + const vault = await login() + + try { + const rolesRaw = (await prompt('Role name(s) or ID(s) (comma-separated): ')).trim() + const roles = splitCommaSeparated(rolesRaw) + if (roles.length === 0) { + logger.error('At least one role name or ID is required.') + process.exitCode = 1 + return + } + + const enforcementsRaw = ( + await prompt('Enforcements to apply (KEY:VALUE, comma-separated; VALUE=false removes): ') + ).trim() + const enforcements = splitCommaSeparated(enforcementsRaw) + if (enforcements.length === 0) { + logger.error('At least one KEY:VALUE enforcement is required.') + process.exitCode = 1 + return + } + + const restore = suppressLogs() + let result: SetRoleEnforcementsResult + try { + result = await vault.setRoleEnforcements({ roles, enforcements }) + } finally { + restore() + } + + const table = vault.formatSetRoleEnforcementsResult(result) + logger.info('') + logger.info(vault.renderRoleEnforcementAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(applied=${result.applied}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.applied === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(roleEnforcementsExample) diff --git a/examples/sdk_example/src/roles/rolePrivileges.ts b/examples/sdk_example/src/roles/rolePrivileges.ts new file mode 100644 index 00000000..941411a2 --- /dev/null +++ b/examples/sdk_example/src/roles/rolePrivileges.ts @@ -0,0 +1,71 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { ChangeRolePrivilegesResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +async function rolePrivilegesExample() { + const vault = await login() + + try { + const role = (await prompt('Role name or ID: ')).trim() + if (!role) { + logger.error('A role is required.') + process.exitCode = 1 + return + } + + const node = (await prompt('Managed node name or ID (must already be managed by the role): ')).trim() + if (!node) { + logger.error('A managed node is required.') + process.exitCode = 1 + return + } + + const addRaw = (await prompt('Privileges to add (comma-separated, Enter to skip): ')).trim() + const add = splitCommaSeparated(addRaw) + + const removeRaw = (await prompt('Privileges to remove (comma-separated, Enter to skip): ')).trim() + const remove = splitCommaSeparated(removeRaw) + + if (add.length === 0 && remove.length === 0) { + logger.error('At least one privilege to add or remove is required.') + process.exitCode = 1 + return + } + + const restore = suppressLogs() + let result: ChangeRolePrivilegesResult + try { + result = await vault.changeRolePrivileges({ role, node, add, remove }) + } finally { + restore() + } + + const table = vault.formatChangeRolePrivilegesResult(result) + logger.info('') + logger.info(vault.renderChangeRolePrivilegesAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(succeeded=${result.succeeded}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.succeeded === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rolePrivilegesExample) diff --git a/examples/sdk_example/src/roles/roleUsers.ts b/examples/sdk_example/src/roles/roleUsers.ts new file mode 100644 index 00000000..452dc00c --- /dev/null +++ b/examples/sdk_example/src/roles/roleUsers.ts @@ -0,0 +1,100 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { RoleUserResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +type VaultHandle = Awaited> + +enum RoleUserMenuChoice { + Add = '1', + Remove = '2', +} + +type OperationInput = { kind: RoleUserMenuChoice; roles: string[]; users: string[] } + +function failWith(message: string): null { + logger.error(message) + process.exitCode = 1 + return null +} + +async function promptList(label: string, message: string): Promise { + const items = splitCommaSeparated((await prompt(message)).trim()) + if (items.length === 0) return failWith(`At least one ${label} is required.`) + return items +} + +async function gatherOperationInput(): Promise { + logger.info('') + logger.info('Select an operation:') + logger.info(` ${RoleUserMenuChoice.Add}) Add users to role(s)`) + logger.info(` ${RoleUserMenuChoice.Remove}) Remove users from role(s)`) + logger.info('') + + const choice = (await prompt(`Operation [${RoleUserMenuChoice.Add}-${RoleUserMenuChoice.Remove}]: `)).trim() + if (choice !== RoleUserMenuChoice.Add && choice !== RoleUserMenuChoice.Remove) { + return failWith(`Invalid choice. Please enter ${RoleUserMenuChoice.Add} or ${RoleUserMenuChoice.Remove}.`) + } + + const roles = await promptList('role name or ID', 'Role name(s) or ID(s) (comma-separated): ') + if (!roles) return null + + const users = await promptList('user email or ID', 'User email(s) or ID(s) (comma-separated): ') + if (!users) return null + + return { kind: choice, roles, users } +} + +async function executeOperation(vault: VaultHandle, input: OperationInput): Promise { + const restore = suppressLogs() + try { + switch (input.kind) { + case RoleUserMenuChoice.Add: + return await vault.addUsersToRoles({ roles: input.roles, users: input.users }) + case RoleUserMenuChoice.Remove: + return await vault.removeUsersFromRoles({ roles: input.roles, users: input.users }) + } + } finally { + restore() + } +} + +function reportResult(vault: VaultHandle, result: RoleUserResult): void { + const table = vault.formatRoleUserResult(result) + logger.info('') + logger.info(vault.renderRoleUserAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(succeeded=${result.succeeded}, skipped=${result.skipped}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.succeeded === 0)) { + process.exitCode = 1 + } +} + +async function roleUsersExample() { + const vault = await login() + try { + const input = await gatherOperationInput() + if (!input) return + + const result = await executeOperation(vault, input) + reportResult(vault, result) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(roleUsersExample) diff --git a/examples/sdk_example/src/sharedFolders/apply_membership.ts b/examples/sdk_example/src/sharedFolders/apply_membership.ts new file mode 100644 index 00000000..7788b0dd --- /dev/null +++ b/examples/sdk_example/src/sharedFolders/apply_membership.ts @@ -0,0 +1,115 @@ +import { readFileSync } from "fs"; +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from "@keeper-security/keeper-sdk-javascript"; +import type { + ApplyMembershipInput, + ApplyMembershipOptions, + ApplyMembershipResult, +} from "@keeper-security/keeper-sdk-javascript"; +import { runExample } from "../utils/runner"; +import { isYes } from "../utils/format"; + +function summarize(result: ApplyMembershipResult): void { + for (const folder of result.folders) { + const label = folder.sharedFolderUid + ? `${folder.path} (${folder.sharedFolderUid})` + : folder.path; + if (folder.success) { + logger.info( + `${label}: users +${folder.counts.usersAdded}/~${folder.counts.usersUpdated}/-${folder.counts.usersRemoved}` + + ` teams +${folder.counts.teamsAdded}/~${folder.counts.teamsUpdated}/-${folder.counts.teamsRemoved}`, + ); + } else { + logger.error( + `${label}: FAILED${folder.message ? ` - ${folder.message}` : ""}`, + ); + } + } + for (const team of result.teamMembership) { + const label = team.teamUid ? `${team.name} (${team.teamUid})` : team.name; + if (team.success) { + logger.info( + `Team "${label}" membership: +${team.added}/-${team.removed}`, + ); + } else { + logger.error( + `Team "${label}" membership: FAILED${team.message ? ` - ${team.message}` : ""}`, + ); + } + } + const t = result.totals; + logger.info( + `Totals: users +${t.usersAdded}/~${t.usersUpdated}/-${t.usersRemoved}` + + ` teams +${t.teamsAdded}/~${t.teamsUpdated}/-${t.teamsRemoved}` + + ` team members +${t.teamMembersAdded}/-${t.teamMembersRemoved}`, + ); +} + +async function applyMembershipCommand() { + const vault = await login(); + + try { + const inputPath = + ( + await prompt("Input JSON file path (default membership.json): ") + ).trim() || "membership.json"; + const fullSync = isYes( + await prompt( + "Full sync (also update permissions and remove extras not in the file)? [y/N]: ", + ), + ); + + let raw: string; + try { + raw = readFileSync(inputPath, "utf8"); + } catch (err) { + logger.error( + `Could not read "${inputPath}": ${extractErrorMessage(err)}`, + ); + process.exitCode = 1; + return; + } + + let data: ApplyMembershipInput; + try { + data = JSON.parse(raw); + } catch (err) { + logger.error( + `Could not parse "${inputPath}" as JSON: ${extractErrorMessage(err)}`, + ); + process.exitCode = 1; + return; + } + + const options: ApplyMembershipOptions = { fullSync }; + + let result: ApplyMembershipResult; + const restore = suppressLogs(); + try { + result = await vault.applyMembership(data, options); + } finally { + restore(); + } + + summarize(result); + if (!result.success) { + logger.error( + "Some shared folders or teams could not be fully synced. See details above.", + ); + process.exitCode = 1; + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`); + process.exitCode = 1; + } finally { + cleanup(vault); + } +} + +runExample(applyMembershipCommand); diff --git a/examples/sdk_example/src/sharedFolders/download_membership.ts b/examples/sdk_example/src/sharedFolders/download_membership.ts new file mode 100644 index 00000000..043d252c --- /dev/null +++ b/examples/sdk_example/src/sharedFolders/download_membership.ts @@ -0,0 +1,59 @@ +import { writeFileSync } from "fs"; +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from "@keeper-security/keeper-sdk-javascript"; +import type { + DownloadMembershipOptions, + MembershipData, +} from "@keeper-security/keeper-sdk-javascript"; +import { runExample } from "../utils/runner"; +import { isYes } from "../utils/format"; + +async function downloadMembershipCommand() { + const vault = await login(); + + try { + const outputPath = + ( + await prompt("Output JSON file path (default membership.json): ") + ).trim() || "membership.json"; + const foldersOnly = isYes( + await prompt("Folders only (skip enterprise team membership)? [y/N]: "), + ); + const folderFilter = ( + await prompt("Filter by shared folder name/uid (leave blank for all): ") + ).trim(); + + const options: DownloadMembershipOptions = { foldersOnly }; + if (folderFilter) options.folderFilter = folderFilter; + + let data: MembershipData; + const restore = suppressLogs(); + try { + data = await vault.downloadMembership(options); + } finally { + restore(); + } + + writeFileSync(outputPath, JSON.stringify(data, null, 2), "utf8"); + + logger.info( + `Wrote ${data.shared_folders.length} shared folder(s) to "${outputPath}".`, + ); + if (data.teams) { + logger.info(`Included ${data.teams.length} enterprise team(s).`); + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`); + process.exitCode = 1; + } finally { + cleanup(vault); + } +} + +runExample(downloadMembershipCommand); diff --git a/examples/sdk_example/src/users/update_team_user.ts b/examples/sdk_example/src/users/update_team_user.ts new file mode 100644 index 00000000..01957b1c --- /dev/null +++ b/examples/sdk_example/src/users/update_team_user.ts @@ -0,0 +1,79 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { UpdateUsersOnTeamsResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +function parseHideSharedFolders(raw: string): boolean | null { + const value = raw.trim().toLowerCase() + if (value === 'on') return true + if (value === 'off') return false + return null +} + +async function updateTeamUserExample() { + const vault = await login() + + try { + const usersRaw = (await prompt('User email(s) or ID(s) (comma-separated): ')).trim() + const users = splitCommaSeparated(usersRaw) + if (users.length === 0) { + logger.error('At least one user email or ID is required.') + process.exitCode = 1 + return + } + + const teamsRaw = (await prompt('Team name(s) or UID(s) (comma-separated): ')).trim() + const teams = splitCommaSeparated(teamsRaw) + if (teams.length === 0) { + logger.error('At least one team name or UID is required.') + process.exitCode = 1 + return + } + + const hideSharedFolders = parseHideSharedFolders( + await prompt('Hide shared folders for these users on these teams? [on/off]: ') + ) + if (hideSharedFolders === null) { + logger.error('Invalid value. Use on or off.') + process.exitCode = 1 + return + } + + logger.info('Only users that are already members of the given team(s) will be updated.') + + const restore = suppressLogs() + let result: UpdateUsersOnTeamsResult + try { + result = await vault.updateUsersOnTeams({ users, teams, hideSharedFolders }) + } finally { + restore() + } + + const table = vault.formatUpdateTeamUserResult(result) + logger.info('') + logger.info(vault.renderUpdateTeamUserAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(updated=${result.updated}, skipped=${result.skipped}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.updated === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(updateTeamUserExample) diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index 48920dce..6bdd4355 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1,12 +1,12 @@ { "name": "@keeper-security/keeperapi", - "version": "18.0.3", + "version": "18.0.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@keeper-security/keeperapi", - "version": "18.0.3", + "version": "18.0.6", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", diff --git a/keeperapi/package.json b/keeperapi/package.json index 687676a3..13594842 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -1,7 +1,7 @@ { "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", - "version": "18.0.5", + "version": "18.0.6", "browser": "dist/browser/index.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", diff --git a/keeperapi/src/browser/platform.ts b/keeperapi/src/browser/platform.ts index c3a00dfa..92f1e75b 100644 --- a/keeperapi/src/browser/platform.ts +++ b/keeperapi/src/browser/platform.ts @@ -86,7 +86,7 @@ export const browserPlatform: Platform = class { static async importKey(keyId: string, key: Uint8Array, storage?: KeyStorage, canExport?: boolean): Promise { // An AES key for one of our Keeper objects can be used for either CBC or GCM operations. // Since CryptoKeys are bound to a particular algorithm, we need to keep a copy for each. - const extractable = !!canExport + const extractable = typeof storage?.saveKeyBytes === 'function' ? true : !!canExport const cbcKey = await this.aesCbcImportKey(key, extractable) const gcmKey = await this.aesGcmImportKey(key, extractable) cryptoKeysCache['cbc'][keyId] = cbcKey @@ -96,7 +96,8 @@ export const browserPlatform: Platform = class { if (storage.saveObject) { await storage.saveObject(this.getStorageKeyId(keyId, 'cbc'), cbcKey) await storage.saveObject(this.getStorageKeyId(keyId, 'gcm'), gcmKey) - } else { + } + if (storage.saveKeyBytes) { await storage.saveKeyBytes(keyId, key) } } @@ -114,7 +115,8 @@ export const browserPlatform: Platform = class { if (storage) { if (storage.saveObject) { await storage.saveObject(this.getStorageKeyId(keyId, 'ecc'), key) - } else { + } + if (storage.saveKeyBytes) { const jwk = await crypto.subtle.exportKey('jwk', key) const keyBytes = this.stringToBytes(JSON.stringify(jwk)) await storage.saveKeyBytes(keyId, keyBytes) @@ -168,10 +170,9 @@ export const browserPlatform: Platform = class { if (storage?.getObject) { const storageKeyId = this.getStorageKeyId(keyId, keyType) const storedKey = await storage.getObject(storageKeyId) - if (!storedKey) { - throw new Error('Unable to load crypto key ' + keyId) + if (storedKey) { + return storedKey } - return storedKey } const keyBytes = await this.loadKeyBytes(keyId, storage) @@ -209,9 +210,34 @@ export const browserPlatform: Platform = class { const key = await this.loadCryptoKey(keyId, keyType, storage) cryptoKeysCache[keyType][keyId] = key + // AES folder/record keys are imported as a pair; hydrate the sibling cache entry too. + if (keyType === 'cbc' || keyType === 'gcm') { + const sibling: EncryptionType = keyType === 'cbc' ? 'gcm' : 'cbc' + if (!cryptoKeysCache[sibling][keyId]) { + try { + cryptoKeysCache[sibling][keyId] = await this.loadCryptoKey(keyId, sibling, storage) + } catch { + // sibling may be unavailable for legacy key material + } + } + } return key } + static async ensureAesKeyLoaded(keyId: string, storage?: KeyStorage): Promise { + if (cryptoKeysCache['cbc'][keyId] && cryptoKeysCache['gcm'][keyId]) { + return true + } + if (storage?.getKeyBytes) { + const keyBytes = await storage.getKeyBytes(keyId) + if (keyBytes) { + await this.importKey(keyId, keyBytes, storage, true) + return true + } + } + return false + } + static async unwrapKeys(keys: UnwrapKeyMap, storage?: KeyStorage): Promise { if (workerPool) { try { @@ -288,9 +314,7 @@ export const browserPlatform: Platform = class { await this.unwrapRSAKey(key, keyId, unwrappingKeyId, encryptionType, storage) break case 'aes': - if (cryptoKeysCache['gcm'][keyId]) { - // Keeperapp sometimes provides redundant key data, for example, like if you own a record in a shared folder, - // or if a record belongs to multiple shared folders. So, short circuit when possible for a performance improvement + if (await this.ensureAesKeyLoaded(keyId, storage)) { return } @@ -298,7 +322,7 @@ export const browserPlatform: Platform = class { break // TODO: add something like this, need to find pub/priv key pair case 'ecc': - if (cryptoKeysCache['gcm'][keyId]) { + if (cryptoKeysCache['ecc'][keyId]) { return } @@ -365,7 +389,8 @@ export const browserPlatform: Platform = class { break } - const canExtract: boolean = storage?.saveObject ? !!canExport : true + const mustPersistKeyBytes = typeof storage?.saveKeyBytes === 'function' + const canExtract: boolean = mustPersistKeyBytes ? true : storage?.saveObject ? !!canExport : true const keyUsages: KeyUsage[] = ['encrypt', 'decrypt', 'unwrapKey', 'wrapKey'] const gcmKey = await crypto.subtle.unwrapKey( @@ -394,7 +419,8 @@ export const browserPlatform: Platform = class { if (storage.saveObject) { await storage.saveObject(this.getStorageKeyId(keyId, 'cbc'), cbcKey) await storage.saveObject(this.getStorageKeyId(keyId, 'gcm'), gcmKey) - } else { + } + if (storage.saveKeyBytes) { const keyBuffer = await crypto.subtle.exportKey('raw', gcmKey) await storage.saveKeyBytes(keyId, new Uint8Array(keyBuffer)) } diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index d8d97c80..49d44e49 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -232,6 +232,86 @@ export type RoleDeleteRequest = { export const roleDeleteCommand = (request: RoleDeleteRequest): RestCommand => createCommand(request, 'role_delete') +export type RoleUserAddRequest = { + role_id: number + enterprise_user_id: number +} + +export const roleUserAddCommand = ( + request: RoleUserAddRequest +): RestCommand => createCommand(request, 'role_user_add') + +export type RoleUserRemoveRequest = { + role_id: number + enterprise_user_id: number +} + +export const roleUserRemoveCommand = ( + request: RoleUserRemoveRequest +): RestCommand => createCommand(request, 'role_user_remove') + +export type RoleManagedNodeTreeKey = { + enterprise_user_id: number + tree_key: string + tree_key_type?: number +} + +export type RoleManagedNodeAddRequest = { + role_id: number + managed_node_id: number + cascade_node_management?: boolean + tree_keys?: RoleManagedNodeTreeKey[] +} + +export const roleManagedNodeAddCommand = ( + request: RoleManagedNodeAddRequest +): RestCommand => createCommand(request, 'role_managed_node_add') + +export type RoleManagedNodeUpdateRequest = { + role_id: number + managed_node_id: number + cascade_node_management?: boolean + tree_keys?: RoleManagedNodeTreeKey[] +} + +export const roleManagedNodeUpdateCommand = ( + request: RoleManagedNodeUpdateRequest +): RestCommand => createCommand(request, 'role_managed_node_update') + +export type RoleManagedNodeRemoveRequest = { + role_id: number + managed_node_id: number +} + +export const roleManagedNodeRemoveCommand = ( + request: RoleManagedNodeRemoveRequest +): RestCommand => createCommand(request, 'role_managed_node_remove') + +export type ManagedNodePrivilegeAddRequest = { + role_id: number + managed_node_id: number + privilege: string + role_key_enc_with_tree_key?: string + role_public_key?: string + role_private_key?: string + role_keys?: unknown[] +} + +export const managedNodePrivilegeAddCommand = ( + request: ManagedNodePrivilegeAddRequest +): RestCommand => createCommand(request, 'managed_node_privilege_add') + +export type ManagedNodePrivilegeRemoveRequest = { + role_id: number + managed_node_id: number + privilege: string +} + +export const managedNodePrivilegeRemoveCommand = ( + request: ManagedNodePrivilegeRemoveRequest +): RestCommand => + createCommand(request, 'managed_node_privilege_remove') + export type MoveRequest = { to_type: 'user_folder' | 'shared_folder' | 'shared_folder_folder' to_uid?: string @@ -379,6 +459,34 @@ export const teamEnterpriseUserRemoveCommand = ( request: TeamUserCommandRequest ): RestCommand => createCommand(request, 'team_enterprise_user_remove') +export type TeamEnterpriseUserUpdateRequest = { + team_uid: string + enterprise_user_id: number + user_type: number +} + +export const teamEnterpriseUserUpdateCommand = ( + request: TeamEnterpriseUserUpdateRequest +): RestCommand => createCommand(request, 'team_enterprise_user_update') + +export type TeamGetKeysRequest = { + teams: string[] +} + +export type TeamGetKeyEntry = { + team_uid: string + key?: string + type?: number + result_code?: string +} + +export type TeamGetKeysResponse = KeeperResponse & { + keys?: TeamGetKeyEntry[] +} + +export const teamGetKeysCommand = (request: TeamGetKeysRequest): RestCommand => + createCommand(request, 'team_get_keys') + export type GetRecordHistoryRequest = { record_uid: string client_time: number diff --git a/keeperapi/src/proto.d.ts b/keeperapi/src/proto.d.ts index 771a401c..16947e6a 100644 --- a/keeperapi/src/proto.d.ts +++ b/keeperapi/src/proto.d.ts @@ -2356,6 +2356,9 @@ export namespace Authentication { /** TwoFactorChannelInfo lastFrequency */ lastFrequency?: (Authentication.TwoFactorExpiration|null); + + /** TwoFactorChannelInfo challengeToken */ + challengeToken?: (Uint8Array|null); } /** Represents a TwoFactorChannelInfo. */ @@ -2394,6 +2397,9 @@ export namespace Authentication { /** TwoFactorChannelInfo lastFrequency. */ public lastFrequency: Authentication.TwoFactorExpiration; + /** TwoFactorChannelInfo challengeToken. */ + public challengeToken: Uint8Array; + /** * Creates a new TwoFactorChannelInfo instance using the specified properties. * @param [properties] Properties to set @@ -3046,6 +3052,9 @@ export namespace Authentication { /** TwoFactorValidateRequest expireIn */ expireIn?: (Authentication.TwoFactorExpiration|null); + + /** TwoFactorValidateRequest challengeToken */ + challengeToken?: (Uint8Array|null); } /** Represents a TwoFactorValidateRequest. */ @@ -3072,6 +3081,9 @@ export namespace Authentication { /** TwoFactorValidateRequest expireIn. */ public expireIn: Authentication.TwoFactorExpiration; + /** TwoFactorValidateRequest challengeToken. */ + public challengeToken: Uint8Array; + /** * Creates a new TwoFactorValidateRequest instance using the specified properties. * @param [properties] Properties to set @@ -16935,7 +16947,8 @@ export namespace Enterprise { MAY_NOT_REMOVE_SELF_FROM_ROLE = 6, MUST_HAVE_ONE_USER_ADMIN = 7, INVALID_ROLE_ID = 8, - PAM_LICENSE_SEAT_EXCEEDED = 9 + PAM_LICENSE_SEAT_EXCEEDED = 9, + WOULD_LOCK_SELF = 10 } /** Properties of a RoleUserAddResult. */ @@ -24318,6 +24331,12 @@ export namespace Enterprise { /** UserUpdate email */ email?: (string|null); + + /** UserUpdate inviteeLocale */ + inviteeLocale?: (string|null); + + /** UserUpdate encryptedDataString */ + encryptedDataString?: (string|null); } /** Represents a UserUpdate. */ @@ -24350,6 +24369,12 @@ export namespace Enterprise { /** UserUpdate email. */ public email: string; + /** UserUpdate inviteeLocale. */ + public inviteeLocale: string; + + /** UserUpdate encryptedDataString. */ + public encryptedDataString: string; + /** * Creates a new UserUpdate instance using the specified properties. * @param [properties] Properties to set @@ -24485,6 +24510,12 @@ export namespace Enterprise { /** UserUpdateResult status */ status?: (Enterprise.UserUpdateStatus|null); + + /** UserUpdateResult errorMessage */ + errorMessage?: (string|null); + + /** UserUpdateResult additionalInfo */ + additionalInfo?: (string|null); } /** Represents a UserUpdateResult. */ @@ -24502,6 +24533,12 @@ export namespace Enterprise { /** UserUpdateResult status. */ public status: Enterprise.UserUpdateStatus; + /** UserUpdateResult errorMessage. */ + public errorMessage: string; + + /** UserUpdateResult additionalInfo. */ + public additionalInfo: string; + /** * Creates a new UserUpdateResult instance using the specified properties. * @param [properties] Properties to set @@ -24559,7 +24596,13 @@ export namespace Enterprise { /** UserUpdateStatus enum. */ enum UserUpdateStatus { USER_UPDATE_OK = 0, - USER_UPDATE_ACCESS_DENIED = 1 + USER_UPDATE_ACCESS_DENIED = 1, + USER_UPDATE_EXCEEDED_LICENSE_SEATS = 2, + USER_UPDATE_BAD_REQUEST = 3, + USER_UPDATE_DUPLICATE = 4, + USER_UPDATE_INVALID_STATE = 5, + USER_UPDATE_FAILED = 6, + USER_UPDATE_ERROR = 7 } /** Properties of a ComplianceRecordOwnersRequest. */ @@ -34630,6 +34673,12 @@ export namespace Folder { /** time limited access settings define expiration, notification and rotation policies. */ tlaProperties?: (common.tla.ITLAProperties|null); + + /** + * Record key encrypted with the owner's data key: an owner-decryptable copy independent of folder + * placement, so owner-trashed records can be decrypted and restored (KA-8946 / KA-8939). + */ + recordKeyEncryptedByOwnerKey?: (Uint8Array|null); } /** Represents a RecordMetadata. */ @@ -34653,6 +34702,12 @@ export namespace Folder { /** time limited access settings define expiration, notification and rotation policies. */ public tlaProperties?: (common.tla.ITLAProperties|null); + /** + * Record key encrypted with the owner's data key: an owner-decryptable copy independent of folder + * placement, so owner-trashed records can be decrypted and restored (KA-8946 / KA-8939). + */ + public recordKeyEncryptedByOwnerKey: Uint8Array; + /** * Creates a new RecordMetadata instance using the specified properties. * @param [properties] Properties to set @@ -36673,6 +36728,9 @@ export namespace Records { /** FolderRecordKey recordKey */ recordKey?: (Uint8Array|null); + + /** FolderRecordKey recordKeyType */ + recordKeyType?: (Records.RecordKeyType|null); } /** Represents a FolderRecordKey. */ @@ -36693,6 +36751,9 @@ export namespace Records { /** FolderRecordKey recordKey. */ public recordKey: Uint8Array; + /** FolderRecordKey recordKeyType. */ + public recordKeyType: Records.RecordKeyType; + /** * Creates a new FolderRecordKey instance using the specified properties. * @param [properties] Properties to set @@ -46438,7 +46499,8 @@ export namespace Automator { UNKNOWN_SKILL_TYPE = 0, DEVICE_APPROVAL = 1, TEAM_APPROVAL = 2, - TEAM_FOR_USER_APPROVAL = 3 + TEAM_FOR_USER_APPROVAL = 3, + REPORTING = 4 } /** Properties of a LogEntry. */ @@ -59612,6 +59674,79 @@ export namespace Tokens { public static getTypeUrl(typeUrlPrefix?: string): string; } + /** Properties of a MemcachedString. */ + interface IMemcachedString { + + /** MemcachedString value */ + value?: (string|null); + } + + /** Represents a MemcachedString. */ + class MemcachedString implements IMemcachedString { + + /** + * Constructs a new MemcachedString. + * @param [properties] Properties to set + */ + constructor(properties?: Tokens.IMemcachedString); + + /** MemcachedString value. */ + public value: string; + + /** + * Creates a new MemcachedString instance using the specified properties. + * @param [properties] Properties to set + * @returns MemcachedString instance + */ + public static create(properties?: Tokens.IMemcachedString): Tokens.MemcachedString; + + /** + * Encodes the specified MemcachedString message. Does not implicitly {@link Tokens.MemcachedString.verify|verify} messages. + * @param message MemcachedString message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Tokens.IMemcachedString, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a MemcachedString message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns MemcachedString + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): Tokens.MemcachedString; + + /** + * Creates a MemcachedString message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns MemcachedString + */ + public static fromObject(object: { [k: string]: any }): Tokens.MemcachedString; + + /** + * Creates a plain object from a MemcachedString message. Also converts values to other types if specified. + * @param message MemcachedString + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Tokens.MemcachedString, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this MemcachedString to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for MemcachedString + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + /** Properties of an IncrementalSecurityDataContToken. */ interface IIncrementalSecurityDataContToken { @@ -69615,6 +69750,9 @@ export namespace record { /** RecordAdd securityScoreData */ securityScoreData?: (Records.ISecurityScoreData|null); + + /** RecordAdd recordKeyEncryptedByOwnerKey */ + recordKeyEncryptedByOwnerKey?: (Uint8Array|null); } /** Represents a RecordAdd. */ @@ -69665,6 +69803,9 @@ export namespace record { /** RecordAdd securityScoreData. */ public securityScoreData?: (Records.ISecurityScoreData|null); + /** RecordAdd recordKeyEncryptedByOwnerKey. */ + public recordKeyEncryptedByOwnerKey: Uint8Array; + /** * Creates a new RecordAdd instance using the specified properties. * @param [properties] Properties to set @@ -71142,6 +71283,9 @@ export namespace BI { /** SubscriptionStatusResponse ksm */ ksm?: (BI.IKsmBilling|null); + + /** SubscriptionStatusResponse epm */ + epm?: (BI.IEpmBilling|null); } /** Represents a SubscriptionStatusResponse. */ @@ -71198,6 +71342,9 @@ export namespace BI { /** SubscriptionStatusResponse ksm. */ public ksm?: (BI.IKsmBilling|null); + /** SubscriptionStatusResponse epm. */ + public epm?: (BI.IEpmBilling|null); + /** * Creates a new SubscriptionStatusResponse instance using the specified properties. * @param [properties] Properties to set @@ -71349,6 +71496,103 @@ export namespace BI { public static getTypeUrl(typeUrlPrefix?: string): string; } + /** Properties of an EpmBilling. */ + interface IEpmBilling { + + /** EpmBilling billingStartTimestamp */ + billingStartTimestamp?: (number|null); + + /** EpmBilling billingEndTimestamp */ + billingEndTimestamp?: (number|null); + + /** EpmBilling currentTierId */ + currentTierId?: (number|null); + + /** EpmBilling enterpriseBlocks */ + enterpriseBlocks?: (number|null); + + /** EpmBilling currentTierCeiling */ + currentTierCeiling?: (number|null); + } + + /** Represents an EpmBilling. */ + class EpmBilling implements IEpmBilling { + + /** + * Constructs a new EpmBilling. + * @param [properties] Properties to set + */ + constructor(properties?: BI.IEpmBilling); + + /** EpmBilling billingStartTimestamp. */ + public billingStartTimestamp: number; + + /** EpmBilling billingEndTimestamp. */ + public billingEndTimestamp: number; + + /** EpmBilling currentTierId. */ + public currentTierId: number; + + /** EpmBilling enterpriseBlocks. */ + public enterpriseBlocks: number; + + /** EpmBilling currentTierCeiling. */ + public currentTierCeiling: number; + + /** + * Creates a new EpmBilling instance using the specified properties. + * @param [properties] Properties to set + * @returns EpmBilling instance + */ + public static create(properties?: BI.IEpmBilling): BI.EpmBilling; + + /** + * Encodes the specified EpmBilling message. Does not implicitly {@link BI.EpmBilling.verify|verify} messages. + * @param message EpmBilling message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.IEpmBilling, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes an EpmBilling message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns EpmBilling + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): BI.EpmBilling; + + /** + * Creates an EpmBilling message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns EpmBilling + */ + public static fromObject(object: { [k: string]: any }): BI.EpmBilling; + + /** + * Creates a plain object from an EpmBilling message. Also converts values to other types if specified. + * @param message EpmBilling + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.EpmBilling, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this EpmBilling to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for EpmBilling + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + /** Properties of a NhiBilling. */ interface INhiBilling { @@ -75957,6 +76201,15 @@ export namespace BI { /** CustomerCaptureRequest notes */ notes?: (string|null); + + /** CustomerCaptureRequest extensionVersion */ + extensionVersion?: (string|null); + + /** CustomerCaptureRequest aiAutofillStatus */ + aiAutofillStatus?: (string|null); + + /** CustomerCaptureRequest mlLabels */ + mlLabels?: (string|null); } /** Represents a CustomerCaptureRequest. */ @@ -75995,6 +76248,15 @@ export namespace BI { /** CustomerCaptureRequest notes. */ public notes: string; + /** CustomerCaptureRequest extensionVersion. */ + public extensionVersion?: (string|null); + + /** CustomerCaptureRequest aiAutofillStatus. */ + public aiAutofillStatus?: (string|null); + + /** CustomerCaptureRequest mlLabels. */ + public mlLabels?: (string|null); + /** * Creates a new CustomerCaptureRequest instance using the specified properties. * @param [properties] Properties to set @@ -85562,6 +85824,9 @@ export namespace Router { /** Router2FAValidateRequest value */ value?: (string|null); + + /** Router2FAValidateRequest challengeToken */ + challengeToken?: (Uint8Array|null); } /** Represents a Router2FAValidateRequest. */ @@ -85582,6 +85847,9 @@ export namespace Router { /** Router2FAValidateRequest value. */ public value: string; + /** Router2FAValidateRequest challengeToken. */ + public challengeToken: Uint8Array; + /** * Creates a new Router2FAValidateRequest instance using the specified properties. * @param [properties] Properties to set @@ -85808,6 +86076,9 @@ export namespace Router { /** Router2FAGetWebAuthnChallengeResponse capabilities */ capabilities?: (string[]|null); + + /** Router2FAGetWebAuthnChallengeResponse challengeToken */ + challengeToken?: (Uint8Array|null); } /** Represents a Router2FAGetWebAuthnChallengeResponse. */ @@ -85825,6 +86096,9 @@ export namespace Router { /** Router2FAGetWebAuthnChallengeResponse capabilities. */ public capabilities: string[]; + /** Router2FAGetWebAuthnChallengeResponse challengeToken. */ + public challengeToken: Uint8Array; + /** * Creates a new Router2FAGetWebAuthnChallengeResponse instance using the specified properties. * @param [properties] Properties to set @@ -87880,6 +88154,12 @@ export namespace PAM { /** PAMController isInitialized */ isInitialized?: (boolean|null); + + /** PAMController maxInstanceCount */ + maxInstanceCount?: (number|null); + + /** PAMController lastSeen */ + lastSeen?: (number|null); } /** Represents a PAMController. */ @@ -87921,6 +88201,12 @@ export namespace PAM { /** PAMController isInitialized. */ public isInitialized: boolean; + /** PAMController maxInstanceCount. */ + public maxInstanceCount: number; + + /** PAMController lastSeen. */ + public lastSeen: number; + /** * Creates a new PAMController instance using the specified properties. * @param [properties] Properties to set diff --git a/keeperapi/src/proto/Authentication.js b/keeperapi/src/proto/Authentication.js index 10c1c2dd..1b305ac9 100644 --- a/keeperapi/src/proto/Authentication.js +++ b/keeperapi/src/proto/Authentication.js @@ -6999,6 +6999,7 @@ export const Authentication = $root.Authentication = (() => { * @property {Authentication.TwoFactorExpiration|null} [maxExpiration] TwoFactorChannelInfo maxExpiration * @property {number|null} [createdOn] TwoFactorChannelInfo createdOn * @property {Authentication.TwoFactorExpiration|null} [lastFrequency] TwoFactorChannelInfo lastFrequency + * @property {Uint8Array|null} [challengeToken] TwoFactorChannelInfo challengeToken */ /** @@ -7089,6 +7090,14 @@ export const Authentication = $root.Authentication = (() => { */ TwoFactorChannelInfo.prototype.lastFrequency = 0; + /** + * TwoFactorChannelInfo challengeToken. + * @member {Uint8Array} challengeToken + * @memberof Authentication.TwoFactorChannelInfo + * @instance + */ + TwoFactorChannelInfo.prototype.challengeToken = $util.newBuffer([]); + /** * Creates a new TwoFactorChannelInfo instance using the specified properties. * @function create @@ -7136,6 +7145,8 @@ export const Authentication = $root.Authentication = (() => { writer.uint32(/* id 8, wireType 0 =*/64).int64(message.createdOn); if (message.lastFrequency != null && Object.hasOwnProperty.call(message, "lastFrequency")) writer.uint32(/* id 9, wireType 0 =*/72).int32(message.lastFrequency); + if (message.challengeToken != null && Object.hasOwnProperty.call(message, "challengeToken")) + writer.uint32(/* id 10, wireType 2 =*/82).bytes(message.challengeToken); return writer; }; @@ -7201,6 +7212,10 @@ export const Authentication = $root.Authentication = (() => { message.lastFrequency = reader.int32(); break; } + case 10: { + message.challengeToken = reader.bytes(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -7366,6 +7381,11 @@ export const Authentication = $root.Authentication = (() => { message.lastFrequency = 5; break; } + if (object.challengeToken != null) + if (typeof object.challengeToken === "string") + $util.base64.decode(object.challengeToken, message.challengeToken = $util.newBuffer($util.base64.length(object.challengeToken)), 0); + else if (object.challengeToken.length >= 0) + message.challengeToken = object.challengeToken; return message; }; @@ -7407,6 +7427,13 @@ export const Authentication = $root.Authentication = (() => { } else object.createdOn = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; object.lastFrequency = options.enums === String ? "TWO_FA_EXP_IMMEDIATELY" : 0; + if (options.bytes === String) + object.challengeToken = ""; + else { + object.challengeToken = []; + if (options.bytes !== Array) + object.challengeToken = $util.newBuffer(object.challengeToken); + } } if (message.channelType != null && Object.hasOwnProperty.call(message, "channelType")) object.channelType = options.enums === String ? $root.Authentication.TwoFactorChannelType[message.channelType] === undefined ? message.channelType : $root.Authentication.TwoFactorChannelType[message.channelType] : message.channelType; @@ -7434,6 +7461,8 @@ export const Authentication = $root.Authentication = (() => { object.createdOn = options.longs === String ? $util.Long.prototype.toString.call(message.createdOn) : options.longs === Number ? new $util.LongBits(message.createdOn.low >>> 0, message.createdOn.high >>> 0).toNumber() : message.createdOn; if (message.lastFrequency != null && Object.hasOwnProperty.call(message, "lastFrequency")) object.lastFrequency = options.enums === String ? $root.Authentication.TwoFactorExpiration[message.lastFrequency] === undefined ? message.lastFrequency : $root.Authentication.TwoFactorExpiration[message.lastFrequency] : message.lastFrequency; + if (message.challengeToken != null && Object.hasOwnProperty.call(message, "challengeToken")) + object.challengeToken = options.bytes === String ? $util.base64.encode(message.challengeToken, 0, message.challengeToken.length) : options.bytes === Array ? Array.prototype.slice.call(message.challengeToken) : message.challengeToken; return object; }; @@ -9136,6 +9165,7 @@ export const Authentication = $root.Authentication = (() => { * @property {string|null} [value] TwoFactorValidateRequest value * @property {Uint8Array|null} [channelUid] TwoFactorValidateRequest channelUid * @property {Authentication.TwoFactorExpiration|null} [expireIn] TwoFactorValidateRequest expireIn + * @property {Uint8Array|null} [challengeToken] TwoFactorValidateRequest challengeToken */ /** @@ -9193,6 +9223,14 @@ export const Authentication = $root.Authentication = (() => { */ TwoFactorValidateRequest.prototype.expireIn = 0; + /** + * TwoFactorValidateRequest challengeToken. + * @member {Uint8Array} challengeToken + * @memberof Authentication.TwoFactorValidateRequest + * @instance + */ + TwoFactorValidateRequest.prototype.challengeToken = $util.newBuffer([]); + /** * Creates a new TwoFactorValidateRequest instance using the specified properties. * @function create @@ -9231,6 +9269,8 @@ export const Authentication = $root.Authentication = (() => { writer.uint32(/* id 4, wireType 2 =*/34).bytes(message.channelUid); if (message.expireIn != null && Object.hasOwnProperty.call(message, "expireIn")) writer.uint32(/* id 5, wireType 0 =*/40).int32(message.expireIn); + if (message.challengeToken != null && Object.hasOwnProperty.call(message, "challengeToken")) + writer.uint32(/* id 6, wireType 2 =*/50).bytes(message.challengeToken); return writer; }; @@ -9278,6 +9318,10 @@ export const Authentication = $root.Authentication = (() => { message.expireIn = reader.int32(); break; } + case 6: { + message.challengeToken = reader.bytes(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -9388,6 +9432,11 @@ export const Authentication = $root.Authentication = (() => { message.expireIn = 5; break; } + if (object.challengeToken != null) + if (typeof object.challengeToken === "string") + $util.base64.decode(object.challengeToken, message.challengeToken = $util.newBuffer($util.base64.length(object.challengeToken)), 0); + else if (object.challengeToken.length >= 0) + message.challengeToken = object.challengeToken; return message; }; @@ -9426,6 +9475,13 @@ export const Authentication = $root.Authentication = (() => { object.channelUid = $util.newBuffer(object.channelUid); } object.expireIn = options.enums === String ? "TWO_FA_EXP_IMMEDIATELY" : 0; + if (options.bytes === String) + object.challengeToken = ""; + else { + object.challengeToken = []; + if (options.bytes !== Array) + object.challengeToken = $util.newBuffer(object.challengeToken); + } } if (message.encryptedLoginToken != null && Object.hasOwnProperty.call(message, "encryptedLoginToken")) object.encryptedLoginToken = options.bytes === String ? $util.base64.encode(message.encryptedLoginToken, 0, message.encryptedLoginToken.length) : options.bytes === Array ? Array.prototype.slice.call(message.encryptedLoginToken) : message.encryptedLoginToken; @@ -9437,6 +9493,8 @@ export const Authentication = $root.Authentication = (() => { object.channelUid = options.bytes === String ? $util.base64.encode(message.channelUid, 0, message.channelUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.channelUid) : message.channelUid; if (message.expireIn != null && Object.hasOwnProperty.call(message, "expireIn")) object.expireIn = options.enums === String ? $root.Authentication.TwoFactorExpiration[message.expireIn] === undefined ? message.expireIn : $root.Authentication.TwoFactorExpiration[message.expireIn] : message.expireIn; + if (message.challengeToken != null && Object.hasOwnProperty.call(message, "challengeToken")) + object.challengeToken = options.bytes === String ? $util.base64.encode(message.challengeToken, 0, message.challengeToken.length) : options.bytes === Array ? Array.prototype.slice.call(message.challengeToken) : message.challengeToken; return object; }; diff --git a/keeperapi/src/proto/Automator.js b/keeperapi/src/proto/Automator.js index b7076c37..7db55792 100644 --- a/keeperapi/src/proto/Automator.js +++ b/keeperapi/src/proto/Automator.js @@ -4329,6 +4329,7 @@ export const Automator = $root.Automator = (() => { * @property {number} DEVICE_APPROVAL=1 DEVICE_APPROVAL value * @property {number} TEAM_APPROVAL=2 TEAM_APPROVAL value * @property {number} TEAM_FOR_USER_APPROVAL=3 TEAM_FOR_USER_APPROVAL value + * @property {number} REPORTING=4 REPORTING value */ Automator.SkillType = (function() { const valuesById = {}, values = Object.create(valuesById); @@ -4336,6 +4337,7 @@ export const Automator = $root.Automator = (() => { values[valuesById[1] = "DEVICE_APPROVAL"] = 1; values[valuesById[2] = "TEAM_APPROVAL"] = 2; values[valuesById[3] = "TEAM_FOR_USER_APPROVAL"] = 3; + values[valuesById[4] = "REPORTING"] = 4; return values; })(); @@ -6835,6 +6837,10 @@ export const Automator = $root.Automator = (() => { case 3: message.skillTypes[i] = 3; break; + case "REPORTING": + case 4: + message.skillTypes[i] = 4; + break; } } if (object.automatorSettingValues) { @@ -7224,6 +7230,10 @@ export const Automator = $root.Automator = (() => { case 3: message.skillTypes[i] = 3; break; + case "REPORTING": + case 4: + message.skillTypes[i] = 4; + break; } } if (object.encryptedTreeKey != null) @@ -8016,6 +8026,10 @@ export const Automator = $root.Automator = (() => { case 3: message.skillType = 3; break; + case "REPORTING": + case 4: + message.skillType = 4; + break; } if (object.name != null) message.name = String(object.name); diff --git a/keeperapi/src/proto/BI.js b/keeperapi/src/proto/BI.js index 33fc8090..e3a259c2 100644 --- a/keeperapi/src/proto/BI.js +++ b/keeperapi/src/proto/BI.js @@ -846,6 +846,7 @@ export const BI = $root.BI = (() => { * @property {BI.INhiBilling|null} [nhi] SubscriptionStatusResponse nhi * @property {number|null} [freeKsmApiCallsCount] SubscriptionStatusResponse freeKsmApiCallsCount * @property {BI.IKsmBilling|null} [ksm] SubscriptionStatusResponse ksm + * @property {BI.IEpmBilling|null} [epm] SubscriptionStatusResponse epm */ /** @@ -984,6 +985,14 @@ export const BI = $root.BI = (() => { */ SubscriptionStatusResponse.prototype.ksm = null; + /** + * SubscriptionStatusResponse epm. + * @member {BI.IEpmBilling|null|undefined} epm + * @memberof BI.SubscriptionStatusResponse + * @instance + */ + SubscriptionStatusResponse.prototype.epm = null; + /** * Creates a new SubscriptionStatusResponse instance using the specified properties. * @function create @@ -1043,6 +1052,8 @@ export const BI = $root.BI = (() => { writer.uint32(/* id 15, wireType 0 =*/120).int32(message.freeKsmApiCallsCount); if (message.ksm != null && Object.hasOwnProperty.call(message, "ksm")) $root.BI.KsmBilling.encode(message.ksm, writer.uint32(/* id 16, wireType 2 =*/130).fork(), q + 1).ldelim(); + if (message.epm != null && Object.hasOwnProperty.call(message, "epm")) + $root.BI.EpmBilling.encode(message.epm, writer.uint32(/* id 17, wireType 2 =*/138).fork(), q + 1).ldelim(); return writer; }; @@ -1132,6 +1143,10 @@ export const BI = $root.BI = (() => { message.ksm = $root.BI.KsmBilling.decode(reader, reader.uint32(), undefined, long + 1); break; } + case 17: { + message.epm = $root.BI.EpmBilling.decode(reader, reader.uint32(), undefined, long + 1); + break; + } default: reader.skipType(tag & 7, long); break; @@ -1237,6 +1252,11 @@ export const BI = $root.BI = (() => { throw TypeError(".BI.SubscriptionStatusResponse.ksm: object expected"); message.ksm = $root.BI.KsmBilling.fromObject(object.ksm, long + 1); } + if (object.epm != null) { + if (!$util.isObject(object.epm)) + throw TypeError(".BI.SubscriptionStatusResponse.epm: object expected"); + message.epm = $root.BI.EpmBilling.fromObject(object.epm, long + 1); + } return message; }; @@ -1278,6 +1298,7 @@ export const BI = $root.BI = (() => { object.nhi = null; object.freeKsmApiCallsCount = 0; object.ksm = null; + object.epm = null; } if (message.autoRenewal != null && Object.hasOwnProperty.call(message, "autoRenewal")) object.autoRenewal = $root.BI.AutoRenewal.toObject(message.autoRenewal, options, q + 1); @@ -1317,6 +1338,8 @@ export const BI = $root.BI = (() => { object.freeKsmApiCallsCount = message.freeKsmApiCallsCount; if (message.ksm != null && Object.hasOwnProperty.call(message, "ksm")) object.ksm = $root.BI.KsmBilling.toObject(message.ksm, options, q + 1); + if (message.epm != null && Object.hasOwnProperty.call(message, "epm")) + object.epm = $root.BI.EpmBilling.toObject(message.epm, options, q + 1); return object; }; @@ -1639,6 +1662,312 @@ export const BI = $root.BI = (() => { return KsmBilling; })(); + BI.EpmBilling = (function() { + + /** + * Properties of an EpmBilling. + * @memberof BI + * @interface IEpmBilling + * @property {number|null} [billingStartTimestamp] EpmBilling billingStartTimestamp + * @property {number|null} [billingEndTimestamp] EpmBilling billingEndTimestamp + * @property {number|null} [currentTierId] EpmBilling currentTierId + * @property {number|null} [enterpriseBlocks] EpmBilling enterpriseBlocks + * @property {number|null} [currentTierCeiling] EpmBilling currentTierCeiling + */ + + /** + * Constructs a new EpmBilling. + * @memberof BI + * @classdesc Represents an EpmBilling. + * @implements IEpmBilling + * @constructor + * @param {BI.IEpmBilling=} [properties] Properties to set + */ + function EpmBilling(properties) { + if (properties) + for (let keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null && keys[i] !== "__proto__") + this[keys[i]] = properties[keys[i]]; + } + + /** + * EpmBilling billingStartTimestamp. + * @member {number} billingStartTimestamp + * @memberof BI.EpmBilling + * @instance + */ + EpmBilling.prototype.billingStartTimestamp = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * EpmBilling billingEndTimestamp. + * @member {number} billingEndTimestamp + * @memberof BI.EpmBilling + * @instance + */ + EpmBilling.prototype.billingEndTimestamp = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * EpmBilling currentTierId. + * @member {number} currentTierId + * @memberof BI.EpmBilling + * @instance + */ + EpmBilling.prototype.currentTierId = 0; + + /** + * EpmBilling enterpriseBlocks. + * @member {number} enterpriseBlocks + * @memberof BI.EpmBilling + * @instance + */ + EpmBilling.prototype.enterpriseBlocks = 0; + + /** + * EpmBilling currentTierCeiling. + * @member {number} currentTierCeiling + * @memberof BI.EpmBilling + * @instance + */ + EpmBilling.prototype.currentTierCeiling = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * Creates a new EpmBilling instance using the specified properties. + * @function create + * @memberof BI.EpmBilling + * @static + * @param {BI.IEpmBilling=} [properties] Properties to set + * @returns {BI.EpmBilling} EpmBilling instance + */ + EpmBilling.create = function create(properties) { + return new EpmBilling(properties); + }; + + /** + * Encodes the specified EpmBilling message. Does not implicitly {@link BI.EpmBilling.verify|verify} messages. + * @function encode + * @memberof BI.EpmBilling + * @static + * @param {BI.IEpmBilling} message EpmBilling message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + EpmBilling.encode = function encode(message, writer, q) { + if (!writer) + writer = $Writer.create(); + if (q === undefined) + q = 0; + if (q > $util.recursionLimit) + throw Error("max depth exceeded"); + if (message.billingStartTimestamp != null && Object.hasOwnProperty.call(message, "billingStartTimestamp")) + writer.uint32(/* id 1, wireType 0 =*/8).int64(message.billingStartTimestamp); + if (message.billingEndTimestamp != null && Object.hasOwnProperty.call(message, "billingEndTimestamp")) + writer.uint32(/* id 2, wireType 0 =*/16).int64(message.billingEndTimestamp); + if (message.currentTierId != null && Object.hasOwnProperty.call(message, "currentTierId")) + writer.uint32(/* id 3, wireType 0 =*/24).int32(message.currentTierId); + if (message.enterpriseBlocks != null && Object.hasOwnProperty.call(message, "enterpriseBlocks")) + writer.uint32(/* id 4, wireType 0 =*/32).int32(message.enterpriseBlocks); + if (message.currentTierCeiling != null && Object.hasOwnProperty.call(message, "currentTierCeiling")) + writer.uint32(/* id 5, wireType 0 =*/40).int64(message.currentTierCeiling); + return writer; + }; + + /** + * Decodes an EpmBilling message from the specified reader or buffer. + * @function decode + * @memberof BI.EpmBilling + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {BI.EpmBilling} EpmBilling + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + EpmBilling.decode = function decode(reader, length, error, long) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + if (long === undefined) + long = 0; + if (long > $Reader.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let end = length === undefined ? reader.len : reader.pos + length, message = new $root.BI.EpmBilling(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.billingStartTimestamp = reader.int64(); + break; + } + case 2: { + message.billingEndTimestamp = reader.int64(); + break; + } + case 3: { + message.currentTierId = reader.int32(); + break; + } + case 4: { + message.enterpriseBlocks = reader.int32(); + break; + } + case 5: { + message.currentTierCeiling = reader.int64(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates an EpmBilling message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof BI.EpmBilling + * @static + * @param {Object.} object Plain object + * @returns {BI.EpmBilling} EpmBilling + */ + EpmBilling.fromObject = function fromObject(object, long) { + if (object instanceof $root.BI.EpmBilling) + return object; + if (!$util.isObject(object)) + throw TypeError(".BI.EpmBilling: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.BI.EpmBilling(); + if (object.billingStartTimestamp != null) + if ($util.Long) + message.billingStartTimestamp = $util.Long.fromValue(object.billingStartTimestamp, false); + else if (typeof object.billingStartTimestamp === "string") + message.billingStartTimestamp = parseInt(object.billingStartTimestamp, 10); + else if (typeof object.billingStartTimestamp === "number") + message.billingStartTimestamp = object.billingStartTimestamp; + else if (typeof object.billingStartTimestamp === "object") + message.billingStartTimestamp = new $util.LongBits(object.billingStartTimestamp.low >>> 0, object.billingStartTimestamp.high >>> 0).toNumber(); + if (object.billingEndTimestamp != null) + if ($util.Long) + message.billingEndTimestamp = $util.Long.fromValue(object.billingEndTimestamp, false); + else if (typeof object.billingEndTimestamp === "string") + message.billingEndTimestamp = parseInt(object.billingEndTimestamp, 10); + else if (typeof object.billingEndTimestamp === "number") + message.billingEndTimestamp = object.billingEndTimestamp; + else if (typeof object.billingEndTimestamp === "object") + message.billingEndTimestamp = new $util.LongBits(object.billingEndTimestamp.low >>> 0, object.billingEndTimestamp.high >>> 0).toNumber(); + if (object.currentTierId != null) + message.currentTierId = object.currentTierId | 0; + if (object.enterpriseBlocks != null) + message.enterpriseBlocks = object.enterpriseBlocks | 0; + if (object.currentTierCeiling != null) + if ($util.Long) + message.currentTierCeiling = $util.Long.fromValue(object.currentTierCeiling, false); + else if (typeof object.currentTierCeiling === "string") + message.currentTierCeiling = parseInt(object.currentTierCeiling, 10); + else if (typeof object.currentTierCeiling === "number") + message.currentTierCeiling = object.currentTierCeiling; + else if (typeof object.currentTierCeiling === "object") + message.currentTierCeiling = new $util.LongBits(object.currentTierCeiling.low >>> 0, object.currentTierCeiling.high >>> 0).toNumber(); + return message; + }; + + /** + * Creates a plain object from an EpmBilling message. Also converts values to other types if specified. + * @function toObject + * @memberof BI.EpmBilling + * @static + * @param {BI.EpmBilling} message EpmBilling + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + EpmBilling.toObject = function toObject(message, options, q) { + if (!options) + options = {}; + if (q === undefined) + q = 0; + if (q > $util.recursionLimit) + throw Error("max depth exceeded"); + let object = {}; + if (options.defaults) { + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.billingStartTimestamp = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.billingStartTimestamp = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.billingEndTimestamp = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.billingEndTimestamp = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + object.currentTierId = 0; + object.enterpriseBlocks = 0; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.currentTierCeiling = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.currentTierCeiling = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + } + if (message.billingStartTimestamp != null && Object.hasOwnProperty.call(message, "billingStartTimestamp")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.billingStartTimestamp = typeof message.billingStartTimestamp === "number" ? BigInt(message.billingStartTimestamp) : $util.Long.fromBits(message.billingStartTimestamp.low >>> 0, message.billingStartTimestamp.high >>> 0, false).toBigInt(); + else if (typeof message.billingStartTimestamp === "number") + object.billingStartTimestamp = options.longs === String ? String(message.billingStartTimestamp) : message.billingStartTimestamp; + else + object.billingStartTimestamp = options.longs === String ? $util.Long.prototype.toString.call(message.billingStartTimestamp) : options.longs === Number ? new $util.LongBits(message.billingStartTimestamp.low >>> 0, message.billingStartTimestamp.high >>> 0).toNumber() : message.billingStartTimestamp; + if (message.billingEndTimestamp != null && Object.hasOwnProperty.call(message, "billingEndTimestamp")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.billingEndTimestamp = typeof message.billingEndTimestamp === "number" ? BigInt(message.billingEndTimestamp) : $util.Long.fromBits(message.billingEndTimestamp.low >>> 0, message.billingEndTimestamp.high >>> 0, false).toBigInt(); + else if (typeof message.billingEndTimestamp === "number") + object.billingEndTimestamp = options.longs === String ? String(message.billingEndTimestamp) : message.billingEndTimestamp; + else + object.billingEndTimestamp = options.longs === String ? $util.Long.prototype.toString.call(message.billingEndTimestamp) : options.longs === Number ? new $util.LongBits(message.billingEndTimestamp.low >>> 0, message.billingEndTimestamp.high >>> 0).toNumber() : message.billingEndTimestamp; + if (message.currentTierId != null && Object.hasOwnProperty.call(message, "currentTierId")) + object.currentTierId = message.currentTierId; + if (message.enterpriseBlocks != null && Object.hasOwnProperty.call(message, "enterpriseBlocks")) + object.enterpriseBlocks = message.enterpriseBlocks; + if (message.currentTierCeiling != null && Object.hasOwnProperty.call(message, "currentTierCeiling")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.currentTierCeiling = typeof message.currentTierCeiling === "number" ? BigInt(message.currentTierCeiling) : $util.Long.fromBits(message.currentTierCeiling.low >>> 0, message.currentTierCeiling.high >>> 0, false).toBigInt(); + else if (typeof message.currentTierCeiling === "number") + object.currentTierCeiling = options.longs === String ? String(message.currentTierCeiling) : message.currentTierCeiling; + else + object.currentTierCeiling = options.longs === String ? $util.Long.prototype.toString.call(message.currentTierCeiling) : options.longs === Number ? new $util.LongBits(message.currentTierCeiling.low >>> 0, message.currentTierCeiling.high >>> 0).toNumber() : message.currentTierCeiling; + return object; + }; + + /** + * Converts this EpmBilling to JSON. + * @function toJSON + * @memberof BI.EpmBilling + * @instance + * @returns {Object.} JSON object + */ + EpmBilling.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for EpmBilling + * @function getTypeUrl + * @memberof BI.EpmBilling + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + EpmBilling.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/BI.EpmBilling"; + }; + + return EpmBilling; + })(); + BI.NhiBilling = (function() { /** @@ -13740,6 +14069,9 @@ export const BI = $root.BI = (() => { * @property {boolean|null} [test] CustomerCaptureRequest test * @property {string|null} [issueType] CustomerCaptureRequest issueType * @property {string|null} [notes] CustomerCaptureRequest notes + * @property {string|null} [extensionVersion] CustomerCaptureRequest extensionVersion + * @property {string|null} [aiAutofillStatus] CustomerCaptureRequest aiAutofillStatus + * @property {string|null} [mlLabels] CustomerCaptureRequest mlLabels */ /** @@ -13829,6 +14161,51 @@ export const BI = $root.BI = (() => { */ CustomerCaptureRequest.prototype.notes = ""; + /** + * CustomerCaptureRequest extensionVersion. + * @member {string|null|undefined} extensionVersion + * @memberof BI.CustomerCaptureRequest + * @instance + */ + CustomerCaptureRequest.prototype.extensionVersion = null; + + /** + * CustomerCaptureRequest aiAutofillStatus. + * @member {string|null|undefined} aiAutofillStatus + * @memberof BI.CustomerCaptureRequest + * @instance + */ + CustomerCaptureRequest.prototype.aiAutofillStatus = null; + + /** + * CustomerCaptureRequest mlLabels. + * @member {string|null|undefined} mlLabels + * @memberof BI.CustomerCaptureRequest + * @instance + */ + CustomerCaptureRequest.prototype.mlLabels = null; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + // Virtual OneOf for proto3 optional field + Object.defineProperty(CustomerCaptureRequest.prototype, "_extensionVersion", { + get: $util.oneOfGetter($oneOfFields = ["extensionVersion"]), + set: $util.oneOfSetter($oneOfFields) + }); + + // Virtual OneOf for proto3 optional field + Object.defineProperty(CustomerCaptureRequest.prototype, "_aiAutofillStatus", { + get: $util.oneOfGetter($oneOfFields = ["aiAutofillStatus"]), + set: $util.oneOfSetter($oneOfFields) + }); + + // Virtual OneOf for proto3 optional field + Object.defineProperty(CustomerCaptureRequest.prototype, "_mlLabels", { + get: $util.oneOfGetter($oneOfFields = ["mlLabels"]), + set: $util.oneOfSetter($oneOfFields) + }); + /** * Creates a new CustomerCaptureRequest instance using the specified properties. * @function create @@ -13875,6 +14252,12 @@ export const BI = $root.BI = (() => { writer.uint32(/* id 8, wireType 2 =*/66).string(message.issueType); if (message.notes != null && Object.hasOwnProperty.call(message, "notes")) writer.uint32(/* id 9, wireType 2 =*/74).string(message.notes); + if (message.extensionVersion != null && Object.hasOwnProperty.call(message, "extensionVersion")) + writer.uint32(/* id 10, wireType 2 =*/82).string(message.extensionVersion); + if (message.aiAutofillStatus != null && Object.hasOwnProperty.call(message, "aiAutofillStatus")) + writer.uint32(/* id 11, wireType 2 =*/90).string(message.aiAutofillStatus); + if (message.mlLabels != null && Object.hasOwnProperty.call(message, "mlLabels")) + writer.uint32(/* id 12, wireType 2 =*/98).string(message.mlLabels); return writer; }; @@ -13938,6 +14321,18 @@ export const BI = $root.BI = (() => { message.notes = reader.string(); break; } + case 10: { + message.extensionVersion = reader.string(); + break; + } + case 11: { + message.aiAutofillStatus = reader.string(); + break; + } + case 12: { + message.mlLabels = reader.string(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -13982,6 +14377,12 @@ export const BI = $root.BI = (() => { message.issueType = String(object.issueType); if (object.notes != null) message.notes = String(object.notes); + if (object.extensionVersion != null) + message.extensionVersion = String(object.extensionVersion); + if (object.aiAutofillStatus != null) + message.aiAutofillStatus = String(object.aiAutofillStatus); + if (object.mlLabels != null) + message.mlLabels = String(object.mlLabels); return message; }; @@ -14031,6 +14432,21 @@ export const BI = $root.BI = (() => { object.issueType = message.issueType; if (message.notes != null && Object.hasOwnProperty.call(message, "notes")) object.notes = message.notes; + if (message.extensionVersion != null && Object.hasOwnProperty.call(message, "extensionVersion")) { + object.extensionVersion = message.extensionVersion; + if (options.oneofs) + object._extensionVersion = "extensionVersion"; + } + if (message.aiAutofillStatus != null && Object.hasOwnProperty.call(message, "aiAutofillStatus")) { + object.aiAutofillStatus = message.aiAutofillStatus; + if (options.oneofs) + object._aiAutofillStatus = "aiAutofillStatus"; + } + if (message.mlLabels != null && Object.hasOwnProperty.call(message, "mlLabels")) { + object.mlLabels = message.mlLabels; + if (options.oneofs) + object._mlLabels = "mlLabels"; + } return object; }; diff --git a/keeperapi/src/proto/Enterprise.js b/keeperapi/src/proto/Enterprise.js index b0e27490..22d6b4ab 100644 --- a/keeperapi/src/proto/Enterprise.js +++ b/keeperapi/src/proto/Enterprise.js @@ -4209,6 +4209,7 @@ export const Enterprise = $root.Enterprise = (() => { * @property {number} MUST_HAVE_ONE_USER_ADMIN=7 MUST_HAVE_ONE_USER_ADMIN value * @property {number} INVALID_ROLE_ID=8 INVALID_ROLE_ID value * @property {number} PAM_LICENSE_SEAT_EXCEEDED=9 PAM_LICENSE_SEAT_EXCEEDED value + * @property {number} WOULD_LOCK_SELF=10 WOULD_LOCK_SELF value */ Enterprise.RoleUserModifyStatus = (function() { const valuesById = {}, values = Object.create(valuesById); @@ -4222,6 +4223,7 @@ export const Enterprise = $root.Enterprise = (() => { values[valuesById[7] = "MUST_HAVE_ONE_USER_ADMIN"] = 7; values[valuesById[8] = "INVALID_ROLE_ID"] = 8; values[valuesById[9] = "PAM_LICENSE_SEAT_EXCEEDED"] = 9; + values[valuesById[10] = "WOULD_LOCK_SELF"] = 10; return values; })(); @@ -4454,6 +4456,10 @@ export const Enterprise = $root.Enterprise = (() => { case 9: message.status = 9; break; + case "WOULD_LOCK_SELF": + case 10: + message.status = 10; + break; } if (object.message != null) message.message = String(object.message); @@ -5400,6 +5406,10 @@ export const Enterprise = $root.Enterprise = (() => { case 9: message.status = 9; break; + case "WOULD_LOCK_SELF": + case 10: + message.status = 10; + break; } if (object.message != null) message.message = String(object.message); @@ -26117,6 +26127,8 @@ export const Enterprise = $root.Enterprise = (() => { * @property {string|null} [fullName] UserUpdate fullName * @property {string|null} [jobTitle] UserUpdate jobTitle * @property {string|null} [email] UserUpdate email + * @property {string|null} [inviteeLocale] UserUpdate inviteeLocale + * @property {string|null} [encryptedDataString] UserUpdate encryptedDataString */ /** @@ -26190,6 +26202,22 @@ export const Enterprise = $root.Enterprise = (() => { */ UserUpdate.prototype.email = ""; + /** + * UserUpdate inviteeLocale. + * @member {string} inviteeLocale + * @memberof Enterprise.UserUpdate + * @instance + */ + UserUpdate.prototype.inviteeLocale = ""; + + /** + * UserUpdate encryptedDataString. + * @member {string} encryptedDataString + * @memberof Enterprise.UserUpdate + * @instance + */ + UserUpdate.prototype.encryptedDataString = ""; + /** * Creates a new UserUpdate instance using the specified properties. * @function create @@ -26232,6 +26260,10 @@ export const Enterprise = $root.Enterprise = (() => { writer.uint32(/* id 6, wireType 2 =*/50).string(message.jobTitle); if (message.email != null && Object.hasOwnProperty.call(message, "email")) writer.uint32(/* id 7, wireType 2 =*/58).string(message.email); + if (message.inviteeLocale != null && Object.hasOwnProperty.call(message, "inviteeLocale")) + writer.uint32(/* id 8, wireType 2 =*/66).string(message.inviteeLocale); + if (message.encryptedDataString != null && Object.hasOwnProperty.call(message, "encryptedDataString")) + writer.uint32(/* id 9, wireType 2 =*/74).string(message.encryptedDataString); return writer; }; @@ -26287,6 +26319,14 @@ export const Enterprise = $root.Enterprise = (() => { message.email = reader.string(); break; } + case 8: { + message.inviteeLocale = reader.string(); + break; + } + case 9: { + message.encryptedDataString = reader.string(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -26370,6 +26410,10 @@ export const Enterprise = $root.Enterprise = (() => { message.jobTitle = String(object.jobTitle); if (object.email != null) message.email = String(object.email); + if (object.inviteeLocale != null) + message.inviteeLocale = String(object.inviteeLocale); + if (object.encryptedDataString != null) + message.encryptedDataString = String(object.encryptedDataString); return message; }; @@ -26412,6 +26456,8 @@ export const Enterprise = $root.Enterprise = (() => { object.fullName = ""; object.jobTitle = ""; object.email = ""; + object.inviteeLocale = ""; + object.encryptedDataString = ""; } if (message.enterpriseUserId != null && Object.hasOwnProperty.call(message, "enterpriseUserId")) if (typeof BigInt !== "undefined" && options.longs === BigInt) @@ -26437,6 +26483,10 @@ export const Enterprise = $root.Enterprise = (() => { object.jobTitle = message.jobTitle; if (message.email != null && Object.hasOwnProperty.call(message, "email")) object.email = message.email; + if (message.inviteeLocale != null && Object.hasOwnProperty.call(message, "inviteeLocale")) + object.inviteeLocale = message.inviteeLocale; + if (message.encryptedDataString != null && Object.hasOwnProperty.call(message, "encryptedDataString")) + object.encryptedDataString = message.encryptedDataString; return object; }; @@ -26669,6 +26719,8 @@ export const Enterprise = $root.Enterprise = (() => { * @interface IUserUpdateResult * @property {number|null} [enterpriseUserId] UserUpdateResult enterpriseUserId * @property {Enterprise.UserUpdateStatus|null} [status] UserUpdateResult status + * @property {string|null} [errorMessage] UserUpdateResult errorMessage + * @property {string|null} [additionalInfo] UserUpdateResult additionalInfo */ /** @@ -26702,6 +26754,22 @@ export const Enterprise = $root.Enterprise = (() => { */ UserUpdateResult.prototype.status = 0; + /** + * UserUpdateResult errorMessage. + * @member {string} errorMessage + * @memberof Enterprise.UserUpdateResult + * @instance + */ + UserUpdateResult.prototype.errorMessage = ""; + + /** + * UserUpdateResult additionalInfo. + * @member {string} additionalInfo + * @memberof Enterprise.UserUpdateResult + * @instance + */ + UserUpdateResult.prototype.additionalInfo = ""; + /** * Creates a new UserUpdateResult instance using the specified properties. * @function create @@ -26734,6 +26802,10 @@ export const Enterprise = $root.Enterprise = (() => { writer.uint32(/* id 1, wireType 0 =*/8).int64(message.enterpriseUserId); if (message.status != null && Object.hasOwnProperty.call(message, "status")) writer.uint32(/* id 2, wireType 0 =*/16).int32(message.status); + if (message.errorMessage != null && Object.hasOwnProperty.call(message, "errorMessage")) + writer.uint32(/* id 3, wireType 2 =*/26).string(message.errorMessage); + if (message.additionalInfo != null && Object.hasOwnProperty.call(message, "additionalInfo")) + writer.uint32(/* id 4, wireType 2 =*/34).string(message.additionalInfo); return writer; }; @@ -26769,6 +26841,14 @@ export const Enterprise = $root.Enterprise = (() => { message.status = reader.int32(); break; } + case 3: { + message.errorMessage = reader.string(); + break; + } + case 4: { + message.additionalInfo = reader.string(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -26819,7 +26899,35 @@ export const Enterprise = $root.Enterprise = (() => { case 1: message.status = 1; break; + case "USER_UPDATE_EXCEEDED_LICENSE_SEATS": + case 2: + message.status = 2; + break; + case "USER_UPDATE_BAD_REQUEST": + case 3: + message.status = 3; + break; + case "USER_UPDATE_DUPLICATE": + case 4: + message.status = 4; + break; + case "USER_UPDATE_INVALID_STATE": + case 5: + message.status = 5; + break; + case "USER_UPDATE_FAILED": + case 6: + message.status = 6; + break; + case "USER_UPDATE_ERROR": + case 7: + message.status = 7; + break; } + if (object.errorMessage != null) + message.errorMessage = String(object.errorMessage); + if (object.additionalInfo != null) + message.additionalInfo = String(object.additionalInfo); return message; }; @@ -26847,6 +26955,8 @@ export const Enterprise = $root.Enterprise = (() => { } else object.enterpriseUserId = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; object.status = options.enums === String ? "USER_UPDATE_OK" : 0; + object.errorMessage = ""; + object.additionalInfo = ""; } if (message.enterpriseUserId != null && Object.hasOwnProperty.call(message, "enterpriseUserId")) if (typeof BigInt !== "undefined" && options.longs === BigInt) @@ -26857,6 +26967,10 @@ export const Enterprise = $root.Enterprise = (() => { object.enterpriseUserId = options.longs === String ? $util.Long.prototype.toString.call(message.enterpriseUserId) : options.longs === Number ? new $util.LongBits(message.enterpriseUserId.low >>> 0, message.enterpriseUserId.high >>> 0).toNumber() : message.enterpriseUserId; if (message.status != null && Object.hasOwnProperty.call(message, "status")) object.status = options.enums === String ? $root.Enterprise.UserUpdateStatus[message.status] === undefined ? message.status : $root.Enterprise.UserUpdateStatus[message.status] : message.status; + if (message.errorMessage != null && Object.hasOwnProperty.call(message, "errorMessage")) + object.errorMessage = message.errorMessage; + if (message.additionalInfo != null && Object.hasOwnProperty.call(message, "additionalInfo")) + object.additionalInfo = message.additionalInfo; return object; }; @@ -26895,11 +27009,23 @@ export const Enterprise = $root.Enterprise = (() => { * @enum {number} * @property {number} USER_UPDATE_OK=0 USER_UPDATE_OK value * @property {number} USER_UPDATE_ACCESS_DENIED=1 USER_UPDATE_ACCESS_DENIED value + * @property {number} USER_UPDATE_EXCEEDED_LICENSE_SEATS=2 USER_UPDATE_EXCEEDED_LICENSE_SEATS value + * @property {number} USER_UPDATE_BAD_REQUEST=3 USER_UPDATE_BAD_REQUEST value + * @property {number} USER_UPDATE_DUPLICATE=4 USER_UPDATE_DUPLICATE value + * @property {number} USER_UPDATE_INVALID_STATE=5 USER_UPDATE_INVALID_STATE value + * @property {number} USER_UPDATE_FAILED=6 USER_UPDATE_FAILED value + * @property {number} USER_UPDATE_ERROR=7 USER_UPDATE_ERROR value */ Enterprise.UserUpdateStatus = (function() { const valuesById = {}, values = Object.create(valuesById); values[valuesById[0] = "USER_UPDATE_OK"] = 0; values[valuesById[1] = "USER_UPDATE_ACCESS_DENIED"] = 1; + values[valuesById[2] = "USER_UPDATE_EXCEEDED_LICENSE_SEATS"] = 2; + values[valuesById[3] = "USER_UPDATE_BAD_REQUEST"] = 3; + values[valuesById[4] = "USER_UPDATE_DUPLICATE"] = 4; + values[valuesById[5] = "USER_UPDATE_INVALID_STATE"] = 5; + values[valuesById[6] = "USER_UPDATE_FAILED"] = 6; + values[valuesById[7] = "USER_UPDATE_ERROR"] = 7; return values; })(); diff --git a/keeperapi/src/proto/Folder.js b/keeperapi/src/proto/Folder.js index 0d9a6d4e..7ef67525 100644 --- a/keeperapi/src/proto/Folder.js +++ b/keeperapi/src/proto/Folder.js @@ -12340,6 +12340,8 @@ export const Folder = $root.Folder = (() => { * @property {Uint8Array|null} [encryptedRecordKey] The record key encrypted with the folder key or the user’s data key if the record is located in the Vault root. * @property {Folder.EncryptedKeyType|null} [encryptedRecordKeyType] Indicates the encryption scheme used to encrypt the record key. * @property {common.tla.ITLAProperties|null} [tlaProperties] time limited access settings define expiration, notification and rotation policies. + * @property {Uint8Array|null} [recordKeyEncryptedByOwnerKey] Record key encrypted with the owner's data key: an owner-decryptable copy independent of folder + * placement, so owner-trashed records can be decrypted and restored (KA-8946 / KA-8939). */ /** @@ -12389,6 +12391,15 @@ export const Folder = $root.Folder = (() => { */ RecordMetadata.prototype.tlaProperties = null; + /** + * Record key encrypted with the owner's data key: an owner-decryptable copy independent of folder + * placement, so owner-trashed records can be decrypted and restored (KA-8946 / KA-8939). + * @member {Uint8Array} recordKeyEncryptedByOwnerKey + * @memberof Folder.RecordMetadata + * @instance + */ + RecordMetadata.prototype.recordKeyEncryptedByOwnerKey = $util.newBuffer([]); + /** * Creates a new RecordMetadata instance using the specified properties. * @function create @@ -12425,6 +12436,8 @@ export const Folder = $root.Folder = (() => { writer.uint32(/* id 3, wireType 0 =*/24).int32(message.encryptedRecordKeyType); if (message.tlaProperties != null && Object.hasOwnProperty.call(message, "tlaProperties")) $root.common.tla.TLAProperties.encode(message.tlaProperties, writer.uint32(/* id 5, wireType 2 =*/42).fork(), q + 1).ldelim(); + if (message.recordKeyEncryptedByOwnerKey != null && Object.hasOwnProperty.call(message, "recordKeyEncryptedByOwnerKey")) + writer.uint32(/* id 6, wireType 2 =*/50).bytes(message.recordKeyEncryptedByOwnerKey); return writer; }; @@ -12468,6 +12481,10 @@ export const Folder = $root.Folder = (() => { message.tlaProperties = $root.common.tla.TLAProperties.decode(reader, reader.uint32(), undefined, long + 1); break; } + case 6: { + message.recordKeyEncryptedByOwnerKey = reader.bytes(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -12537,6 +12554,11 @@ export const Folder = $root.Folder = (() => { throw TypeError(".Folder.RecordMetadata.tlaProperties: object expected"); message.tlaProperties = $root.common.tla.TLAProperties.fromObject(object.tlaProperties, long + 1); } + if (object.recordKeyEncryptedByOwnerKey != null) + if (typeof object.recordKeyEncryptedByOwnerKey === "string") + $util.base64.decode(object.recordKeyEncryptedByOwnerKey, message.recordKeyEncryptedByOwnerKey = $util.newBuffer($util.base64.length(object.recordKeyEncryptedByOwnerKey)), 0); + else if (object.recordKeyEncryptedByOwnerKey.length >= 0) + message.recordKeyEncryptedByOwnerKey = object.recordKeyEncryptedByOwnerKey; return message; }; @@ -12574,6 +12596,13 @@ export const Folder = $root.Folder = (() => { } object.encryptedRecordKeyType = options.enums === String ? "no_key" : 0; object.tlaProperties = null; + if (options.bytes === String) + object.recordKeyEncryptedByOwnerKey = ""; + else { + object.recordKeyEncryptedByOwnerKey = []; + if (options.bytes !== Array) + object.recordKeyEncryptedByOwnerKey = $util.newBuffer(object.recordKeyEncryptedByOwnerKey); + } } if (message.recordUid != null && Object.hasOwnProperty.call(message, "recordUid")) object.recordUid = options.bytes === String ? $util.base64.encode(message.recordUid, 0, message.recordUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.recordUid) : message.recordUid; @@ -12583,6 +12612,8 @@ export const Folder = $root.Folder = (() => { object.encryptedRecordKeyType = options.enums === String ? $root.Folder.EncryptedKeyType[message.encryptedRecordKeyType] === undefined ? message.encryptedRecordKeyType : $root.Folder.EncryptedKeyType[message.encryptedRecordKeyType] : message.encryptedRecordKeyType; if (message.tlaProperties != null && Object.hasOwnProperty.call(message, "tlaProperties")) object.tlaProperties = $root.common.tla.TLAProperties.toObject(message.tlaProperties, options, q + 1); + if (message.recordKeyEncryptedByOwnerKey != null && Object.hasOwnProperty.call(message, "recordKeyEncryptedByOwnerKey")) + object.recordKeyEncryptedByOwnerKey = options.bytes === String ? $util.base64.encode(message.recordKeyEncryptedByOwnerKey, 0, message.recordKeyEncryptedByOwnerKey.length) : options.bytes === Array ? Array.prototype.slice.call(message.recordKeyEncryptedByOwnerKey) : message.recordKeyEncryptedByOwnerKey; return object; }; diff --git a/keeperapi/src/proto/PAM.js b/keeperapi/src/proto/PAM.js index bec67200..963640de 100644 --- a/keeperapi/src/proto/PAM.js +++ b/keeperapi/src/proto/PAM.js @@ -4964,6 +4964,8 @@ export const PAM = $root.PAM = (() => { * @property {Uint8Array|null} [applicationUid] PAMController applicationUid * @property {Enterprise.AppClientType|null} [appClientType] PAMController appClientType * @property {boolean|null} [isInitialized] PAMController isInitialized + * @property {number|null} [maxInstanceCount] PAMController maxInstanceCount + * @property {number|null} [lastSeen] PAMController lastSeen */ /** @@ -5061,6 +5063,22 @@ export const PAM = $root.PAM = (() => { */ PAMController.prototype.isInitialized = false; + /** + * PAMController maxInstanceCount. + * @member {number} maxInstanceCount + * @memberof PAM.PAMController + * @instance + */ + PAMController.prototype.maxInstanceCount = 0; + + /** + * PAMController lastSeen. + * @member {number} lastSeen + * @memberof PAM.PAMController + * @instance + */ + PAMController.prototype.lastSeen = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + /** * Creates a new PAMController instance using the specified properties. * @function create @@ -5109,6 +5127,10 @@ export const PAM = $root.PAM = (() => { writer.uint32(/* id 9, wireType 0 =*/72).int32(message.appClientType); if (message.isInitialized != null && Object.hasOwnProperty.call(message, "isInitialized")) writer.uint32(/* id 10, wireType 0 =*/80).bool(message.isInitialized); + if (message.maxInstanceCount != null && Object.hasOwnProperty.call(message, "maxInstanceCount")) + writer.uint32(/* id 11, wireType 0 =*/88).int32(message.maxInstanceCount); + if (message.lastSeen != null && Object.hasOwnProperty.call(message, "lastSeen")) + writer.uint32(/* id 12, wireType 0 =*/96).int64(message.lastSeen); return writer; }; @@ -5176,6 +5198,14 @@ export const PAM = $root.PAM = (() => { message.isInitialized = reader.bool(); break; } + case 11: { + message.maxInstanceCount = reader.int32(); + break; + } + case 12: { + message.lastSeen = reader.int64(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -5275,6 +5305,17 @@ export const PAM = $root.PAM = (() => { } if (object.isInitialized != null) message.isInitialized = Boolean(object.isInitialized); + if (object.maxInstanceCount != null) + message.maxInstanceCount = object.maxInstanceCount | 0; + if (object.lastSeen != null) + if ($util.Long) + message.lastSeen = $util.Long.fromValue(object.lastSeen, false); + else if (typeof object.lastSeen === "string") + message.lastSeen = parseInt(object.lastSeen, 10); + else if (typeof object.lastSeen === "number") + message.lastSeen = object.lastSeen; + else if (typeof object.lastSeen === "object") + message.lastSeen = new $util.LongBits(object.lastSeen.low >>> 0, object.lastSeen.high >>> 0).toNumber(); return message; }; @@ -5330,6 +5371,12 @@ export const PAM = $root.PAM = (() => { } object.appClientType = options.enums === String ? "NOT_USED" : 0; object.isInitialized = false; + object.maxInstanceCount = 0; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.lastSeen = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.lastSeen = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; } if (message.controllerUid != null && Object.hasOwnProperty.call(message, "controllerUid")) object.controllerUid = options.bytes === String ? $util.base64.encode(message.controllerUid, 0, message.controllerUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.controllerUid) : message.controllerUid; @@ -5366,6 +5413,15 @@ export const PAM = $root.PAM = (() => { object.appClientType = options.enums === String ? $root.Enterprise.AppClientType[message.appClientType] === undefined ? message.appClientType : $root.Enterprise.AppClientType[message.appClientType] : message.appClientType; if (message.isInitialized != null && Object.hasOwnProperty.call(message, "isInitialized")) object.isInitialized = message.isInitialized; + if (message.maxInstanceCount != null && Object.hasOwnProperty.call(message, "maxInstanceCount")) + object.maxInstanceCount = message.maxInstanceCount; + if (message.lastSeen != null && Object.hasOwnProperty.call(message, "lastSeen")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.lastSeen = typeof message.lastSeen === "number" ? BigInt(message.lastSeen) : $util.Long.fromBits(message.lastSeen.low >>> 0, message.lastSeen.high >>> 0, false).toBigInt(); + else if (typeof message.lastSeen === "number") + object.lastSeen = options.longs === String ? String(message.lastSeen) : message.lastSeen; + else + object.lastSeen = options.longs === String ? $util.Long.prototype.toString.call(message.lastSeen) : options.longs === Number ? new $util.LongBits(message.lastSeen.low >>> 0, message.lastSeen.high >>> 0).toNumber() : message.lastSeen; return object; }; diff --git a/keeperapi/src/proto/Records.js b/keeperapi/src/proto/Records.js index afed229c..f5293d6e 100644 --- a/keeperapi/src/proto/Records.js +++ b/keeperapi/src/proto/Records.js @@ -1702,6 +1702,7 @@ export const Records = $root.Records = (() => { * @property {Uint8Array|null} [folderUid] FolderRecordKey folderUid * @property {Uint8Array|null} [recordUid] FolderRecordKey recordUid * @property {Uint8Array|null} [recordKey] FolderRecordKey recordKey + * @property {Records.RecordKeyType|null} [recordKeyType] FolderRecordKey recordKeyType */ /** @@ -1743,6 +1744,14 @@ export const Records = $root.Records = (() => { */ FolderRecordKey.prototype.recordKey = $util.newBuffer([]); + /** + * FolderRecordKey recordKeyType. + * @member {Records.RecordKeyType} recordKeyType + * @memberof Records.FolderRecordKey + * @instance + */ + FolderRecordKey.prototype.recordKeyType = 0; + /** * Creates a new FolderRecordKey instance using the specified properties. * @function create @@ -1777,6 +1786,8 @@ export const Records = $root.Records = (() => { writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.recordUid); if (message.recordKey != null && Object.hasOwnProperty.call(message, "recordKey")) writer.uint32(/* id 3, wireType 2 =*/26).bytes(message.recordKey); + if (message.recordKeyType != null && Object.hasOwnProperty.call(message, "recordKeyType")) + writer.uint32(/* id 4, wireType 0 =*/32).int32(message.recordKeyType); return writer; }; @@ -1816,6 +1827,10 @@ export const Records = $root.Records = (() => { message.recordKey = reader.bytes(); break; } + case 4: { + message.recordKeyType = reader.int32(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -1857,6 +1872,42 @@ export const Records = $root.Records = (() => { $util.base64.decode(object.recordKey, message.recordKey = $util.newBuffer($util.base64.length(object.recordKey)), 0); else if (object.recordKey.length >= 0) message.recordKey = object.recordKey; + switch (object.recordKeyType) { + default: + if (typeof object.recordKeyType === "number") { + message.recordKeyType = object.recordKeyType; + break; + } + break; + case "NO_KEY": + case 0: + message.recordKeyType = 0; + break; + case "ENCRYPTED_BY_DATA_KEY": + case 1: + message.recordKeyType = 1; + break; + case "ENCRYPTED_BY_PUBLIC_KEY": + case 2: + message.recordKeyType = 2; + break; + case "ENCRYPTED_BY_DATA_KEY_GCM": + case 3: + message.recordKeyType = 3; + break; + case "ENCRYPTED_BY_PUBLIC_KEY_ECC": + case 4: + message.recordKeyType = 4; + break; + case "ENCRYPTED_BY_ROOT_KEY_CBC": + case 5: + message.recordKeyType = 5; + break; + case "ENCRYPTED_BY_ROOT_KEY_GCM": + case 6: + message.recordKeyType = 6; + break; + } return message; }; @@ -1899,6 +1950,7 @@ export const Records = $root.Records = (() => { if (options.bytes !== Array) object.recordKey = $util.newBuffer(object.recordKey); } + object.recordKeyType = options.enums === String ? "NO_KEY" : 0; } if (message.folderUid != null && Object.hasOwnProperty.call(message, "folderUid")) object.folderUid = options.bytes === String ? $util.base64.encode(message.folderUid, 0, message.folderUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.folderUid) : message.folderUid; @@ -1906,6 +1958,8 @@ export const Records = $root.Records = (() => { object.recordUid = options.bytes === String ? $util.base64.encode(message.recordUid, 0, message.recordUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.recordUid) : message.recordUid; if (message.recordKey != null && Object.hasOwnProperty.call(message, "recordKey")) object.recordKey = options.bytes === String ? $util.base64.encode(message.recordKey, 0, message.recordKey.length) : options.bytes === Array ? Array.prototype.slice.call(message.recordKey) : message.recordKey; + if (message.recordKeyType != null && Object.hasOwnProperty.call(message, "recordKeyType")) + object.recordKeyType = options.enums === String ? $root.Records.RecordKeyType[message.recordKeyType] === undefined ? message.recordKeyType : $root.Records.RecordKeyType[message.recordKeyType] : message.recordKeyType; return object; }; diff --git a/keeperapi/src/proto/Router.js b/keeperapi/src/proto/Router.js index cc89f289..9f432690 100644 --- a/keeperapi/src/proto/Router.js +++ b/keeperapi/src/proto/Router.js @@ -9315,6 +9315,7 @@ export const Router = $root.Router = (() => { * @property {Uint8Array|null} [transmissionKey] Router2FAValidateRequest transmissionKey * @property {Uint8Array|null} [sessionToken] Router2FAValidateRequest sessionToken * @property {string|null} [value] Router2FAValidateRequest value + * @property {Uint8Array|null} [challengeToken] Router2FAValidateRequest challengeToken */ /** @@ -9356,6 +9357,14 @@ export const Router = $root.Router = (() => { */ Router2FAValidateRequest.prototype.value = ""; + /** + * Router2FAValidateRequest challengeToken. + * @member {Uint8Array} challengeToken + * @memberof Router.Router2FAValidateRequest + * @instance + */ + Router2FAValidateRequest.prototype.challengeToken = $util.newBuffer([]); + /** * Creates a new Router2FAValidateRequest instance using the specified properties. * @function create @@ -9390,6 +9399,8 @@ export const Router = $root.Router = (() => { writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.sessionToken); if (message.value != null && Object.hasOwnProperty.call(message, "value")) writer.uint32(/* id 3, wireType 2 =*/26).string(message.value); + if (message.challengeToken != null && Object.hasOwnProperty.call(message, "challengeToken")) + writer.uint32(/* id 4, wireType 2 =*/34).bytes(message.challengeToken); return writer; }; @@ -9429,6 +9440,10 @@ export const Router = $root.Router = (() => { message.value = reader.string(); break; } + case 4: { + message.challengeToken = reader.bytes(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -9467,6 +9482,11 @@ export const Router = $root.Router = (() => { message.sessionToken = object.sessionToken; if (object.value != null) message.value = String(object.value); + if (object.challengeToken != null) + if (typeof object.challengeToken === "string") + $util.base64.decode(object.challengeToken, message.challengeToken = $util.newBuffer($util.base64.length(object.challengeToken)), 0); + else if (object.challengeToken.length >= 0) + message.challengeToken = object.challengeToken; return message; }; @@ -9503,6 +9523,13 @@ export const Router = $root.Router = (() => { object.sessionToken = $util.newBuffer(object.sessionToken); } object.value = ""; + if (options.bytes === String) + object.challengeToken = ""; + else { + object.challengeToken = []; + if (options.bytes !== Array) + object.challengeToken = $util.newBuffer(object.challengeToken); + } } if (message.transmissionKey != null && Object.hasOwnProperty.call(message, "transmissionKey")) object.transmissionKey = options.bytes === String ? $util.base64.encode(message.transmissionKey, 0, message.transmissionKey.length) : options.bytes === Array ? Array.prototype.slice.call(message.transmissionKey) : message.transmissionKey; @@ -9510,6 +9537,8 @@ export const Router = $root.Router = (() => { object.sessionToken = options.bytes === String ? $util.base64.encode(message.sessionToken, 0, message.sessionToken.length) : options.bytes === Array ? Array.prototype.slice.call(message.sessionToken) : message.sessionToken; if (message.value != null && Object.hasOwnProperty.call(message, "value")) object.value = message.value; + if (message.challengeToken != null && Object.hasOwnProperty.call(message, "challengeToken")) + object.challengeToken = options.bytes === String ? $util.base64.encode(message.challengeToken, 0, message.challengeToken.length) : options.bytes === Array ? Array.prototype.slice.call(message.challengeToken) : message.challengeToken; return object; }; @@ -10036,6 +10065,7 @@ export const Router = $root.Router = (() => { * @interface IRouter2FAGetWebAuthnChallengeResponse * @property {string|null} [challenge] Router2FAGetWebAuthnChallengeResponse challenge * @property {Array.|null} [capabilities] Router2FAGetWebAuthnChallengeResponse capabilities + * @property {Uint8Array|null} [challengeToken] Router2FAGetWebAuthnChallengeResponse challengeToken */ /** @@ -10070,6 +10100,14 @@ export const Router = $root.Router = (() => { */ Router2FAGetWebAuthnChallengeResponse.prototype.capabilities = $util.emptyArray; + /** + * Router2FAGetWebAuthnChallengeResponse challengeToken. + * @member {Uint8Array} challengeToken + * @memberof Router.Router2FAGetWebAuthnChallengeResponse + * @instance + */ + Router2FAGetWebAuthnChallengeResponse.prototype.challengeToken = $util.newBuffer([]); + /** * Creates a new Router2FAGetWebAuthnChallengeResponse instance using the specified properties. * @function create @@ -10103,6 +10141,8 @@ export const Router = $root.Router = (() => { if (message.capabilities != null && message.capabilities.length) for (let i = 0; i < message.capabilities.length; ++i) writer.uint32(/* id 2, wireType 2 =*/18).string(message.capabilities[i]); + if (message.challengeToken != null && Object.hasOwnProperty.call(message, "challengeToken")) + writer.uint32(/* id 3, wireType 2 =*/26).bytes(message.challengeToken); return writer; }; @@ -10140,6 +10180,10 @@ export const Router = $root.Router = (() => { message.capabilities.push(reader.string()); break; } + case 3: { + message.challengeToken = reader.bytes(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -10175,6 +10219,11 @@ export const Router = $root.Router = (() => { for (let i = 0; i < object.capabilities.length; ++i) message.capabilities[i] = String(object.capabilities[i]); } + if (object.challengeToken != null) + if (typeof object.challengeToken === "string") + $util.base64.decode(object.challengeToken, message.challengeToken = $util.newBuffer($util.base64.length(object.challengeToken)), 0); + else if (object.challengeToken.length >= 0) + message.challengeToken = object.challengeToken; return message; }; @@ -10197,8 +10246,16 @@ export const Router = $root.Router = (() => { let object = {}; if (options.arrays || options.defaults) object.capabilities = []; - if (options.defaults) + if (options.defaults) { object.challenge = ""; + if (options.bytes === String) + object.challengeToken = ""; + else { + object.challengeToken = []; + if (options.bytes !== Array) + object.challengeToken = $util.newBuffer(object.challengeToken); + } + } if (message.challenge != null && Object.hasOwnProperty.call(message, "challenge")) object.challenge = message.challenge; if (message.capabilities && message.capabilities.length) { @@ -10206,6 +10263,8 @@ export const Router = $root.Router = (() => { for (let j = 0; j < message.capabilities.length; ++j) object.capabilities[j] = message.capabilities[j]; } + if (message.challengeToken != null && Object.hasOwnProperty.call(message, "challengeToken")) + object.challengeToken = options.bytes === String ? $util.base64.encode(message.challengeToken, 0, message.challengeToken.length) : options.bytes === Array ? Array.prototype.slice.call(message.challengeToken) : message.challengeToken; return object; }; diff --git a/keeperapi/src/proto/Tokens.js b/keeperapi/src/proto/Tokens.js index 8df362bf..f042da1b 100644 --- a/keeperapi/src/proto/Tokens.js +++ b/keeperapi/src/proto/Tokens.js @@ -19169,6 +19169,183 @@ export const Tokens = $root.Tokens = (() => { return IPWhiteList; })(); + Tokens.MemcachedString = (function() { + + /** + * Properties of a MemcachedString. + * @memberof Tokens + * @interface IMemcachedString + * @property {string|null} [value] MemcachedString value + */ + + /** + * Constructs a new MemcachedString. + * @memberof Tokens + * @classdesc Represents a MemcachedString. + * @implements IMemcachedString + * @constructor + * @param {Tokens.IMemcachedString=} [properties] Properties to set + */ + function MemcachedString(properties) { + if (properties) + for (let keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null && keys[i] !== "__proto__") + this[keys[i]] = properties[keys[i]]; + } + + /** + * MemcachedString value. + * @member {string} value + * @memberof Tokens.MemcachedString + * @instance + */ + MemcachedString.prototype.value = ""; + + /** + * Creates a new MemcachedString instance using the specified properties. + * @function create + * @memberof Tokens.MemcachedString + * @static + * @param {Tokens.IMemcachedString=} [properties] Properties to set + * @returns {Tokens.MemcachedString} MemcachedString instance + */ + MemcachedString.create = function create(properties) { + return new MemcachedString(properties); + }; + + /** + * Encodes the specified MemcachedString message. Does not implicitly {@link Tokens.MemcachedString.verify|verify} messages. + * @function encode + * @memberof Tokens.MemcachedString + * @static + * @param {Tokens.IMemcachedString} message MemcachedString message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + MemcachedString.encode = function encode(message, writer, q) { + if (!writer) + writer = $Writer.create(); + if (q === undefined) + q = 0; + if (q > $util.recursionLimit) + throw Error("max depth exceeded"); + if (message.value != null && Object.hasOwnProperty.call(message, "value")) + writer.uint32(/* id 1, wireType 2 =*/10).string(message.value); + return writer; + }; + + /** + * Decodes a MemcachedString message from the specified reader or buffer. + * @function decode + * @memberof Tokens.MemcachedString + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Tokens.MemcachedString} MemcachedString + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + MemcachedString.decode = function decode(reader, length, error, long) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + if (long === undefined) + long = 0; + if (long > $Reader.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let end = length === undefined ? reader.len : reader.pos + length, message = new $root.Tokens.MemcachedString(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.value = reader.string(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a MemcachedString message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Tokens.MemcachedString + * @static + * @param {Object.} object Plain object + * @returns {Tokens.MemcachedString} MemcachedString + */ + MemcachedString.fromObject = function fromObject(object, long) { + if (object instanceof $root.Tokens.MemcachedString) + return object; + if (!$util.isObject(object)) + throw TypeError(".Tokens.MemcachedString: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Tokens.MemcachedString(); + if (object.value != null) + message.value = String(object.value); + return message; + }; + + /** + * Creates a plain object from a MemcachedString message. Also converts values to other types if specified. + * @function toObject + * @memberof Tokens.MemcachedString + * @static + * @param {Tokens.MemcachedString} message MemcachedString + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + MemcachedString.toObject = function toObject(message, options, q) { + if (!options) + options = {}; + if (q === undefined) + q = 0; + if (q > $util.recursionLimit) + throw Error("max depth exceeded"); + let object = {}; + if (options.defaults) + object.value = ""; + if (message.value != null && Object.hasOwnProperty.call(message, "value")) + object.value = message.value; + return object; + }; + + /** + * Converts this MemcachedString to JSON. + * @function toJSON + * @memberof Tokens.MemcachedString + * @instance + * @returns {Object.} JSON object + */ + MemcachedString.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for MemcachedString + * @function getTypeUrl + * @memberof Tokens.MemcachedString + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + MemcachedString.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Tokens.MemcachedString"; + }; + + return MemcachedString; + })(); + Tokens.IncrementalSecurityDataContToken = (function() { /** diff --git a/keeperapi/src/proto/record.js b/keeperapi/src/proto/record.js index 0fc3a988..5bc15d8b 100644 --- a/keeperapi/src/proto/record.js +++ b/keeperapi/src/proto/record.js @@ -2031,6 +2031,7 @@ export const record = $root.record = (() => { * @property {Records.IRecordAudit|null} [audit] RecordAdd audit * @property {Records.ISecurityData|null} [securityData] RecordAdd securityData * @property {Records.ISecurityScoreData|null} [securityScoreData] RecordAdd securityScoreData + * @property {Uint8Array|null} [recordKeyEncryptedByOwnerKey] RecordAdd recordKeyEncryptedByOwnerKey */ /** @@ -2146,6 +2147,14 @@ export const record = $root.record = (() => { */ RecordAdd.prototype.securityScoreData = null; + /** + * RecordAdd recordKeyEncryptedByOwnerKey. + * @member {Uint8Array} recordKeyEncryptedByOwnerKey + * @memberof record.v3.RecordAdd + * @instance + */ + RecordAdd.prototype.recordKeyEncryptedByOwnerKey = $util.newBuffer([]); + /** * Creates a new RecordAdd instance using the specified properties. * @function create @@ -2199,6 +2208,8 @@ export const record = $root.record = (() => { $root.Records.SecurityData.encode(message.securityData, writer.uint32(/* id 11, wireType 2 =*/90).fork(), q + 1).ldelim(); if (message.securityScoreData != null && Object.hasOwnProperty.call(message, "securityScoreData")) $root.Records.SecurityScoreData.encode(message.securityScoreData, writer.uint32(/* id 12, wireType 2 =*/98).fork(), q + 1).ldelim(); + if (message.recordKeyEncryptedByOwnerKey != null && Object.hasOwnProperty.call(message, "recordKeyEncryptedByOwnerKey")) + writer.uint32(/* id 13, wireType 2 =*/106).bytes(message.recordKeyEncryptedByOwnerKey); return writer; }; @@ -2276,6 +2287,10 @@ export const record = $root.record = (() => { message.securityScoreData = $root.Records.SecurityScoreData.decode(reader, reader.uint32(), undefined, long + 1); break; } + case 13: { + message.recordKeyEncryptedByOwnerKey = reader.bytes(); + break; + } default: reader.skipType(tag & 7, long); break; @@ -2409,6 +2424,11 @@ export const record = $root.record = (() => { throw TypeError(".record.v3.RecordAdd.securityScoreData: object expected"); message.securityScoreData = $root.Records.SecurityScoreData.fromObject(object.securityScoreData, long + 1); } + if (object.recordKeyEncryptedByOwnerKey != null) + if (typeof object.recordKeyEncryptedByOwnerKey === "string") + $util.base64.decode(object.recordKeyEncryptedByOwnerKey, message.recordKeyEncryptedByOwnerKey = $util.newBuffer($util.base64.length(object.recordKeyEncryptedByOwnerKey)), 0); + else if (object.recordKeyEncryptedByOwnerKey.length >= 0) + message.recordKeyEncryptedByOwnerKey = object.recordKeyEncryptedByOwnerKey; return message; }; @@ -2477,6 +2497,13 @@ export const record = $root.record = (() => { object.audit = null; object.securityData = null; object.securityScoreData = null; + if (options.bytes === String) + object.recordKeyEncryptedByOwnerKey = ""; + else { + object.recordKeyEncryptedByOwnerKey = []; + if (options.bytes !== Array) + object.recordKeyEncryptedByOwnerKey = $util.newBuffer(object.recordKeyEncryptedByOwnerKey); + } } if (message.recordUid != null && Object.hasOwnProperty.call(message, "recordUid")) object.recordUid = options.bytes === String ? $util.base64.encode(message.recordUid, 0, message.recordUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.recordUid) : message.recordUid; @@ -2510,6 +2537,8 @@ export const record = $root.record = (() => { object.securityData = $root.Records.SecurityData.toObject(message.securityData, options, q + 1); if (message.securityScoreData != null && Object.hasOwnProperty.call(message, "securityScoreData")) object.securityScoreData = $root.Records.SecurityScoreData.toObject(message.securityScoreData, options, q + 1); + if (message.recordKeyEncryptedByOwnerKey != null && Object.hasOwnProperty.call(message, "recordKeyEncryptedByOwnerKey")) + object.recordKeyEncryptedByOwnerKey = options.bytes === String ? $util.base64.encode(message.recordKeyEncryptedByOwnerKey, 0, message.recordKeyEncryptedByOwnerKey.length) : options.bytes === Array ? Array.prototype.slice.call(message.recordKeyEncryptedByOwnerKey) : message.recordKeyEncryptedByOwnerKey; return object; }; diff --git a/keeperapi/src/restMessageCodecs.ts b/keeperapi/src/restMessageCodecs.ts new file mode 100644 index 00000000..eed44152 --- /dev/null +++ b/keeperapi/src/restMessageCodecs.ts @@ -0,0 +1,44 @@ +import { Reader, Writer } from 'protobufjs' + +export type RecordUidsRequest = { + recordUids: Uint8Array[] +} + +/** Request shape for v3 record endpoints that send record UIDs on protobuf field 3. */ +export const recordUidsRequestCodec = { + create(properties?: RecordUidsRequest): RecordUidsRequest { + return { recordUids: properties?.recordUids ?? [] } + }, + encode(message: RecordUidsRequest, writer?: Writer): Writer { + if (!writer) writer = Writer.create() + for (const uid of message.recordUids) { + writer.uint32(26).bytes(uid) + } + return writer + }, +} + +/** Response shape used by v3 record detail endpoints: repeated field 1 + forbidden UIDs on field 2. */ +export function decodeRepeatedWithForbidden( + data: Uint8Array, + decodeItem: (reader: Reader, length: number) => T +): { items: T[]; forbiddenRecords: Uint8Array[] } { + const reader = Reader.create(data) + const items: T[] = [] + const forbiddenRecords: Uint8Array[] = [] + while (reader.pos < reader.len) { + const tag = reader.uint32() + switch (tag >>> 3) { + case 1: + items.push(decodeItem(reader, reader.uint32())) + break + case 2: + forbiddenRecords.push(reader.bytes() as Uint8Array) + break + default: + reader.skipType(tag & 7) + break + } + } + return { items, forbiddenRecords } +} diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index e8301747..aab0517c 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -1,6 +1,6 @@ // noinspection JSUnusedGlobalSymbols -import { Writer } from 'protobufjs' +import { Reader, Writer } from 'protobufjs' import { AccountSummary, Authentication, @@ -21,6 +21,7 @@ import { folder, Workflow, } from './proto' +import { decodeRepeatedWithForbidden, recordUidsRequestCodec, type RecordUidsRequest } from './restMessageCodecs' // generated protobuf has all properties optional and nullable, while this is not an issue for KeeperApp, this type fixes it export type NN = Required<{ [prop in keyof T]: NonNullable }> @@ -473,6 +474,65 @@ export const removeRecordMessage = ( folder.v3.remove.RemoveResponse ) +export const removeFolderMessage = ( + data: folder.v3.remove.IRemoveFolderRequest +): RestMessage => + createMessage( + data, + 'vault/folders/v3/remove_folder', + folder.v3.remove.RemoveFolderRequest, + folder.v3.remove.RemoveResponse + ) + +export interface IRecordDetailsDataRequest extends RecordUidsRequest { + clientTime?: number +} + +export interface IRecordDetailsDataResponse { + data: Records.IRecordData[] + forbiddenRecords: Uint8Array[] +} + +const RecordDetailsDataRequest = { + create(properties?: IRecordDetailsDataRequest): IRecordDetailsDataRequest { + return { + recordUids: properties?.recordUids ?? [], + clientTime: properties?.clientTime, + } + }, + encode(message: IRecordDetailsDataRequest, writer?: Writer): Writer { + if (!writer) writer = Writer.create() + if (message.clientTime != null) { + writer.uint32(8).int64(message.clientTime) + } + return recordUidsRequestCodec.encode(message, writer) + }, +} + +const RecordDetailsDataResponse = { + decode(data: Uint8Array): IRecordDetailsDataResponse { + const { items, forbiddenRecords } = decodeRepeatedWithForbidden(data, (reader, length) => + Records.RecordData.decode(reader, length) + ) + return { data: items, forbiddenRecords } + }, +} + +export const recordDetailsDataMessage = ( + data: IRecordDetailsDataRequest +): RestMessage => + createMessage(data, 'vault/records/v3/details/data', RecordDetailsDataRequest, RecordDetailsDataResponse) + +export const folderAddMessage = ( + data: Folder.IFolderAddRequest +): RestMessage => + createMessage(data, 'vault/folders/v3/add', Folder.FolderAddRequest, Folder.FolderAddResponse) + +export const folderUpdateMessage = ( + data: Folder.IFolderUpdateRequest +): RestMessage => + createMessage(data, 'vault/folders/v3/update', Folder.FolderUpdateRequest, Folder.FolderUpdateResponse) + export const recordsAddMessage = ( data: Records.IRecordsAddRequest ): RestMessage => @@ -1065,6 +1125,7 @@ export const keeperDriveRecordsUpdate = ( ): RestMessage => createMessage(data, 'vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) + export const getSharingAdminsMessage = ( data: Enterprise.IGetSharingAdminsRequest ): RestMessage => @@ -1074,35 +1135,6 @@ export const getSharingAdminsMessage = ( Enterprise.GetSharingAdminsRequest, Enterprise.GetSharingAdminsResponse ) -export const removeFolderMessage = ( - data: folder.v3.remove.IRemoveFolderRequest -): RestMessage => - createMessage( - data, - 'vault/folders/v3/remove_folder', - folder.v3.remove.RemoveFolderRequest, - folder.v3.remove.RemoveResponse - ) - -export const recordDetailsDataMessage = ( - data: record.v3.details.IRecordDataRequest -): RestMessage => - createMessage( - data, - 'vault/records/v3/details/data', - record.v3.details.RecordDataRequest, - record.v3.details.RecordDataResponse - ) - -export const folderAddMessage = ( - data: Folder.IFolderAddRequest -): RestMessage => - createMessage(data, 'vault/folders/v3/add', Folder.FolderAddRequest, Folder.FolderAddResponse) - -export const folderUpdateMessage = ( - data: Folder.IFolderUpdateRequest -): RestMessage => - createMessage(data, 'vault/folders/v3/update', Folder.FolderUpdateRequest, Folder.FolderUpdateResponse) export const getFolderAccessMessage = ( data: folder.v3.IGetFolderAccessRequest @@ -1118,16 +1150,78 @@ export const getRecordAccessMessage = ( record.v3.details.RecordAccessRequest, record.v3.details.RecordAccessResponse ) -export const folderAccessUpdateMessage = ( - data: Folder.IFolderAccessRequest -): RestMessage => - createMessage(data, 'vault/folders/v3/access_update', Folder.FolderAccessRequest, Folder.FolderAccessResponse) + +export type IRecordAccessDetailsRequest = RecordUidsRequest + +export type IRecordAccessEntry = { + data?: Folder.IRecordAccessData + accessorInfo?: { name?: string } +} + +export type IRecordAccessDetailsResponse = { + recordAccesses: IRecordAccessEntry[] + forbiddenRecords: Uint8Array[] +} + +function decodeRecordAccessEntry(reader: Reader, length: number): IRecordAccessEntry { + const end = reader.pos + length + const entry: IRecordAccessEntry = {} + while (reader.pos < end) { + const tag = reader.uint32() + switch (tag >>> 3) { + case 1: + entry.data = Folder.RecordAccessData.decode(reader, reader.uint32()) + break + case 2: { + const infoEnd = reader.pos + reader.uint32() + const accessorInfo: { name?: string } = {} + while (reader.pos < infoEnd) { + const infoTag = reader.uint32() + if ((infoTag >>> 3) === 1) accessorInfo.name = reader.string() + else reader.skipType(infoTag & 7) + } + entry.accessorInfo = accessorInfo + break + } + default: + reader.skipType(tag & 7) + } + } + return entry +} + +const RecordAccessDetailsResponse = { + decode(data: Uint8Array): IRecordAccessDetailsResponse { + const { items, forbiddenRecords } = decodeRepeatedWithForbidden(data, decodeRecordAccessEntry) + return { recordAccesses: items, forbiddenRecords } + }, +} + +export const recordAccessDetailsMessage = ( + data: IRecordAccessDetailsRequest +): RestMessage => + createMessage( + data, + 'vault/records/v3/details/access', + recordUidsRequestCodec, + RecordAccessDetailsResponse + ) export const recordsShareV3Message = ( data: record.v3.sharing.IRequest ): RestMessage => createMessage(data, 'vault/records/v3/share', record.v3.sharing.Request, record.v3.sharing.Response) +export const folderAccessUpdateMessage = ( + data: Folder.IFolderAccessRequest +): RestMessage => + createMessage( + data, + 'vault/folders/v3/access_update', + Folder.FolderAccessRequest, + Folder.FolderAccessResponse + ) + export const recordsTransferV3Message = ( data: Records.IRecordsOnwershipTransferRequest ): RestMessage => diff --git a/keeperapi/src/utils.ts b/keeperapi/src/utils.ts index e0b4e4a8..24140b98 100644 --- a/keeperapi/src/utils.ts +++ b/keeperapi/src/utils.ts @@ -11,12 +11,13 @@ import { Ciphersuite, HPKE_ECDH_KYBER, MlKemVariant, OPTIONAL_DATA_LENGTH } from import { logger } from './log' export const formatTimeDiff = (timeDiff: Date): string => { - const minutes = timeDiff.getMinutes() - const seconds = timeDiff.getSeconds().toString().padStart(2, '0') - const milliseconds = timeDiff.getMilliseconds() + const totalMs = timeDiff.getTime() + const minutes = Math.floor(totalMs / 60000) + const seconds = Math.floor((totalMs % 60000) / 1000) + const milliseconds = totalMs % 1000 return minutes > 0 - ? `${minutes.toString().padStart(2, '0')}:${seconds}.${milliseconds}` - : `${seconds}.${milliseconds}` + ? `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds}` + : `${seconds.toString().padStart(2, '0')}.${milliseconds}` } export function getKeeperUrl(host: KeeperHost, forPath: string) { diff --git a/keeperapi/src/vault.ts b/keeperapi/src/vault.ts index 9efb78f4..cc328b09 100644 --- a/keeperapi/src/vault.ts +++ b/keeperapi/src/vault.ts @@ -1157,7 +1157,54 @@ const processKdRemovedFolders = async ( } } -const processKdFolderKeys = async (storage: VaultStorage, folderKeys?: Folder.IFolderKey[] | null) => { +type KdParentKeyUnwrap = { + folderKey: Uint8Array + folderUid: string + parentUid: string +} + +async function parentFolderKeyReady(parentUid: string, storage: VaultStorage): Promise { + const bytes = await storage.getKeyBytes?.(parentUid) + if (bytes?.length) { + return true + } + if (storage.getObject) { + if (await storage.getObject(`${parentUid}_gcm`)) return true + if (await storage.getObject(`${parentUid}_cbc`)) return true + } + return false +} + +async function flushDeferredKdParentKeyUnwraps( + storage: VaultStorage, + deferred: KdParentKeyUnwrap[] +): Promise { + if (!deferred.length) return + + let index = 0 + while (index < deferred.length) { + const item = deferred[index]! + if (!(await parentFolderKeyReady(item.parentUid, storage))) { + index++ + continue + } + try { + await platform.unwrapKey(item.folderKey, item.folderUid, item.parentUid, 'gcm', 'aes', storage) + deferred.splice(index, 1) + } catch (e: any) { + logger.error( + `The folder key for ${item.folderUid} cannot be decrypted using parent ${item.parentUid} (${e.message})` + ) + index++ + } + } +} + +const processKdFolderKeys = async ( + storage: VaultStorage, + folderKeys?: Folder.IFolderKey[] | null, + deferredParentKeyUnwraps?: KdParentKeyUnwrap[] +) => { if (!folderKeys) return const encryptedByDataKeyMap: UnwrapKeyMap = {} const encryptedByDataKey = folderKeys.filter( @@ -1182,7 +1229,32 @@ const processKdFolderKeys = async (storage: VaultStorage, folderKeys?: Folder.IF if (!key.folderUid || !key.folderKey || !key.parentUid || isNil(key.encryptedBy)) continue const folderUid = webSafe64FromBytes(key.folderUid) const parentUid = webSafe64FromBytes(key.parentUid) - await platform.unwrapKey(key.folderKey, folderUid, parentUid, 'gcm', 'aes', storage) + const wrappedFolderKey = key.folderKey + const unwrap = async () => { + await platform.unwrapKey(wrappedFolderKey, folderUid, parentUid, 'gcm', 'aes', storage) + } + if (await parentFolderKeyReady(parentUid, storage)) { + try { + await unwrap() + } catch (e: any) { + logger.error( + `The folder key for ${folderUid} cannot be decrypted using parent ${parentUid} (${e.message})` + ) + deferredParentKeyUnwraps?.push({ folderKey: wrappedFolderKey, folderUid, parentUid }) + } + continue + } + if (deferredParentKeyUnwraps) { + deferredParentKeyUnwraps.push({ folderKey: wrappedFolderKey, folderUid, parentUid }) + continue + } + try { + await unwrap() + } catch (e: any) { + logger.error( + `The folder key for ${folderUid} cannot be decrypted using parent ${parentUid} (${e.message})` + ) + } } } @@ -1591,21 +1663,21 @@ export const syncDown = async (options: SyncDownOptions): Promise => pageCount: 0, } let networkTime = 0 + const deferredKdParentKeyUnwraps: KdParentKeyUnwrap[] = [] try { const storage = wrapObjWithProxy(options.storage, controller) const dToken = await storage.get('continuationToken') let continuationToken = dToken ? platform.base64ToBytes(dToken.token) : undefined - await platform.importKey('data', auth.dataKey!, undefined, true) + await platform.importKey('data', auth.dataKey!, storage, true) await platform.importKeyEC( 'pk_ecc', new Uint8Array(auth.eccPrivateKey!), new Uint8Array(auth.eccPublicKey!), - undefined, - true + storage ) - await platform.importKeyRSA('pk_rsa', auth.privateKey!, undefined, true) + await platform.importKeyRSA('pk_rsa', auth.privateKey!, storage) while (true) { const msg = syncDownMessage({ @@ -1681,7 +1753,7 @@ export const syncDown = async (options: SyncDownOptions): Promise => await processKdFolderAccesses(storage, keeperDriveData.folderAccesses) - await processKdFolderKeys(storage, keeperDriveData.folderKeys) + await processKdFolderKeys(storage, keeperDriveData.folderKeys, deferredKdParentKeyUnwraps) await processKdFolders(storage, keeperDriveData.folders) @@ -1775,6 +1847,8 @@ export const syncDown = async (options: SyncDownOptions): Promise => await storage.removeDependencies(removedDependencies) + await flushDeferredKdParentKeyUnwraps(storage, deferredKdParentKeyUnwraps) + continuationToken = resp.continuationToken || undefined const respContinuationToken = platform.bytesToBase64(continuationToken) result.continuationToken = respContinuationToken @@ -1788,6 +1862,8 @@ export const syncDown = async (options: SyncDownOptions): Promise => break } } + + await flushDeferredKdParentKeyUnwraps(storage, deferredKdParentKeyUnwraps) } catch (e: any) { logger.error(e) result.error = e.message