From be318c66e92e35fbbbdff399fed02f6927dabf96 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Tue, 16 Jun 2026 21:52:57 +0530 Subject: [PATCH 01/48] initial implementation of vault cli --- KeeperSdk/README.md | 54 ++++- KeeperSdk/package-lock.json | 65 ++--- KeeperSdk/package.json | 8 +- KeeperSdk/src/api.ts | 241 +++++++++++++++++++ KeeperSdk/src/auth/ConsoleAuthUI.ts | 34 +-- KeeperSdk/src/auth/ConsoleLogin.ts | 19 +- KeeperSdk/src/auth/SessionManager.ts | 99 ++------ KeeperSdk/src/auth/UnavailableAuthUI.ts | 23 ++ KeeperSdk/src/auth/config.ts | 42 ++++ KeeperSdk/src/auth/node/FileConfigLoader.ts | 36 +++ KeeperSdk/src/auth/sessionRestore.ts | 201 ++++++++++++++++ KeeperSdk/src/browser.ts | 6 + KeeperSdk/src/cli/access.ts | 30 +++ KeeperSdk/src/cli/builtinCommands.ts | 36 +++ KeeperSdk/src/cli/commandHelpers.ts | 24 ++ KeeperSdk/src/cli/commander/get.ts | 42 ++++ KeeperSdk/src/cli/commander/getCore.ts | 125 ++++++++++ KeeperSdk/src/cli/commander/index.ts | 4 + KeeperSdk/src/cli/commander/misc.ts | 187 ++++++++++++++ KeeperSdk/src/cli/commander/nav.ts | 234 ++++++++++++++++++ KeeperSdk/src/cli/commands/help.ts | 76 ++++++ KeeperSdk/src/cli/commands/login.ts | 178 ++++++++++++++ KeeperSdk/src/cli/commands/logout.ts | 39 +++ KeeperSdk/src/cli/commands/restoreSession.ts | 211 ++++++++++++++++ KeeperSdk/src/cli/commands/sync.ts | 61 +++++ KeeperSdk/src/cli/commands/vault.ts | 56 +++++ KeeperSdk/src/cli/dispatch.ts | 52 ++++ KeeperSdk/src/cli/help.ts | 82 +++++++ KeeperSdk/src/cli/index.ts | 109 +++++++++ KeeperSdk/src/cli/jsonArg.ts | 30 +++ KeeperSdk/src/cli/parse.ts | 173 +++++++++++++ KeeperSdk/src/cli/parser.ts | 172 +++++++++++++ KeeperSdk/src/cli/prompt.ts | 12 + KeeperSdk/src/cli/registry.ts | 55 +++++ KeeperSdk/src/cli/table.ts | 8 + KeeperSdk/src/cli/types.ts | 90 +++++++ KeeperSdk/src/cli/utils.ts | 18 ++ KeeperSdk/src/cli/vaultSurface.ts | 18 ++ KeeperSdk/src/folders/folderTree.ts | 45 +++- KeeperSdk/src/index.ts | 17 +- KeeperSdk/src/platform/browser/platform.ts | 76 ++++++ KeeperSdk/src/platform/index.ts | 20 ++ KeeperSdk/src/platform/node/platform.ts | 42 ++++ KeeperSdk/src/platform/types.ts | 25 ++ KeeperSdk/src/records/RecordUtils.ts | 97 +++++++- KeeperSdk/src/records/Totp.ts | 21 +- KeeperSdk/src/records/listRecordsTable.ts | 95 ++++++++ KeeperSdk/src/storage/InMemoryStorage.ts | 3 + KeeperSdk/src/utils/constants.ts | 5 + KeeperSdk/src/utils/index.ts | 2 + KeeperSdk/src/utils/patterns.ts | 8 +- KeeperSdk/src/vault/KeeperVault.ts | 113 ++++++++- KeeperSdk/tsconfig.json | 2 +- 53 files changed, 3324 insertions(+), 197 deletions(-) create mode 100644 KeeperSdk/src/api.ts create mode 100644 KeeperSdk/src/auth/UnavailableAuthUI.ts create mode 100644 KeeperSdk/src/auth/config.ts create mode 100644 KeeperSdk/src/auth/node/FileConfigLoader.ts create mode 100644 KeeperSdk/src/auth/sessionRestore.ts create mode 100644 KeeperSdk/src/browser.ts create mode 100644 KeeperSdk/src/cli/access.ts create mode 100644 KeeperSdk/src/cli/builtinCommands.ts create mode 100644 KeeperSdk/src/cli/commandHelpers.ts create mode 100644 KeeperSdk/src/cli/commander/get.ts create mode 100644 KeeperSdk/src/cli/commander/getCore.ts create mode 100644 KeeperSdk/src/cli/commander/index.ts create mode 100644 KeeperSdk/src/cli/commander/misc.ts create mode 100644 KeeperSdk/src/cli/commander/nav.ts create mode 100644 KeeperSdk/src/cli/commands/help.ts create mode 100644 KeeperSdk/src/cli/commands/login.ts create mode 100644 KeeperSdk/src/cli/commands/logout.ts create mode 100644 KeeperSdk/src/cli/commands/restoreSession.ts create mode 100644 KeeperSdk/src/cli/commands/sync.ts create mode 100644 KeeperSdk/src/cli/commands/vault.ts create mode 100644 KeeperSdk/src/cli/dispatch.ts create mode 100644 KeeperSdk/src/cli/help.ts create mode 100644 KeeperSdk/src/cli/index.ts create mode 100644 KeeperSdk/src/cli/jsonArg.ts create mode 100644 KeeperSdk/src/cli/parse.ts create mode 100644 KeeperSdk/src/cli/parser.ts create mode 100644 KeeperSdk/src/cli/prompt.ts create mode 100644 KeeperSdk/src/cli/registry.ts create mode 100644 KeeperSdk/src/cli/table.ts create mode 100644 KeeperSdk/src/cli/types.ts create mode 100644 KeeperSdk/src/cli/utils.ts create mode 100644 KeeperSdk/src/cli/vaultSurface.ts create mode 100644 KeeperSdk/src/platform/browser/platform.ts create mode 100644 KeeperSdk/src/platform/index.ts create mode 100644 KeeperSdk/src/platform/node/platform.ts create mode 100644 KeeperSdk/src/platform/types.ts create mode 100644 KeeperSdk/src/records/listRecordsTable.ts diff --git a/KeeperSdk/README.md b/KeeperSdk/README.md index 62fa48b8..4c2fc428 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -28,21 +28,57 @@ console.log(`Loaded ${vault.records.size} records`) ## Supported functionality -`KeeperVault` exposes the operations below. Enterprise features require an enterprise administrator account. +`KeeperVault` exposes vault operations. Enterprise features require an enterprise administrator account. - **Authentication**: Login, session token login, resume session, sync, logout - **Records**: List, search, add, update, delete, move, history - **Folders**: List, get, create, rename, delete, change directory, folder tree - **Shared folders**: List shared folders, share with users, update permissions - **Sharing**: Share and unshare records, check share info -- **Teams**: List, view, add, update, delete teams -- **Users**: List, view, add, update, delete users; lock/unlock accounts; expire passwords; manage aliases and team membership +- **Teams / users / roles** (enterprise admin): Available via the SDK API; not exposed as shell commands in this release -Enterprise features need an enterprise administrator account. +## Built-in shell CLI + +The package includes a Commander-style CLI (`dispatchCliLine`, `createKeeperCliParser`) for auth, records, and folders. + +**Before login:** `help`, `login`, `restore-session` + +**After login:** + +| Area | Commands | +|------|----------| +| Session | `logout`, `sync` (`syncdown`, `sync-down`, `d`), `whoami` | +| Records | `list` (`l`), `search` (`s`), `get` (`g`) | +| Folders | `ls`, `cd`, `tree`, `mkdir`, `list-sf` (`lsf`) | +| Vault info | `vault summary` | + +Every command supports `--help`. Record/folder write operations (`add`, `update`, `delete`, `share`, …) are SDK-only — see the examples below. + +### Finding records and folders + +| Goal | Command | +|------|---------| +| Record by UID (exact) | `get ` | +| Record by title | `get "Gmail Login"` or `search gmail` | +| Text in title/fields | `search ` (all terms must match) | +| Shared folder by UID | `get ` or `list-sf ` | +| Folder by path/UID | `get `, `ls`, `cd`, `tree` | +| All records (table) | `list` or `list --verbose` | +| Account summary | `whoami` or `vault summary` | + +`search` only covers **vault records** (title, fields, UID). It does not search teams or enterprise users — use the SDK API (`vault.viewTeam`, `vault.listTeams`, …) or `examples/sdk_example` scripts for those. + +```typescript +import { dispatchCliLine, type KeeperCliHost } from '@keeper-security/keeper-sdk-javascript' + +await dispatchCliLine('restore-session --from-json session.json', host) +await dispatchCliLine('list', host) +await dispatchCliLine('ls', host) +``` ## Examples -Runnable scripts for the areas above are in [`examples/sdk_example`](../examples/sdk_example): +Runnable SDK scripts are in [`examples/sdk_example`](../examples/sdk_example): ```bash cd examples/sdk_example @@ -51,8 +87,12 @@ npm run auth:login npm run records:list npm run folders:ls npm run shared-folders:list-sf -npm run teams:list -npm run users:list +``` + +Shell CLI parity (same dispatch path as the vault shell): + +```bash +npm run records:list:shell-cli -- --from-json /path/to/session.json ``` ## Local development diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 0eed8f14..071875e6 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,7 +9,8 @@ "version": "1.1.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "17.2.3", + "@keeper-security/keeperapi": "17.2.6", + "asmcrypto.js": "^2.3.2", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, @@ -56,9 +57,9 @@ } }, "node_modules/@keeper-security/keeperapi": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.2.3.tgz", - "integrity": "sha512-RT1ZnvfonFrPuLij8mPso/1eyTzurm2ikJxydzOA7oLQlqNRcH2FRramCan0sN85U5RNnM5QS05NVaJq4K72pg==", + "version": "17.2.6", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.2.6.tgz", + "integrity": "sha512-SEPP2rYioDFBJcCcjq/U2pUw5KBq6ymz85PTs/Na77Jwno7JXjabzKYApq+L1zMql0fW5UKYVUgTrSz2KImtbQ==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -136,18 +137,19 @@ "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==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", + "peer": true, "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "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==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -277,9 +279,9 @@ } }, "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==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -316,16 +318,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -417,9 +419,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -480,9 +482,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -569,6 +571,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -578,9 +581,9 @@ } }, "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==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -590,9 +593,9 @@ "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==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 7a1fe816..a0dcd9c4 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -8,10 +8,13 @@ "directory": "KeeperSdk" }, "license": "ISC", - "main": "src/index.ts", + "main": "dist/index.js", + "browser": "dist/browser.js", + "types": "dist/index.d.ts", "scripts": { "build": "tsc", - "clean": "rm -rf dist", + "clean": "rm -rf dist node_modules", + "rebuild": "npm run clean && npm install && npm run build", "link-local": "npm link ../keeperapi", "format": "prettier --write .", "format:check": "prettier --check .", @@ -23,6 +26,7 @@ "dependencies": { "@keeper-security/keeperapi": "17.2.6", "ts-node": "^10.7.0", + "asmcrypto.js": "^2.3.2", "typescript": "^4.6.3" }, "devDependencies": { diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts new file mode 100644 index 00000000..def13c4e --- /dev/null +++ b/KeeperSdk/src/api.ts @@ -0,0 +1,241 @@ +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, +} from './utils' +export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' + +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' +export type { + PasswordRecordData, + TypedRecordData, + RecordFieldInput, + NewRecordInput, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + HistoryEntry, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from './records/RecordOperations' + +export { shareRecord, removeRecordShare, getRecordShareInfo } from './sharing/Sharing' +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 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 { + dispatchCliLine, + dispatchKeeperCli, + ensureKeeperCliRegistry, + registerCliCommand, + registerCliAlias, + getCliCommand, + listCliCommands, + listCliCommandNames, + listCliCommandNamesForLoginState, + listCliCommandsForLoginState, + isAuthCliCommand, + listDocumentedCommands, + getDetailedHelpPage, + formatDetailedHelpForCommand, + tokenizeArguments, + parseCliArgs, + wantsCliHelp, + rejectUnknownOptions, + loginWithCredentials, + loginWithSessionToken, + runLoginCommand, + runLogoutCommand, + KeeperCliParser, + createKeeperCliParser, + getKeeperCliPromptPrefix, + BUILTIN_CLI_COMMANDS, + registerBuiltinCliCommands, + listCommand, +} from './cli' +export type { KeeperCliParserOptions } from './cli' +export type { + CliResult, + ParsedCli, + CliCommandDefinition, + CliHelpDoc, + KeeperCliHost, + KeeperCliVault, +} from './cli' + +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..e4f8cb89 100644 --- a/KeeperSdk/src/auth/ConsoleAuthUI.ts +++ b/KeeperSdk/src/auth/ConsoleAuthUI.ts @@ -1,7 +1,6 @@ -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 { getSdkPlatform } from '../platform' import { logger, extractErrorMessage, KeeperSdkError, AuthDefaults, ResultCodes } from '../utils' export class ConsoleAuthUI implements AuthUI3 { @@ -54,18 +53,19 @@ export class ConsoleAuthUI implements AuthUI3 { } private static async waitWithCancel(timeoutMs: number, cancel?: Promise): Promise { + const platform = getSdkPlatform() if (!cancel) { - await delay(timeoutMs) + await platform.delay(timeoutMs) return } - await Promise.race([delay(timeoutMs), cancel]) + await Promise.race([platform.delay(timeoutMs), cancel]) } - public async waitForDeviceApproval(channels: DeviceApprovalChannel[], isCloud: boolean): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + 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 ---') @@ -106,10 +106,10 @@ export class ConsoleAuthUI implements AuthUI3 { } public async waitForTwoFactorCode(channels: TwoFactorChannelData[], cancel: Promise): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + const rl = getSdkPlatform().createReadline( + typeof process !== 'undefined' ? process.stdin : undefined, + typeof process !== 'undefined' ? process.stdout : undefined + ) try { logger.info('\n--- Two-Factor Authentication Required ---') @@ -155,10 +155,10 @@ export class ConsoleAuthUI implements AuthUI3 { } public async getPassword(isAlternate: boolean): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + 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() diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts index 3d0907e3..93f30bba 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -1,6 +1,7 @@ -import readline from 'readline/promises' import type { AuthUI3 } from '@keeper-security/keeperapi' import { KeeperVault } from '../vault/KeeperVault' +import { getSdkPlatform } from '../platform' +import type { SdkReadline } from '../platform' import { logger, extractResultCode, @@ -12,8 +13,7 @@ import { KEEPER_PUBLIC_HOSTS, } from '../utils' import { ConsoleAuthUI } from './ConsoleAuthUI' -import { FileConfigLoader } from './SessionManager' -import type { KeeperJsonConfig } from './SessionManager' +import type { KeeperJsonConfig } from './config' const DEFAULT_REGION = 'US' const MASK_CHAR = '*' @@ -35,8 +35,6 @@ type ConsoleHandlers = { stderrWrite: typeof process.stderr.write } -const defaultConfigLoader = new FileConfigLoader() - let rlManager: ReadlineManager | null = null let suppressionDepth = 0 let originals: ConsoleHandlers | null = null @@ -69,14 +67,11 @@ function classifyInputChar(ch: string): CliCharAction { } class ReadlineManager { - private rl: readline.Interface | null = null + private rl: SdkReadline | null = null - private getOrCreate(): readline.Interface { + private getOrCreate(): SdkReadline { if (!this.rl) { - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + this.rl = getSdkPlatform().createReadline(process.stdin, process.stdout) } return this.rl } @@ -154,7 +149,7 @@ export function prompt(question: string, masked = false): Promise { export async function loadKeeperConfig(preloaded?: KeeperJsonConfig): Promise { if (preloaded) return preloaded - return defaultConfigLoader.load() + return getSdkPlatform().createFileConfigLoader().load() } export async function resolveServer(username?: string, preloadedConfig?: KeeperJsonConfig): Promise { diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts index ec3c7a9b..0370b3ce 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -1,6 +1,3 @@ -import fs from 'fs/promises' -import path from 'path' -import os from 'os' import { normal64Bytes, type DeviceConfig, @@ -8,37 +5,24 @@ import { type KeeperHost, type SessionParams, } from '@keeper-security/keeperapi' -import { logger, extractErrorMessage, SdkDefaults } from '../utils' +import { getSdkPlatform } from '../platform' +import { logger, extractErrorMessage } 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 -} +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 @@ -51,41 +35,6 @@ type DeviceCacheEntry = { 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, - }) - } -} - export class SessionManager implements SessionStorage { private readonly configLoader: ConfigLoader private sessionParams: Nullable = null @@ -98,8 +47,10 @@ export class SessionManager implements SessionStorage { constructor(configDir?: string) constructor(loader: ConfigLoader) constructor(configDirOrLoader?: string | ConfigLoader) { - if (typeof configDirOrLoader === 'string' || configDirOrLoader === undefined) { - this.configLoader = new FileConfigLoader(configDirOrLoader as string | undefined) + if (typeof configDirOrLoader === 'string') { + this.configLoader = getSdkPlatform().createFileConfigLoader(configDirOrLoader) + } else if (configDirOrLoader === undefined) { + this.configLoader = getSdkPlatform().createFileConfigLoader() } else { this.configLoader = configDirOrLoader } @@ -187,7 +138,7 @@ export class SessionManager implements SessionStorage { ) if (user?.last_device?.device_token) { const device = (parsed.devices || []).find( - (configDevice) => configDevice.device_token === user.last_device.device_token + (configDevice) => configDevice.device_token === user.last_device!.device_token ) if (device?.server_info) { const serverInfo = device.server_info.find((entry) => entry.server === host) @@ -284,10 +235,6 @@ export class SessionManager implements SessionStorage { } 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 + return isValidKeeperConfig(value) } } diff --git a/KeeperSdk/src/auth/UnavailableAuthUI.ts b/KeeperSdk/src/auth/UnavailableAuthUI.ts new file mode 100644 index 00000000..16278777 --- /dev/null +++ b/KeeperSdk/src/auth/UnavailableAuthUI.ts @@ -0,0 +1,23 @@ +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).' + +/** Placeholder when Node readline-based ConsoleAuthUI must not be used. */ +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..af3d2dd7 --- /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..61c4d08e --- /dev/null +++ b/KeeperSdk/src/auth/node/FileConfigLoader.ts @@ -0,0 +1,36 @@ +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' + +/** Node-only: read/write `~/.keeper/config.json`. */ +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..0e64a4e0 --- /dev/null +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -0,0 +1,201 @@ +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..463daba6 --- /dev/null +++ b/KeeperSdk/src/browser.ts @@ -0,0 +1,6 @@ +import { connectSdkPlatform } from './platform' +import { browserSdkPlatform } from './platform/browser/platform' + +connectSdkPlatform(browserSdkPlatform) + +export * from './api' diff --git a/KeeperSdk/src/cli/access.ts b/KeeperSdk/src/cli/access.ts new file mode 100644 index 00000000..027c0467 --- /dev/null +++ b/KeeperSdk/src/cli/access.ts @@ -0,0 +1,30 @@ +import type { CliCommandDefinition } from './types' +import { listCliCommands, resolveCliCommandName } from './registry' + +/** Commands available before a vault session exists. */ +export const AUTH_CLI_COMMAND_NAMES = new Set([ + 'help', + 'login', + 'restore-session', +]) + +export function isAuthCliCommand(name: string): boolean { + const resolved = resolveCliCommandName(name) + return resolved != null && AUTH_CLI_COMMAND_NAMES.has(resolved) +} + +export function filterCliCommandsForLoginState( + commands: readonly CliCommandDefinition[], + loggedIn: boolean +): CliCommandDefinition[] { + if (loggedIn) return [...commands] + return commands.filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) +} + +export function listCliCommandsForLoginState(loggedIn: boolean): CliCommandDefinition[] { + return filterCliCommandsForLoginState(listCliCommands(), loggedIn) +} + +export function listCliCommandNamesForLoginState(loggedIn: boolean): readonly string[] { + return listCliCommandsForLoginState(loggedIn).map((c) => c.name) +} diff --git a/KeeperSdk/src/cli/builtinCommands.ts b/KeeperSdk/src/cli/builtinCommands.ts new file mode 100644 index 00000000..1cd33332 --- /dev/null +++ b/KeeperSdk/src/cli/builtinCommands.ts @@ -0,0 +1,36 @@ +import type { CliCommandDefinition } from './types' +import { registerCliCommand } from './registry' +import { helpCommand } from './commands/help' +import { loginCommand } from './commands/login' +import { logoutCommand } from './commands/logout' +import { restoreSessionCommand } from './commands/restoreSession' +import { syncCommand } from './commands/sync' +import { vaultCommand } from './commands/vault' +import { getCommand } from './commander/get' +import { cdCommand, lsCommand, mkdirCommand, treeCommand } from './commander/nav' +import { listCommand, listSfCommand, searchCommand, whoamiCommand } from './commander/misc' + +/** Built-in CLI commands (Keeper Commander-style vault shell). */ +export const BUILTIN_CLI_COMMANDS: readonly CliCommandDefinition[] = [ + helpCommand, + loginCommand, + restoreSessionCommand, + syncCommand, + vaultCommand, + getCommand, + listCommand, + lsCommand, + cdCommand, + treeCommand, + mkdirCommand, + searchCommand, + listSfCommand, + whoamiCommand, + logoutCommand, +] + +export function registerBuiltinCliCommands(): void { + for (const def of BUILTIN_CLI_COMMANDS) { + registerCliCommand(def) + } +} diff --git a/KeeperSdk/src/cli/commandHelpers.ts b/KeeperSdk/src/cli/commandHelpers.ts new file mode 100644 index 00000000..e4fa6652 --- /dev/null +++ b/KeeperSdk/src/cli/commandHelpers.ts @@ -0,0 +1,24 @@ +import type { CliResult, KeeperCliHost, KeeperCliVault } from './types' +import { ensureLoggedIn } from './commands/login' + +export async function ensureSession(host: KeeperCliHost): Promise { + const v = host.getVault() + if (v.isLoggedIn) return null + const r = await ensureLoggedIn(host) + return r.code === 0 ? null : r +} + +export function ensureCapability( + v: KeeperCliVault, + name: K, + context: string +): CliResult | null { + if (typeof v[name] !== 'function') { + return { + code: 1, + out: '', + err: `${context}: this host does not expose KeeperCliVault.${String(name)}.\n`, + } + } + return null +} diff --git a/KeeperSdk/src/cli/commander/get.ts b/KeeperSdk/src/cli/commander/get.ts new file mode 100644 index 00000000..24fd82c1 --- /dev/null +++ b/KeeperSdk/src/cli/commander/get.ts @@ -0,0 +1,42 @@ +import type { CliCommandDefinition } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { executeGet } from './getCore' + +export const getCommand: CliCommandDefinition = { + name: 'get', + order: 10, + aliases: ['g'], + description: 'Get details of a record or folder by UID or title.', + usage: 'get [--format {detail,json,password,fields}] [--unmask]', + flagOptions: ['--format', '--unmask', '--detail', '--json'], + help: { + title: 'get — record/folder details (Keeper Commander)', + synopsis: 'usage: get [--unmask] [--format {detail,json,password,fields}] uid', + description: + ' Resolves a vault object by UID or title. Records support all output formats; folders and shared folders support detail/json.\n' + + ' Prefer get for exact UID lookup; search is for text in titles and fields.', + arguments: ' uid Record, folder, or shared-folder UID or title.', + options: ` --format {detail,json,password,fields} + detail (default): human-readable output. + json: JSON object. + password: password field only (records). + fields: JSON array of {name, value} (records). + --unmask Show sensitive field values (records). + --help, -h Show this help.`, + examples: ` get "Amazon" + get AbCdEf123456 --format json --unmask + get MyFolderUid --format json`, + seeAlso: ' ls, search, list', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(getCommand), err: '' } + } + try { + return await executeGet(host, parsed, 'get') + } catch (e) { + return { code: 1, out: '', err: host.formatError('get', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commander/getCore.ts b/KeeperSdk/src/cli/commander/getCore.ts new file mode 100644 index 00000000..6d98bde0 --- /dev/null +++ b/KeeperSdk/src/cli/commander/getCore.ts @@ -0,0 +1,125 @@ +import type { DRecord } from '@keeper-security/keeperapi' +import { + formatRecord, + formatRecordFields, + getRecordPassword, +} from '../../records/RecordUtils' +import type { CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt } from '../parse' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { GetFolderFormat } from '../../folders/getFolder' + +export type GetOutputFormat = 'detail' | 'json' | 'password' | 'fields' + +export function resolveGetFormat(parsed: ParsedCli): GetOutputFormat { + const raw = getOpt(parsed.opts, 'format')?.toLowerCase() + if (raw === 'json' || hasOpt(parsed.opts, 'json')) return 'json' + if (raw === 'password') return 'password' + if (raw === 'fields') return 'fields' + if (raw === 'detail') return 'detail' + return hasOpt(parsed.opts, 'detail') ? 'detail' : 'detail' +} + +export function resolveGetUnmask(parsed: ParsedCli): boolean { + return hasOpt(parsed.opts, 'unmask') +} + +export function getGetTarget(parsed: ParsedCli): string | undefined { + return parsed.positional[0]?.trim() || undefined +} + +async function outputRecord( + host: KeeperCliHost, + record: DRecord, + fmt: GetOutputFormat, + unmask: boolean, + cmd: string +): Promise { + if (fmt === 'password') { + const pw = getRecordPassword(record) + return { code: 0, out: pw ? `${pw}\n` : '', err: pw ? '' : `${cmd}: record has no password field\n` } + } + if (fmt === 'fields') { + return { code: 0, out: JSON.stringify(formatRecordFields(record, unmask), null, 2) + '\n', err: '' } + } + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(record, null, 2) + '\n', err: '' } + } + return { code: 0, out: formatRecord(record, { showDetails: true, unmask }) + '\n', err: '' } +} + +async function tryGetFolder( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + if (!v.getFolder) return null + try { + const res = await v.getFolder(target, { + format: fmt === 'json' ? GetFolderFormat.JSON : GetFolderFormat.Detail, + }) + if (fmt === 'json') { + const json = (res as { json?: Record }).json ?? res + return { code: 0, out: JSON.stringify(json, null, 2) + '\n', err: '' } + } + const name = 'name' in res ? res.name : target + const uid = + 'folder_uid' in res + ? res.folder_uid + : 'shared_folder_uid' in res + ? res.shared_folder_uid + : target + return { code: 0, out: `${name}\t${uid}\n`, err: '' } + } catch { + return null + } +} + +async function tryGetSharedFolderByUid( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + const hit = v.getSharedFolders().find((sf) => sf.uid === target) + if (!hit) return null + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(hit, null, 2) + '\n', err: '' } + } + if (fmt === 'password' || fmt === 'fields') { + return { code: 1, out: '', err: `${cmd}: --format ${fmt} applies to records only\n` } + } + return { code: 0, out: `${hit.name ?? '(unnamed)'}\t${hit.uid}\n`, err: '' } +} + +/** Commander-style `get` (record, folder, or shared folder by UID/title). */ +export async function executeGet(host: KeeperCliHost, parsed: ParsedCli, cmd = 'get'): Promise { + const target = getGetTarget(parsed) + if (!target) { + return { code: 1, out: '', err: `${cmd}: UID parameter is required\n` } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const fmt = resolveGetFormat(parsed) + const unmask = resolveGetUnmask(parsed) + + await v.sync() + + const sf = await tryGetSharedFolderByUid(host, target, fmt, cmd) + if (sf) return sf + + const folder = await tryGetFolder(host, target, fmt, cmd) + if (folder) return folder + + const cap = ensureCapability(v, 'findRecord', cmd) + if (cap) return cap + const record = v.findRecord!(target) + if (!record) { + return { code: 1, out: '', err: `${cmd}: cannot find any object matching "${target}"\n` } + } + return outputRecord(host, record, fmt, unmask, cmd) +} diff --git a/KeeperSdk/src/cli/commander/index.ts b/KeeperSdk/src/cli/commander/index.ts new file mode 100644 index 00000000..6795faac --- /dev/null +++ b/KeeperSdk/src/cli/commander/index.ts @@ -0,0 +1,4 @@ +export { getCommand } from './get' +export { executeGet } from './getCore' +export { lsCommand, cdCommand, treeCommand, mkdirCommand } from './nav' +export { listCommand, searchCommand, listSfCommand, whoamiCommand } from './misc' diff --git a/KeeperSdk/src/cli/commander/misc.ts b/KeeperSdk/src/cli/commander/misc.ts new file mode 100644 index 00000000..5e855aa5 --- /dev/null +++ b/KeeperSdk/src/cli/commander/misc.ts @@ -0,0 +1,187 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' +import { getRecordTitle } from '../../records/RecordUtils' +import { renderRecordsListTable } from '../../records/listRecordsTable' +import { recordUid } from '../utils' +import { formatSharedFoldersTable, renderSharedFoldersAsciiTable } from '../../sharedFolders/listSharedFolders' + +async function runList(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + await v.sync() + const records = v.getRecords() + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(records, null, 2) + '\n', err: '' } + } + if (records.length === 0) { + return { code: 0, out: '(no records)\n', err: '' } + } + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const out = renderRecordsListTable(records, { verbose }) + '\n' + return { code: 0, out, err: '' } +} + +async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { + const pattern = parsed.positional.join(' ') || getOpt(parsed.opts, 'pattern') + if (!pattern?.trim()) { + return { code: 1, out: '', err: 'search: missing search terms. Usage: search \n' } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'findRecords', 'search') + if (cap) return cap + await v.sync() + const matches = v.findRecords!(pattern) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(matches, null, 2) + '\n', err: '' } + } + if (matches.length === 0) { + return { code: 0, out: `(no records matched "${pattern}")\n`, err: '' } + } + const rows = matches.map((rec) => [recordUid(rec), getRecordTitle(rec)]) + return { code: 0, out: formatTable(['record_uid', 'title'], rows), err: '' } +} + +async function runListSf(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') + if (cap) return cap + await v.sync() + const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const rows = v.listSharedFolders!({ pattern, verbose, includeDetails: verbose }) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } + } + if (rows.length === 0) { + return { + code: 0, + out: pattern ? `(no shared folders matched "${pattern}")\n` : '(no shared folders)\n', + err: '', + } + } + const table = formatSharedFoldersTable(rows, { verbose }) + return { code: 0, out: renderSharedFoldersAsciiTable(table) + '\n', err: '' } +} + +async function runWhoami(host: KeeperCliHost): Promise { + const v = host.getVault() + if (!v.isLoggedIn) { + return { code: 1, out: '', err: 'whoami: not logged in\n' } + } + const username = + (await host.getAccountUsername?.()) ?? host.envString('KEEPER_USER') ?? host.envString('KEEPER_USERNAME') + const summary = v.getSummary?.() + const lines = [`username: ${username ?? '(unknown)'}`] + if (summary) { + lines.push( + `records: ${summary.recordCount}`, + `folders: ${summary.folderCount}`, + `shared_folders: ${summary.sharedFolderCount}` + ) + } + return { code: 0, out: lines.join('\n') + '\n', err: '' } +} + +export const listCommand: CliCommandDefinition = { + name: 'list', + order: 14, + aliases: ['l'], + description: 'List all vault records (Commander table).', + usage: 'list [--verbose|-v] [--json]', + flagOptions: ['--json', '--verbose', '-v'], + help: { + title: 'list — all records (Keeper Commander)', + synopsis: 'usage: list [--verbose]', + description: + ' Syncs and prints every record in a Commander-style table: uid, type, title, description, shared, and record category.', + options: ' --verbose, -v Do not truncate long columns (default max width 40).', + seeAlso: ' get, search, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listCommand), err: '' } + try { + return await runList(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list', e) } + } + }, +} + +export const searchCommand: CliCommandDefinition = { + name: 'search', + order: 15, + aliases: ['s'], + description: 'Search vault records by text.', + usage: 'search [--json]', + flagOptions: ['--json', '--pattern'], + help: { + title: 'search — find records (Keeper Commander)', + synopsis: 'usage: search ', + description: + ' Space-separated terms; all terms must match somewhere in the record (title, fields, or UID).\n' + + ' For exact lookup by UID, use get instead.', + examples: ' search amazon\n search bank account\n get zhJdqy7lb_zIEeCJT7GLlQ', + seeAlso: ' get, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(searchCommand), err: '' } + try { + return await runSearch(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('search', e) } + } + }, +} + +export const listSfCommand: CliCommandDefinition = { + name: 'list-sf', + order: 16, + aliases: ['lsf'], + description: 'List shared folders.', + usage: 'list-sf [pattern] [--verbose] [--json]', + flagOptions: ['--verbose', '-v', '--json', '--pattern'], + help: { + title: 'list-sf — shared folders (Keeper Commander)', + synopsis: 'usage: list-sf [pattern]', + seeAlso: ' ls, get', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listSfCommand), err: '' } + try { + return await runListSf(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list-sf', e) } + } + }, +} + +export const whoamiCommand: CliCommandDefinition = { + name: 'whoami', + order: 18, + description: 'Display current user and vault counts.', + usage: 'whoami', + help: { + title: 'whoami — current user (Keeper Commander)', + synopsis: 'usage: whoami', + seeAlso: ' login, sync-down', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(whoamiCommand), err: '' } + if (parsed.opts.size > 0 || parsed.positional.length > 0) { + return { code: 1, out: '', err: 'whoami: unexpected arguments\n' } + } + try { + return await runWhoami(host) + } catch (e) { + return { code: 1, out: '', err: host.formatError('whoami', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commander/nav.ts b/KeeperSdk/src/cli/commander/nav.ts new file mode 100644 index 00000000..0ff0e268 --- /dev/null +++ b/KeeperSdk/src/cli/commander/nav.ts @@ -0,0 +1,234 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' + +function lsPath(parsed: ParsedCli): string | undefined { + return parsed.positional[0] +} + +function formatLs( + result: { + detail: boolean + folders: Array<{ uid: string; name: string }> + records: Array<{ uid: string; name: string; type?: string }> + }, + detail: boolean +): string { + if (result.folders.length + result.records.length === 0) return '(empty)\n' + + const headers = detail ? ['flags', 'uid', 'name', 'type'] : ['kind', 'uid', 'name'] + const rows: string[][] = [] + for (const f of result.folders) { + const flags = ((f as { flags?: string }).flags ?? '').trim() + rows.push(detail ? [flags || 'f---', f.uid, f.name, ''] : ['dir', f.uid, f.name]) + } + for (const r of result.records) { + const flags = ((r as { flags?: string }).flags ?? '').trim() + const type = r.type ?? '' + rows.push(detail ? [flags || 'r---', r.uid, r.name, type] : ['rec', r.uid, r.name]) + } + return formatTable(headers, rows) +} + +async function runLs(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listFolder', cmd) + if (cap) return cap + await v.sync() + + const detail = hasOpt(parsed.opts, 'detail') || hasOpt(parsed.opts, 'list') || hasOpt(parsed.opts, 'l') + const foldersOnly = hasOpt(parsed.opts, 'folders') || hasOpt(parsed.opts, 'f') + const recordsOnly = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') + const target = lsPath(parsed) + + const listOpts = { + detail, + showFolders: recordsOnly ? false : true, + showRecords: foldersOnly ? false : true, + } + + if (!target) { + const result = await v.listFolder!({ ...listOpts }) + return { code: 0, out: formatLs(result, detail), err: '' } + } + + if (!v.changeDirectory || !v.getCurrentFolderUid) { + return { code: 1, out: '', err: `${cmd}: host lacks navigation capabilities.\n` } + } + + const originalUid = v.getCurrentFolderUid() + let resolvedUid: string | null + try { + const cd = await v.changeDirectory(target) + resolvedUid = cd.folderUid + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } + } + try { + const result = await v.listFolder!({ folderUid: resolvedUid ?? null, ...listOpts }) + return { code: 0, out: formatLs(result, detail), err: '' } + } finally { + if (resolvedUid !== originalUid) { + try { + await v.changeDirectory(originalUid ?? '/') + } catch { + /* best-effort */ + } + } + } +} + +async function runCd(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const target = parsed.positional[0] + if (!target) return { code: 1, out: '', err: `${cmd}: missing folder path. Usage: ${cmd} \n` } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'changeDirectory', cmd) + if (cap) return cap + try { + const res = await v.changeDirectory!(target) + return { code: 0, out: `${res.name}\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } + } +} + +async function runTree(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'tree', cmd) + if (cap) return cap + await v.sync() + const folderPath = parsed.positional[0] + const out = await v.tree!(folderPath ? { folderPath, showRecords: true } : { showRecords: true }) + return { code: 0, out: out.endsWith('\n') ? out : out + '\n', err: '' } +} + +async function runMkdir(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const target = parsed.positional[0] + if (!target) { + return { code: 1, out: '', err: `${cmd}: missing path. Usage: ${cmd} [-sf]\n` } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'mkdir', cmd) + if (cap) return cap + const cwd = v.getWorkingFolderDisplayName?.() ?? 'My Vault' + const shared = + hasOpt(parsed.opts, 'shared-folder') || + hasOpt(parsed.opts, 'sf') || + hasOpt(parsed.opts, 'shared') + try { + const res = await v.mkdir!(target, { sharedFolder: shared }) + if (!res.success) { + return { code: 1, out: '', err: `${cmd} [in ${cwd}]: ${res.message ?? 'failed'}\n` } + } + return { code: 0, out: `${res.folderUid}\t${target} (in ${cwd})\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target} [in ${cwd}]`, e) } + } +} + +const lsHelp: CliCommandDefinition['help'] = { + title: 'ls — list folder contents (Keeper Commander)', + synopsis: 'usage: ls [-l] [-f] [-r] [pattern]', + description: ' Lists records and subfolders in the current folder, or in PATH if given.', + options: ` -l, --list Detailed list (flags, types). + -f, --folders Folders only. + -r, --records Records only. + --help, -h Show this help.`, + examples: ' ls\n ls "Marketing"\n ls -l', + seeAlso: ' cd, tree, get', +} + +export const lsCommand: CliCommandDefinition = { + name: 'ls', + order: 11, + description: 'List folder contents (current folder or PATH).', + usage: 'ls [PATH] [-l|--list] [-f|--folders] [-r|--records]', + flagOptions: ['-l', '--list', '-f', '--folders', '-r', '--records', '--detail'], + help: lsHelp, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(lsCommand), err: '' } + try { + return await runLs(host, parsed, 'ls') + } catch (e) { + return { code: 1, out: '', err: host.formatError('ls', e) } + } + }, +} + +export const cdCommand: CliCommandDefinition = { + name: 'cd', + order: 12, + description: 'Change current folder.', + usage: 'cd ', + help: { + title: 'cd — change current folder (Keeper Commander)', + synopsis: 'usage: cd ', + description: ' PATH is a slash-separated folder name/UID sequence, or `/` for vault root.', + examples: ' cd Marketing\n cd ..\n cd /', + seeAlso: ' ls, tree', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(cdCommand), err: '' } + try { + return await runCd(host, parsed, 'cd') + } catch (e) { + return { code: 1, out: '', err: host.formatError('cd', e) } + } + }, +} + +export const treeCommand: CliCommandDefinition = { + name: 'tree', + order: 13, + description: 'Display the folder structure.', + usage: 'tree [PATH]', + help: { + title: 'tree — folder structure (Keeper Commander)', + synopsis: 'usage: tree [folder]', + description: + ' Renders an ASCII tree from PATH or the vault root. Each node is tagged [folder], [shared folder], or [record].', + seeAlso: ' ls, cd', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(treeCommand), err: '' } + try { + return await runTree(host, parsed, 'tree') + } catch (e) { + return { code: 1, out: '', err: host.formatError('tree', e) } + } + }, +} + +export const mkdirCommand: CliCommandDefinition = { + name: 'mkdir', + order: 14, + description: 'Create a folder.', + usage: 'mkdir [-sf|--shared-folder]', + flagOptions: ['-sf', '--shared-folder', '--shared'], + help: { + title: 'mkdir — create folder (Keeper Commander)', + synopsis: 'usage: mkdir [-sf]', + description: ' Creates a user folder under the current folder. -sf creates a shared folder.', + options: ' -sf, --shared-folder Create a shared folder.', + examples: ' mkdir Drafts\n mkdir TeamShare -sf', + seeAlso: ' cd, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(mkdirCommand), err: '' } + try { + return await runMkdir(host, parsed, 'mkdir') + } catch (e) { + return { code: 1, out: '', err: host.formatError('mkdir', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/help.ts b/KeeperSdk/src/cli/commands/help.ts new file mode 100644 index 00000000..17bae201 --- /dev/null +++ b/KeeperSdk/src/cli/commands/help.ts @@ -0,0 +1,76 @@ +import type { CliCommandDefinition, KeeperCliHost } from '../types' +import { wantsCliHelp } from '../parse' +import { + formatAllCommandsSummary, + formatDetailedHelpForCommand, + formatShortCommandSummary, + getDetailedHelpPageForRegistry, +} from '../help' +import { isAuthCliCommand, listCliCommandsForLoginState } from '../access' +import { getCliCommand } from '../registry' + +export const helpCommand: CliCommandDefinition = { + name: 'help', + order: 0, + description: 'Show all commands, or full docs for one command (same as COMMAND --help).', + usage: 'help [command] (see also: help --help)', + help: { + title: 'help — show commands or short syntax for one command', + synopsis: ' help [COMMAND]', + description: ` Without arguments, lists commands for the current session. + When not logged in, only sign-in commands are listed (login, restore-session, …). + After login, lists vault commands as well. + + With COMMAND, prints usage for that command (sign-in commands only when logged out).`, + options: ' None. This command does not take GNU-style flags.', + seeAlso: ' Each command’s --help output.', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(helpCommand), err: '' } + } + if (parsed.opts.size > 0) { + return { code: 1, out: '', err: 'help: unknown option (try `help --help`)\n' } + } + const loggedIn = host.getVault().isLoggedIn + const visible = listCliCommandsForLoginState(loggedIn) + const args = parsed.positional + if (args.length === 0) { + if (loggedIn) { + return { code: 0, out: formatAllCommandsSummary(visible), err: '' } + } + return { + code: 0, + out: formatAllCommandsSummary(visible, { + header: 'Not logged in — sign-in commands:\n\n', + footer: + '\nRun `login` or `restore-session` to open the vault.\n' + + 'After login, run `help` again for vault commands (get, ls, cd, …).\n', + }), + err: '', + } + } + if (args.length > 1) { + return { code: 1, out: '', err: 'Usage: help [command]\n' } + } + const name = args[0] + if (!loggedIn && !isAuthCliCommand(name)) { + return { + code: 1, + out: '', + err: + `help: "${name}" requires a logged-in session. ` + + 'Run `help` for sign-in commands (login, restore-session).\n', + } + } + const long = getDetailedHelpPageForRegistry(visible, name) + if (long) { + return { code: 0, out: long, err: '' } + } + const def = getCliCommand(name) + if (!def) { + return { code: 1, out: '', err: `help: unknown command: ${name}\n` } + } + return { code: 0, out: formatShortCommandSummary(def), err: '' } + }, +} diff --git a/KeeperSdk/src/cli/commands/login.ts b/KeeperSdk/src/cli/commands/login.ts new file mode 100644 index 00000000..b4e2a710 --- /dev/null +++ b/KeeperSdk/src/cli/commands/login.ts @@ -0,0 +1,178 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { utf8ToBase64Url } from '../utils' + +const LOGIN_ALLOWED = new Set([ + 'username', + 'user', + 'session-token', + 'token', + 'st', + 'session-token-plain', +]) + +export async function runLoginCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { + const opts = parsed?.opts ?? new Map() + if (parsed && wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(loginCommand), err: '' } + } + if (parsed) { + for (const secretFlag of ['password', 'pass', 'pwd'] as const) { + if (opts.has(secretFlag)) { + return { + code: 1, + out: '', + err: + 'login: do not pass --password on the command line (it is logged and visible). ' + + 'Use KEEPER_PASSWORD for automation, or run `login --username …` in the shell and enter the password when prompted (masked).\n', + } + } + } + const bad = rejectUnknownOptions(parsed, LOGIN_ALLOWED, 'login') + if (bad) return bad + } + + const username = getOpt(opts, 'username', 'user') ?? host.envString('KEEPER_USERNAME') + const passwordEnv = host.envString('KEEPER_PASSWORD') + const sessionRaw = getOpt(opts, 'session-token', 'token', 'st') ?? host.envString('KEEPER_SESSION_TOKEN') + const sessionPlain = parsed && hasOpt(opts, 'session-token-plain') + + if (parsed) { + const stPlainVal = opts.get('session-token-plain') + if (stPlainVal !== undefined && stPlainVal !== true) { + return { + code: 1, + out: '', + err: 'login: --session-token-plain is a boolean flag (no value)\n', + } + } + } + + if (!username) { + return { + code: 1, + out: '', + err: 'login: provide --username or KEEPER_USERNAME.\n', + } + } + + const sessionTrimmed = typeof sessionRaw === 'string' ? sessionRaw.trim() : '' + if (sessionTrimmed.length > 0) { + return loginWithSessionToken(host, username, sessionTrimmed, { plainToken: !!sessionPlain }) + } + + if (!passwordEnv) { + return { + code: 1, + needPassword: true, + loginUsername: username, + out: '', + err: '', + } + } + + return loginWithCredentials(host, username, passwordEnv) +} + +export async function loginWithCredentials( + host: KeeperCliHost, + username: string, + password: string +): Promise { + try { + const v = host.getVault() + if (v.isLoggedIn) { + await v.logout() + } + await v.login(username, password) + await v.sync() + return { code: 0, out: `keeper: logged in as ${username}.\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +export async function loginWithSessionToken( + host: KeeperCliHost, + username: string, + sessionToken: string, + options?: { plainToken?: boolean } +): Promise { + let token = sessionToken.trim() + if (options?.plainToken && token.length > 0) { + token = utf8ToBase64Url(token) + } + try { + const v = host.getVault() + if (v.isLoggedIn) { + await v.logout() + } + await v.loginWithSessionToken(username, token) + await v.sync() + return { code: 0, out: `keeper: logged in as ${username} (session token).\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +/** Pass-through if logged in; auto-login when `KEEPER_USERNAME` is set; otherwise "not logged in". */ +export async function ensureLoggedIn(host: KeeperCliHost): Promise { + if (host.getVault().isLoggedIn) { + return { code: 0, out: '', err: '' } + } + if (host.envString('KEEPER_USERNAME')) { + return runLoginCommand(host, { positional: [], opts: new Map() }) + } + return { code: 1, out: '', err: 'not logged in\n' } +} + +export const loginCommand: CliCommandDefinition = { + name: 'login', + order: 10, + description: + 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN). Password never on CLI line.', + usage: + 'login [--username|--user ] [--session-token|--token|--st ] [--session-token-plain] [--help|-h]', + flagOptions: [ + '--user', + '--username', + '--session-token', + '--token', + '--st', + '--session-token-plain', + ], + allowedOptions: LOGIN_ALLOWED, + help: { + title: 'login — authenticate to Keeper (vault session)', + synopsis: ` login [--username|--user EMAIL_OR_NAME] + login [--username|--user U] [--session-token|--token|--st TOKEN] + login [--username|--user U] [--session-token TOKEN] [--session-token-plain]`, + description: ` Establishes a Keeper session. + + Username comes from --username / --user or KEEPER_USERNAME. + + Password MUST NOT appear on the CLI line (logging, proxies, browser history). + Automation: set KEEPER_PASSWORD in the environment when embedding in Node. + Web shell: run login with only a username; the UI prompts for a masked password + and sends it through the login transport, not in "line". + + Session token login: pass the token on the command line or via + KEEPER_SESSION_TOKEN (sensitive — same caveats as any secret on argv). + + --session-token-plain treats the value as plain UTF-8 and encodes base64url + before login (same idea as the session_token_login example). + + Device registration: session token login requires deviceToken + privateKey for + this host in session storage (e.g. ~/.keeper/config.json) or a prior password + login in this shell.`, + options: ` --username, --user Account identifier (often email). + --session-token, --token, --st Session token string (or use KEEPER_SESSION_TOKEN). + --session-token-plain Treat --session-token value as plain UTF-8 and encode base64url.`, + environment: ` KEEPER_USERNAME Default username if not passed on the command line. + KEEPER_PASSWORD Password for non-interactive login (no session token). + KEEPER_SESSION_TOKEN Session token when not passed as a flag. + KEEPER_HOST Optional vault host / region (also: keeper-host attribute).`, + }, + run: (host, parsed) => runLoginCommand(host, parsed), +} diff --git a/KeeperSdk/src/cli/commands/logout.ts b/KeeperSdk/src/cli/commands/logout.ts new file mode 100644 index 00000000..aa70924a --- /dev/null +++ b/KeeperSdk/src/cli/commands/logout.ts @@ -0,0 +1,39 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' + +export async function runLogoutCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { + if (parsed && wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(logoutCommand), err: '' } + } + if (parsed && parsed.opts.size > 0) { + return { code: 1, out: '', err: 'logout: no options (try: logout --help)\n' } + } + if (parsed && parsed.positional.length > 0) { + return { code: 1, out: '', err: 'Usage: logout\n' } + } + try { + const v = host.getVault() + if (!v.isLoggedIn) { + return { code: 0, out: 'keeper: already logged out.\n', err: '' } + } + await v.logout() + return { code: 0, out: 'keeper: logged out.\n', err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +export const logoutCommand: CliCommandDefinition = { + name: 'logout', + order: 200, + description: 'Log out of the current Keeper session.', + usage: 'logout [--help|-h]', + help: { + title: 'logout — end the current Keeper session', + synopsis: ' logout', + description: ' Ends the current session if one exists.', + options: ' None.', + }, + run: (host, parsed) => runLogoutCommand(host, parsed), +} diff --git a/KeeperSdk/src/cli/commands/restoreSession.ts b/KeeperSdk/src/cli/commands/restoreSession.ts new file mode 100644 index 00000000..7e416d59 --- /dev/null +++ b/KeeperSdk/src/cli/commands/restoreSession.ts @@ -0,0 +1,211 @@ +import type { CliCommandDefinition, KeeperCliHost, ParsedCli } from '../types' +import { + resolveSessionRestorePayload, + validateSessionRestoreInput, + type SessionRestoreInput, +} from '../../auth/sessionRestore' +import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { runVaultSync } from './sync' + +/** Flags allowed to follow `--from-json ` on the same line. */ +export const RESTORE_SESSION_TRAILING_OPTS = [ + 'sync', + 'account-uid', + 'client-key', + 'data-key', + 'ecc-private-key', + 'ecc-public-key', + 'message-session-uid', + 'private-key', + 'session-token', + 'st', + 'session-token-type', + 'username', + 'user', + 'user-type', + 'sso-logout-url', + 'sso-session-id', + 'enterprise-public-key', + 'enterprise-ecc-public-key', +] as const + +const RESTORE_ALLOWED = new Set([ + 'sync', + 'from-json', + 'account-uid', + 'client-key', + 'data-key', + 'ecc-private-key', + 'ecc-public-key', + 'message-session-uid', + 'private-key', + 'session-token', + 'st', + 'session-token-type', + 'username', + 'user', + 'user-type', + 'sso-logout-url', + 'sso-session-id', + 'enterprise-public-key', + 'enterprise-ecc-public-key', +]) + +const ENV_PREFIX = 'RESTORE_SESSION_' + +const FIELD_ENV: Record = { + ACCOUNT_UID: 'accountUid', + CLIENT_KEY: 'clientKey', + DATA_KEY: 'dataKey', + ECC_PRIVATE_KEY: 'eccPrivateKey', + ECC_PUBLIC_KEY: 'eccPublicKey', + MESSAGE_SESSION_UID: 'messageSessionUid', + PRIVATE_KEY: 'privateKey', + SESSION_TOKEN: 'sessionToken', + SESSION_TOKEN_TYPE: 'sessionTokenType', + USERNAME: 'username', + USER_TYPE: 'userType', + SSO_LOGOUT_URL: 'ssoLogoutUrl', + SSO_SESSION_ID: 'ssoSessionId', + ENTERPRISE_PUBLIC_KEY: 'enterprisePublicKey', + ENTERPRISE_ECC_PUBLIC_KEY: 'enterpriseEccPublicKey', +} + +const OPT_TO_FIELD: Record = { + 'account-uid': 'accountUid', + 'client-key': 'clientKey', + 'data-key': 'dataKey', + 'ecc-private-key': 'eccPrivateKey', + 'ecc-public-key': 'eccPublicKey', + 'message-session-uid': 'messageSessionUid', + 'private-key': 'privateKey', + 'session-token': 'sessionToken', + st: 'sessionToken', + 'session-token-type': 'sessionTokenType', + username: 'username', + user: 'username', + 'user-type': 'userType', + 'sso-logout-url': 'ssoLogoutUrl', + 'sso-session-id': 'ssoSessionId', + 'enterprise-public-key': 'enterprisePublicKey', + 'enterprise-ecc-public-key': 'enterpriseEccPublicKey', +} + +function envField(host: KeeperCliHost, key: keyof SessionRestoreInput): string | undefined { + const envKey = Object.entries(FIELD_ENV).find(([, v]) => v === key)?.[0] + return envKey ? host.envString(`${ENV_PREFIX}${envKey}`) : undefined +} + +function buildInputFromFlags(host: KeeperCliHost, parsed: ParsedCli): SessionRestoreInput { + const partial: Partial = {} + + for (const [opt, field] of Object.entries(OPT_TO_FIELD)) { + const fromFlag = getOpt(parsed.opts, opt) + if (fromFlag !== undefined) { + ;(partial as Record)[field] = fromFlag + continue + } + const fromEnv = envField(host, field) + if (fromEnv !== undefined) { + ;(partial as Record)[field] = fromEnv + } + } + + return validateSessionRestoreInput(partial) +} + +export const restoreSessionCommand: CliCommandDefinition = { + name: 'restore-session', + order: 12, + description: + 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', + usage: + 'restore-session --from-json FILE|JSON [--sync] OR restore-session --session-token … (see --help)', + flagOptions: [ + '--sync', + '--from-json', + '--account-uid', + '--client-key', + '--data-key', + '--ecc-private-key', + '--ecc-public-key', + '--message-session-uid', + '--private-key', + '--session-token', + '--session-token-type', + '--username', + '--user-type', + '--sso-logout-url', + '--sso-session-id', + '--enterprise-public-key', + '--enterprise-ecc-public-key', + ], + allowedOptions: RESTORE_ALLOWED, + help: { + title: 'restore-session — restore SessionParams from extension / vault export', + synopsis: ` restore-session --from-json session.json + restore-session --session-token TOKEN --username U --account-uid B64 …`, + description: ` Loads a full SessionParams snapshot and resumes the session (same path as + the browser extension after login). Use this when you have accountUid, + clientKey, dataKey, keys, sessionToken, username, etc. from extension storage + — deviceToken/device private key are not part of this payload. + + Provide parameters either as one JSON object (--from-json) or as flags / env. + Binary fields are base64 or base64url.`, + options: ` --from-json Inline JSON (object or JSON-stringified object), or a file path + The entire remainder of the command line is passed to JSON.parse (then file read if needed). + --account-uid, --client-key, --data-key, --ecc-private-key, --ecc-public-key + --message-session-uid, --private-key + --session-token, --st Session token string (as stored; often base64url) + --session-token-type Numeric SessionTokenType enum + --username, --user + --user-type 0=normal, 1=cloud_sso, 2=onsite_sso (or string names) + --sso-logout-url, --sso-session-id + --enterprise-public-key, --enterprise-ecc-public-key (optional) + --sync Run syncDown after restoring the session`, + environment: ` RESTORE_SESSION_JSON Same as --from-json + RESTORE_SESSION_ACCOUNT_UID Per-field overrides (see --help flags) + RESTORE_SESSION_SESSION_TOKEN + … (RESTORE_SESSION_ for each field above)`, + note: ' sessionToken expires; region must match keeper-host / KEEPER_HOST.', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(restoreSessionCommand), err: '' } + } + const bad = rejectUnknownOptions(parsed, RESTORE_ALLOWED, 'restore-session') + if (bad) return bad + if (parsed.positional.length > 0) { + return { code: 1, out: '', err: 'restore-session: unexpected positional arguments\n' } + } + + try { + let input: SessionRestoreInput + const jsonRaw = getOpt(parsed.opts, 'from-json') ?? host.envString('RESTORE_SESSION_JSON') + if (jsonRaw) { + const readFile = + host.readTextFile ?? + (typeof document === 'undefined' + ? async (path: string) => (await import('fs/promises')).readFile(path, 'utf8') + : undefined) + input = await resolveSessionRestorePayload(jsonRaw, readFile) + } else { + input = buildInputFromFlags(host, parsed) + } + + await host.getVault().restoreSession(input) + let out = `keeper: session restored for ${input.username}.\n` + if (hasOpt(parsed.opts, 'sync')) { + const syncResult = await runVaultSync(host) + if (syncResult.code !== 0) { + return syncResult + } + out += syncResult.out + } + return { code: 0, out, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('restore-session', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/sync.ts b/KeeperSdk/src/cli/commands/sync.ts new file mode 100644 index 00000000..72d71f67 --- /dev/null +++ b/KeeperSdk/src/cli/commands/sync.ts @@ -0,0 +1,61 @@ +import type { SyncResult } from '@keeper-security/keeperapi' +import type { CliCommandDefinition, CliResult, KeeperCliHost } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureLoggedIn } from './login' + +function formatSyncSummary(result: SyncResult): string { + const lines = [`keeper: sync complete for ${result.username}.`, ` pages: ${result.pageCount}`] + if (result.totalTime) lines.push(` total: ${result.totalTime}`) + if (result.networkTime) lines.push(` network: ${result.networkTime}`) + const counts = result.counts ?? {} + const parts = Object.entries(counts) + .filter(([, n]) => typeof n === 'number' && n > 0) + .map(([k, n]) => `${k}=${n}`) + if (parts.length) lines.push(` counts: ${parts.join(', ')}`) + if (result.error) lines.push(` warning: ${result.error}`) + return lines.join('\n') + '\n' +} + +/** Download vault data via keeperapi syncDown (KeeperVault.sync). */ +export async function runVaultSync(host: KeeperCliHost): Promise { + const v = host.getVault() + if (!v.isLoggedIn) { + const login = await ensureLoggedIn(host) + if (login.code !== 0) return login + } + const result = await v.sync() + return { code: 0, out: formatSyncSummary(result), err: '' } +} + +export const syncCommand: CliCommandDefinition = { + name: 'sync', + order: 20, + aliases: ['syncdown', 'sync-down', 'd'], + description: 'Download / refresh vault data from Keeper (syncDown).', + usage: 'sync [--help|-h]', + help: { + title: 'sync — download vault data (syncDown)', + synopsis: ' sync', + description: ` Pulls records, folders, and related vault data into local storage. + Requires an active session (login or restore-session).`, + options: ' --help, -h Show this help.', + seeAlso: ' restore-session --sync, list, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(syncCommand), err: '' } + } + if (parsed.opts.size > 0) { + return { code: 1, out: '', err: 'sync: unknown option (try: sync --help)\n' } + } + if (parsed.positional.length > 0) { + return { code: 1, out: '', err: 'sync: unexpected arguments (try: sync --help)\n' } + } + try { + return await runVaultSync(host) + } catch (e) { + return { code: 1, out: '', err: host.formatError('sync', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/vault.ts b/KeeperSdk/src/cli/commands/vault.ts new file mode 100644 index 00000000..4bdaf979 --- /dev/null +++ b/KeeperSdk/src/cli/commands/vault.ts @@ -0,0 +1,56 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' + +async function runSummary(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'getSummary', 'vault summary') + if (cap) return cap + await v.sync!() + const summary = v.getSummary!() + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(summary, null, 2) + '\n', err: '' } + } + const lines = [ + `records: ${summary.recordCount}`, + `shared_folders: ${summary.sharedFolderCount}`, + `teams: ${summary.teamCount}`, + `folders: ${summary.folderCount}`, + ] + return { code: 0, out: lines.join('\n') + '\n', err: '' } +} + +export const vaultCommand: CliCommandDefinition = { + name: 'vault', + order: 25, + description: 'Vault summary counts (records, folders, shared folders).', + usage: 'vault summary [--json] [--help|-h]', + subcommands: ['summary'], + flagOptions: ['--json'], + help: { + title: 'vault — vault-wide statistics', + synopsis: ' vault summary [--json]', + description: ' Runs sync, then prints counts from the local vault cache.', + arguments: ' summary Print record, shared folder, and user-folder counts.', + options: ' --json Emit JSON.\n --help, -h Show this help.', + examples: ' vault summary\n vault summary --json', + seeAlso: ' sync, list, tree, whoami', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(vaultCommand), err: '' } + } + const sub = parsed.positional[0]?.toLowerCase() ?? 'summary' + if (sub !== 'summary') { + return { code: 1, out: '', err: 'Usage: vault summary\n' } + } + try { + return await runSummary(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('vault summary', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/dispatch.ts b/KeeperSdk/src/cli/dispatch.ts new file mode 100644 index 00000000..ba49e697 --- /dev/null +++ b/KeeperSdk/src/cli/dispatch.ts @@ -0,0 +1,52 @@ +import type { CliResult, KeeperCliHost, ParsedCli } from './types' +import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' +import { extractFromJsonFlagValue } from './jsonArg' +import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' +import { formatDetailedHelpForCommand } from './help' +import { isAuthCliCommand } from './access' +import { getCliCommand } from './registry' + +const NOT_LOGGED_IN_ERR = + 'Not logged in. Run `login` or `restore-session` (see `help`).\n' + +export async function dispatchKeeperCli( + commandName: string, + args: string[], + host: KeeperCliHost, + preParsed?: ParsedCli +): Promise { + const def = getCliCommand(commandName) + if (!def) { + return { code: 1, out: '', err: `Unknown command: ${commandName}\n` } + } + if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { + return { code: 1, out: '', err: NOT_LOGGED_IN_ERR } + } + const parsed = preParsed ?? parseCliArgs(args) + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(def), err: '' } + } + return def.run(host, parsed) +} + +export async function dispatchCliLine(line: string, host: KeeperCliHost): Promise { + const trimmed = line.trim() + if (!trimmed) { + return { code: 0, out: '', err: '' } + } + const tokens = tokenizeArguments(trimmed) + const name = tokens[0]?.toLowerCase() + if (!name) { + return { code: 0, out: '', err: '' } + } + const args = tokens.slice(1) + let preParsed: ParsedCli | undefined + if (name === 'restore-session') { + const json = extractFromJsonFlagValue(trimmed, 'from-json', RESTORE_SESSION_TRAILING_OPTS) + if (json) { + preParsed = parseCliArgs(args) + preParsed.opts.set('from-json', json) + } + } + return dispatchKeeperCli(name, args, host, preParsed) +} diff --git a/KeeperSdk/src/cli/help.ts b/KeeperSdk/src/cli/help.ts new file mode 100644 index 00000000..40773a4f --- /dev/null +++ b/KeeperSdk/src/cli/help.ts @@ -0,0 +1,82 @@ +import type { CliCommandDefinition, CliHelpDoc } from './types' + +const SECTION_ORDER: (keyof CliHelpDoc)[] = [ + 'synopsis', + 'description', + 'arguments', + 'options', + 'environment', + 'examples', + 'seeAlso', + 'note', +] + +const SECTION_LABELS: Partial> = { + synopsis: 'SYNOPSIS', + description: 'DESCRIPTION', + arguments: 'ARGUMENTS', + options: 'OPTIONS', + environment: 'ENVIRONMENT', + examples: 'EXAMPLES', + seeAlso: 'SEE ALSO', + note: 'NOTE', +} + +export function formatDetailedHelp(doc: CliHelpDoc): string { + const parts: string[] = [doc.title.trim()] + for (const key of SECTION_ORDER) { + const body = doc[key] + if (typeof body !== 'string' || !body.trim()) continue + const label = SECTION_LABELS[key] + if (label) { + parts.push('') + parts.push(label) + } + parts.push(body.trim()) + } + return `${parts.join('\n')}\n` +} + +export function formatDetailedHelpForCommand(def: CliCommandDefinition): string { + return formatDetailedHelp(def.help) +} + +export function getDetailedHelpPageForRegistry( + commands: Iterable, + name: string +): string | null { + const key = name.toLowerCase() + for (const def of commands) { + if (def.name === key) return formatDetailedHelpForCommand(def) + if (def.aliases?.some((a) => a.toLowerCase() === key)) { + return formatDetailedHelpForCommand(def) + } + } + return null +} + +export type CommandsSummaryOptions = { + header?: string + footer?: string +} + +export function formatAllCommandsSummary( + commands: readonly CliCommandDefinition[], + options?: CommandsSummaryOptions +): string { + const sorted = [...commands].sort((a, b) => a.name.localeCompare(b.name)) + const w = Math.max(...sorted.map((c) => c.name.length), 8) + let out = options?.header ?? 'Supported commands:\n\n' + if (!out.endsWith('\n\n')) { + out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n` + } + for (const c of sorted) { + out += ` ${c.name.padEnd(w)} ${c.description}\n` + } + out += options?.footer ?? '\nRun ` --help` (or `-h`) for details on a specific command.\n' + return out +} + +export function formatShortCommandSummary(def: CliCommandDefinition): string { + return `${def.name} — ${def.description}\n Usage: ${def.usage}\n` +} diff --git a/KeeperSdk/src/cli/index.ts b/KeeperSdk/src/cli/index.ts new file mode 100644 index 00000000..a3c67c51 --- /dev/null +++ b/KeeperSdk/src/cli/index.ts @@ -0,0 +1,109 @@ +import { registerBuiltinCliCommands } from './builtinCommands' +import { registerCliAlias } from './registry' + +let registryInitialized = false + +/** Register built-in Keeper CLI commands (idempotent). */ +export function ensureKeeperCliRegistry(): void { + if (registryInitialized) return + registryInitialized = true + registerBuiltinCliCommands() + registerCliAlias('?', 'help') +} + +ensureKeeperCliRegistry() + +export type { + CliResult, + ParsedCli, + CliHelpDoc, + CliCommandDefinition, + KeeperCliHost, + KeeperCliVault, +} from './types' + +export { + tokenizeArguments, + parseCliArgs, + hasOpt, + getOpt, + wantsCliHelp, + rejectUnknownOptions, +} from './parse' + +export { + formatDetailedHelp, + formatDetailedHelpForCommand, + formatAllCommandsSummary, + formatShortCommandSummary, +} from './help' +import { getDetailedHelpPageForRegistry } from './help' +import { listCliCommands } from './registry' + +export function getDetailedHelpPage(name: string): string | null { + ensureKeeperCliRegistry() + return getDetailedHelpPageForRegistry(listCliCommands(), name) +} + +export { + registerCliCommand, + registerCliAlias, + resolveCliCommandName, + getCliCommand, + listCliCommands, + listCliCommandNames, + listDocumentedCommands, + clearCliRegistry, +} from './registry' + +export { + AUTH_CLI_COMMAND_NAMES, + isAuthCliCommand, + filterCliCommandsForLoginState, + listCliCommandsForLoginState, + listCliCommandNamesForLoginState, +} from './access' + +export { dispatchKeeperCli, dispatchCliLine } from './dispatch' + +export { KeeperCliParser, createKeeperCliParser } from './parser' +export type { KeeperCliParserOptions } from './parser' + +export { + runLoginCommand, + loginWithCredentials, + loginWithSessionToken, + ensureLoggedIn, + loginCommand, +} from './commands/login' + +export { runLogoutCommand, logoutCommand } from './commands/logout' +export { vaultCommand } from './commands/vault' +export { helpCommand } from './commands/help' +export { restoreSessionCommand } from './commands/restoreSession' +export { syncCommand, runVaultSync } from './commands/sync' + +export { getKeeperCliPromptPrefix } from './prompt' +export { BUILTIN_CLI_COMMANDS, registerBuiltinCliCommands } from './builtinCommands' +export { + getCommand, + executeGet, + listCommand, + searchCommand, + listSfCommand, + whoamiCommand, + lsCommand, + cdCommand, + treeCommand, + mkdirCommand, +} from './commander' + +export { utf8ToBase64Url, recordUid } from './utils' + +export type { SessionRestoreInput } from '../auth/sessionRestore' +export { + toSessionParams, + validateSessionRestoreInput, + sessionRestoreFromJson, + resolveSessionRestorePayload, +} from '../auth/sessionRestore' diff --git a/KeeperSdk/src/cli/jsonArg.ts b/KeeperSdk/src/cli/jsonArg.ts new file mode 100644 index 00000000..6a11c1bc --- /dev/null +++ b/KeeperSdk/src/cli/jsonArg.ts @@ -0,0 +1,30 @@ +/** + * Everything after `--from-json` on the command line (trimmed). No tokenization — callers use JSON.parse. + * Trailing flags such as `--sync` are stripped via {@link stripTrailingCliFlags}. + */ +export function extractFromJsonFlagValue( + line: string, + flag = 'from-json', + trailingFlags: readonly string[] = [] +): string | null { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const flagRe = new RegExp(`(?:^|\\s)--${escaped}(?:\\s*=\\s*|\\s+)`, 'i') + const m = line.match(flagRe) + if (!m || m.index === undefined) return null + const rest = line.slice(m.index + m[0].length).trim() + if (!rest) return null + return stripTrailingCliFlags(rest, trailingFlags) +} + +/** Remove trailing ` --flag` tokens (e.g. `--sync` after a file path or JSON blob). */ +export function stripTrailingCliFlags(value: string, flagNames: readonly string[]): string { + if (flagNames.length === 0) return value.trim() + let s = value.trim() + const parts = flagNames.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + const alt = parts.join('|') + const tailFlag = new RegExp(`\\s+--(?:${alt})(?:\\s*=\\s*(?:"[^"]*"|\\S+))?\\s*$`, 'i') + while (tailFlag.test(s)) { + s = s.replace(tailFlag, '').trim() + } + return s +} diff --git a/KeeperSdk/src/cli/parse.ts b/KeeperSdk/src/cli/parse.ts new file mode 100644 index 00000000..73f9ef15 --- /dev/null +++ b/KeeperSdk/src/cli/parse.ts @@ -0,0 +1,173 @@ +import type { CliResult, ParsedCli } from './types' + +const isWhitespace = (ch: string) => /\s/.test(ch) + +/** Split a command line into tokens; respects double quotes and `\\` escapes. */ +export function tokenizeArguments(args: string): string[] { + const out: string[] = [] + const sb: string[] = [] + let pos = 0 + let inQuote = false + let escape = false + + const flush = () => { + if (sb.length > 0) { + out.push(sb.join('')) + sb.length = 0 + } + } + + while (pos < args.length) { + const ch = args[pos] + if (escape) { + escape = false + sb.push(ch) + pos++ + continue + } + if (inQuote) { + if (ch === '\\') { + escape = true + pos++ + continue + } + if (ch === '"') { + inQuote = false + pos++ + continue + } + sb.push(ch) + pos++ + continue + } + switch (ch) { + case '\\': + escape = true + pos++ + break + case '"': + inQuote = true + pos++ + break + default: + if (isWhitespace(ch)) { + flush() + pos++ + } else { + sb.push(ch) + pos++ + } + } + } + flush() + return out +} + +function setBool(opts: Map, k: string): void { + opts.set(k.toLowerCase(), true) +} + +function setStr(opts: Map, k: string, v: string): void { + opts.set(k.toLowerCase(), v) +} + +/** Parse argv-style tokens after the command name. */ +export function parseCliArgs(tokens: string[]): ParsedCli { + const positional: string[] = [] + const opts = new Map() + + let i = 0 + while (i < tokens.length) { + const t = tokens[i] + if (t === '--') { + positional.push(...tokens.slice(i + 1)) + break + } + if (t === '-' || !t.startsWith('-')) { + positional.push(t) + i++ + continue + } + + if (t.startsWith('--')) { + const body = t.slice(2) + if (!body) { + positional.push(t) + i++ + continue + } + const eq = body.indexOf('=') + if (eq >= 0) { + setStr(opts, body.slice(0, eq), body.slice(eq + 1)) + i++ + continue + } + const name = body + const next = tokens[i + 1] + if (next && next !== '--' && !next.startsWith('-')) { + setStr(opts, name, next) + i += 2 + continue + } + setBool(opts, name) + i++ + continue + } + + const rest = t.slice(1) + if (!rest) { + positional.push(t) + i++ + continue + } + if (/^[A-Za-z]$/.test(rest)) { + setBool(opts, rest) + i++ + continue + } + if (/^[A-Za-z]+$/.test(rest)) { + for (const ch of rest) setBool(opts, ch) + i++ + continue + } + + positional.push(t) + i++ + } + + return { positional, opts } +} + +export function hasOpt(opts: Map, ...names: string[]): boolean { + for (const n of names) { + const v = opts.get(n.toLowerCase()) + if (v === true) return true + } + return false +} + +export function getOpt(opts: Map, ...names: string[]): string | undefined { + for (const n of names) { + const v = opts.get(n.toLowerCase()) + if (v !== undefined && v !== true) return v + } + return undefined +} + +export function wantsCliHelp(parsed: ParsedCli): boolean { + return hasOpt(parsed.opts, 'help', 'h') +} + +export function rejectUnknownOptions( + parsed: ParsedCli, + allowed: ReadonlySet, + commandName: string +): CliResult | null { + for (const k of parsed.opts.keys()) { + if (k === 'help' || k === 'h') continue + if (!allowed.has(k)) { + return { code: 1, out: '', err: `${commandName}: unknown option --${k}\n` } + } + } + return null +} diff --git a/KeeperSdk/src/cli/parser.ts b/KeeperSdk/src/cli/parser.ts new file mode 100644 index 00000000..2be6be47 --- /dev/null +++ b/KeeperSdk/src/cli/parser.ts @@ -0,0 +1,172 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from './types' +import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' +import { extractFromJsonFlagValue } from './jsonArg' +import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' +import { AUTH_CLI_COMMAND_NAMES, isAuthCliCommand } from './access' +import { BUILTIN_CLI_COMMANDS } from './builtinCommands' +import { formatAllCommandsSummary, formatDetailedHelpForCommand, formatShortCommandSummary } from './help' + +const NOT_LOGGED_IN_ERR = + 'Not logged in. Run `login` or `restore-session` (see `help`).\n' + +export type KeeperCliParserOptions = { + prog?: string + description?: string + epilog?: string +} + +/** Self-contained CLI parser. Register commands, then `parse()` dispatches a line. */ +export class KeeperCliParser { + private readonly prog: string + private readonly description: string + private readonly epilog?: string + private readonly commands = new Map() + private readonly aliases = new Map() + + constructor(options: KeeperCliParserOptions = {}) { + this.prog = options.prog ?? 'keeper' + this.description = options.description ?? '' + this.epilog = options.epilog + } + + addCommand(def: CliCommandDefinition): this { + const key = def.name.toLowerCase() + this.commands.set(key, def) + if (def.aliases) { + for (const alias of def.aliases) { + this.aliases.set(alias.toLowerCase(), key) + } + } + return this + } + + addCommands(defs: Iterable): this { + for (const def of defs) this.addCommand(def) + return this + } + + list(): CliCommandDefinition[] { + return [...this.commands.values()].sort((a, b) => { + const oa = a.order ?? 500 + const ob = b.order ?? 500 + if (oa !== ob) return oa - ob + return a.name.localeCompare(b.name) + }) + } + + listNames(): string[] { + return this.list().map((c) => c.name) + } + + resolve(name: string): CliCommandDefinition | undefined { + const key = name.toLowerCase() + if (this.commands.has(key)) return this.commands.get(key) + const target = this.aliases.get(key) + return target ? this.commands.get(target) : undefined + } + + formatHelp(host?: KeeperCliHost): string { + const loggedIn = host?.getVault().isLoggedIn ?? true + const commands = loggedIn + ? this.list() + : this.list().filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) + const header = this.description ? `${this.prog} — ${this.description}\n\n` : '' + const body = loggedIn + ? formatAllCommandsSummary(commands) + : formatAllCommandsSummary(commands, { + header: 'Not logged in — sign-in commands:\n\n', + footer: + '\nRun `login` or `restore-session` to open the vault.\n' + + 'After login, run `help` again for vault commands (get, ls, cd, …).\n', + }) + const footer = this.epilog ? `\n${this.epilog}\n` : '' + return header + body + footer + } + + formatCommandHelp(name: string): string | null { + const def = this.resolve(name) + return def ? formatDetailedHelpForCommand(def) : null + } + + formatCommandSummary(name: string): string | null { + const def = this.resolve(name) + return def ? formatShortCommandSummary(def) : null + } + + async parse(line: string | readonly string[], host: KeeperCliHost): Promise { + const { tokens, raw } = normalizeInput(line) + if (tokens.length === 0) { + return ok(this.formatHelp(host)) + } + + const first = tokens[0] + const rest = tokens.slice(1) + + if (isHelpToken(first)) { + const sub = rest[0] + if (!sub) return ok(this.formatHelp(host)) + if (!host.getVault().isLoggedIn && !isAuthCliCommand(sub)) { + return err(NOT_LOGGED_IN_ERR) + } + const page = this.formatCommandHelp(sub) + if (page) return ok(page) + return err(`${this.prog}: unknown command: ${sub}\nTry: ${this.prog} --help\n`) + } + + const def = this.resolve(first) + if (!def) { + return err(`${this.prog}: unknown command: ${first}\nTry: ${this.prog} --help\n`) + } + + if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { + return err(NOT_LOGGED_IN_ERR) + } + + let parsed: ParsedCli + if (def.name === 'restore-session') { + const json = extractFromJsonFlagValue(raw, 'from-json', RESTORE_SESSION_TRAILING_OPTS) + parsed = parseCliArgs(rest) + if (json) parsed.opts.set('from-json', json) + } else { + parsed = parseCliArgs(rest) + } + + if (wantsCliHelp(parsed)) { + return ok(formatDetailedHelpForCommand(def)) + } + return def.run(host, parsed) + } +} + +/** Parser pre-loaded with the SDK's built-in commands. */ +export function createKeeperCliParser(options: KeeperCliParserOptions = {}): KeeperCliParser { + const parser = new KeeperCliParser(options) + void loadBuiltinsInto(parser) + return parser +} + +function loadBuiltinsInto(parser: KeeperCliParser): void { + parser.addCommands(BUILTIN_CLI_COMMANDS) +} + +function normalizeInput(line: string | readonly string[]): { tokens: string[]; raw: string } { + if (typeof line === 'string') { + const trimmed = line.trim() + return { tokens: trimmed ? tokenizeArguments(trimmed) : [], raw: trimmed } + } + const tokens = [...line].filter((t) => t.length > 0) + return { tokens, raw: tokens.join(' ') } +} + +function isHelpToken(token: string): boolean { + const t = token.toLowerCase() + return t === '--help' || t === '-h' || t === 'help' +} + +function ok(out: string): CliResult { + return { code: 0, out, err: '' } +} + +function err(message: string): CliResult { + return { code: 1, out: '', err: message } +} diff --git a/KeeperSdk/src/cli/prompt.ts b/KeeperSdk/src/cli/prompt.ts new file mode 100644 index 00000000..0ac8ea99 --- /dev/null +++ b/KeeperSdk/src/cli/prompt.ts @@ -0,0 +1,12 @@ +import type { KeeperCliHost } from './types' + +const NOT_LOGGED_IN_PROMPT = 'Not logged in> ' +const PROMPT_MAX_LEN = 40 + +export function getKeeperCliPromptPrefix(host: KeeperCliHost): string { + const v = host.getVault() + if (!v.isLoggedIn) return NOT_LOGGED_IN_PROMPT + const name = v.getWorkingFolderDisplayName?.() ?? 'My Vault' + const label = name.length > PROMPT_MAX_LEN ? `...${name.slice(-37)}` : name + return `${label}> ` +} diff --git a/KeeperSdk/src/cli/registry.ts b/KeeperSdk/src/cli/registry.ts new file mode 100644 index 00000000..4e541bac --- /dev/null +++ b/KeeperSdk/src/cli/registry.ts @@ -0,0 +1,55 @@ +import type { CliCommandDefinition } from './types' + +const commands = new Map() +const aliases = new Map() + +function normalizeName(name: string): string { + return name.toLowerCase() +} + +export function registerCliCommand(def: CliCommandDefinition): void { + const key = normalizeName(def.name) + commands.set(key, def) + if (def.aliases) { + for (const alias of def.aliases) { + aliases.set(normalizeName(alias), key) + } + } +} + +export function registerCliAlias(alias: string, commandName: string): void { + aliases.set(normalizeName(alias), normalizeName(commandName)) +} + +export function resolveCliCommandName(name: string): string | undefined { + const key = normalizeName(name) + if (commands.has(key)) return key + return aliases.get(key) +} + +export function getCliCommand(name: string): CliCommandDefinition | undefined { + const key = resolveCliCommandName(name) + return key ? commands.get(key) : undefined +} + +export function listCliCommands(): CliCommandDefinition[] { + return [...commands.values()].sort((a, b) => { + const oa = a.order ?? 500 + const ob = b.order ?? 500 + if (oa !== ob) return oa - ob + return a.name.localeCompare(b.name) + }) +} + +export function listCliCommandNames(): readonly string[] { + return listCliCommands().map((c) => c.name) +} + +export function listDocumentedCommands(): readonly string[] { + return listCliCommandNames() +} + +export function clearCliRegistry(): void { + commands.clear() + aliases.clear() +} diff --git a/KeeperSdk/src/cli/table.ts b/KeeperSdk/src/cli/table.ts new file mode 100644 index 00000000..499f206c --- /dev/null +++ b/KeeperSdk/src/cli/table.ts @@ -0,0 +1,8 @@ +/** Fixed-width column formatter. Last column is left unpadded. */ +export function formatTable(headers: string[], rows: string[][]): string { + if (rows.length === 0) return '' + const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) + const fmt = (cells: string[]): string => + cells.map((s, i) => (i === cells.length - 1 ? s : (s ?? '').padEnd(widths[i]))).join(' ') + return [fmt(headers), ...rows.map(fmt)].join('\n') + '\n' +} diff --git a/KeeperSdk/src/cli/types.ts b/KeeperSdk/src/cli/types.ts new file mode 100644 index 00000000..fb6cb6fc --- /dev/null +++ b/KeeperSdk/src/cli/types.ts @@ -0,0 +1,90 @@ +import type { DRecord, DSharedFolder, SyncResult } from '@keeper-security/keeperapi' +import type { SessionRestoreInput } from '../auth/sessionRestore' +import type { ChangeDirectoryResult } from '../folders/changeDirectory' +import type { FolderTreeBuildOptions } from '../folders/folderTree' +import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' +import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' +import type { MkdirOptions } from '../folders/addFolder' +import type { RenameFolderResult } from '../folders/updateFolder' +import type { DeleteFolderResult } from '../folders/deleteFolder' +import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' +import type { RecordShareInfo } from '../sharing/Sharing' +import type { VaultSummary } from '../vault/KeeperVault' + +export type CliResult = { + code: number + out: string + err: string + /** Set when the host UI must prompt for a masked password (never on the CLI line). */ + needPassword?: boolean + loginUsername?: string +} + +export type ParsedCli = { + positional: string[] + opts: Map +} + +/** + * Vault surface for CLI handlers. Methods beyond session/sync/records are optional; + * commands call `ensureCapability` so thin hosts fail with a clear message. + */ +export type KeeperCliVault = { + readonly isLoggedIn: boolean + login(username: string, password: string): Promise + loginWithSessionToken(username: string, sessionToken: string): Promise + logout(): Promise + sync(): Promise + getRecords(): DRecord[] + getSharedFolders(): DSharedFolder[] + restoreSession(input: SessionRestoreInput): Promise + getSummary?: () => VaultSummary + findRecord?: (uidOrTitle: string) => DRecord | undefined + findRecords?: (criteria: string) => DRecord[] + getRecordShareInfo?: (recordUid: string) => Promise + listSharedFolders?: (options?: ListSharedFoldersOptions) => ListSharedFolderRow[] + listFolder?: (options?: ListFolderOptions) => Promise + tree?: (options?: FolderTreeBuildOptions) => Promise + changeDirectory?: (path: string) => Promise + getCurrentFolderUid?: () => string | null + getWorkingFolderDisplayName?: () => string + getFolder?: (uidOrName: string, options?: GetFolderOptions) => Promise + mkdir?: (path: string, options?: MkdirOptions) => Promise<{ folderUid: string; success: boolean; message?: string }> + renameFolder?: (folderPath: string, newName: string) => Promise + rmdir?: (patterns: string[], options?: { force?: boolean }) => Promise +} + +/** Host adapter (browser shell, Node script, tests). `readTextFile` is optional. */ +export type KeeperCliHost = { + getVault(): KeeperCliVault + envString(name: string): string | undefined + formatError(context: string, err: unknown): string + readTextFile?: (path: string) => Promise + getAccountUsername?: () => Promise +} + +export type CliHelpDoc = { + title: string + synopsis?: string + description?: string + arguments?: string + options?: string + environment?: string + examples?: string + seeAlso?: string + note?: string +} + +export type CliCommandDefinition = { + name: string + order?: number + description: string + usage: string + aliases?: readonly string[] + subcommands?: readonly string[] + flagOptions?: readonly string[] + /** When set, options outside this set are rejected (`--help` / `-h` always allowed). */ + allowedOptions?: ReadonlySet + help: CliHelpDoc + run: (host: KeeperCliHost, parsed: ParsedCli) => Promise +} diff --git a/KeeperSdk/src/cli/utils.ts b/KeeperSdk/src/cli/utils.ts new file mode 100644 index 00000000..5db5dae4 --- /dev/null +++ b/KeeperSdk/src/cli/utils.ts @@ -0,0 +1,18 @@ +export function utf8ToBase64Url(s: string): string { + const bytes = new TextEncoder().encode(s) + let b64: string + if (typeof Buffer !== 'undefined') { + b64 = Buffer.from(bytes).toString('base64') + } else { + let bin = '' + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]!) + } + b64 = globalThis.btoa(bin) + } + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export function recordUid(rec: { uid?: string }): string { + return rec.uid || '(unknown uid)' +} diff --git a/KeeperSdk/src/cli/vaultSurface.ts b/KeeperSdk/src/cli/vaultSurface.ts new file mode 100644 index 00000000..cb3e43f8 --- /dev/null +++ b/KeeperSdk/src/cli/vaultSurface.ts @@ -0,0 +1,18 @@ +/** Shared footer for vault-related command help (SDK APIs not yet exposed as CLI). */ +export const KEEPER_VAULT_SURFACE = ` +KeeperVault (JavaScript SDK) — operations available in code (not all exposed as CLI yet): + + Session: login, loginWithSessionToken, logout, resumeSession, sync, disconnect + Records: getRecords, findRecord, findRecords, getRecordByUid, getRecordsByType, + addRecord, updateRecord, deleteRecord, moveRecord, getRecordHistory, + printRecords + Sharing: shareRecord, removeRecordShare, getRecordShareInfo + Folders: listFolder, changeDirectory, getFolder, mkdir, addFolder, updateFolder, + renameFolder, deleteFolder, rmdir, tree, getCurrentFolderUid + Shared folders: getSharedFolders, listSharedFolders, shareFolder, … + Metadata: getRecordMetadata, getSummary, … + +Utilities exported from @keeper-security/keeper-sdk-javascript include searchRecords, +formatRecord, getRecordTitle, getRecordPassword, getRecordLogin, shareRecord, … +See the SDK package for full APIs. +`.trim() diff --git a/KeeperSdk/src/folders/folderTree.ts b/KeeperSdk/src/folders/folderTree.ts index 03f1dfd2..be047709 100644 --- a/KeeperSdk/src/folders/folderTree.ts +++ b/KeeperSdk/src/folders/folderTree.ts @@ -20,6 +20,12 @@ enum TreeItemKind { Folder = 'folder', } +const TREE_TAG = { + folder: '[folder]', + sharedFolder: '[shared folder]', + record: '[record]', +} as const + export type FolderTreeBuildOptions = { folderPath?: string | null verbose?: boolean @@ -130,6 +136,26 @@ async function collectSharedFolderPermissions( return rows.map((row) => ({ display: row.display })) } +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 +} + +function formatTreeNodeName(baseName: string, tag: string, verbose: boolean, uid?: string): string { + const name = verbose && uid ? `${baseName} (${uid})` : baseName + return `${name} ${tag}` +} + +function formatTreeRecordName(title: string, verbose: boolean, recordUid?: string): string { + const name = verbose && recordUid ? `${title} (${recordUid})` : title + return `${name} ${TREE_TAG.record}` +} + type BuildOpts = Required> & { promotedRootSharedUids?: Set accountUidEmailMap: Map @@ -152,13 +178,12 @@ async function buildFolderSubtree( else if (sharedFolder) baseName = sharedFolderName(sharedFolder) else baseName = sharedFolderFolderName(sharedFolderFolder!) - let displayName = baseName - if (opts.verbose) { - displayName = `${baseName} (${folderUid})` - } - if (sharedFolder) { - displayName += ' [Shared]' - } + let displayName = formatTreeNodeName( + baseName, + folderTreeTag(userFolder, sharedFolder, sharedFolderFolder), + opts.verbose, + folderUid + ) const node: FolderTreeNode = { displayName, children: [] } @@ -188,8 +213,7 @@ async function buildFolderSubtree( 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 { display: formatTreeRecordName(title, opts.verbose, recordRow.uid) } }) } @@ -212,8 +236,7 @@ async function buildVaultRootTree(storage: InMemoryStorage, opts: BuildOpts): Pr 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 { display: formatTreeRecordName(title, opts.verbose, recordRow.uid) } }) } return node diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 5e93ce18..135843d1 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -1,12 +1,13 @@ +import { connectSdkPlatform } from './platform' +import { nodeSdkPlatform } from './platform/node/platform' + +connectSdkPlatform(nodeSdkPlatform) + +export * from './api' + +/** Node.js Commander / CLI helpers (readline, ~/.keeper config). */ export { ConsoleAuthUI } from './auth/ConsoleAuthUI' -export { SessionManager, FileConfigLoader } from './auth/SessionManager' -export type { - KeeperJsonConfig, - ConfigLoader, - ConfigurationUser, - ConfigurationServerConfig, - ConfigurationDeviceConfig, -} from './auth/SessionManager' +export { FileConfigLoader } from './auth/node/FileConfigLoader' export { login, cleanup, prompt, suppressLogs, loadKeeperConfig, resolveServer } from './auth/ConsoleLogin' export { InMemoryStorage } from './storage/InMemoryStorage' diff --git a/KeeperSdk/src/platform/browser/platform.ts b/KeeperSdk/src/platform/browser/platform.ts new file mode 100644 index 00000000..95938970 --- /dev/null +++ b/KeeperSdk/src/platform/browser/platform.ts @@ -0,0 +1,76 @@ +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..32bac290 --- /dev/null +++ b/KeeperSdk/src/platform/index.ts @@ -0,0 +1,20 @@ +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..9a3a9b60 --- /dev/null +++ b/KeeperSdk/src/platform/node/platform.ts @@ -0,0 +1,42 @@ +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..7816e006 --- /dev/null +++ b/KeeperSdk/src/platform/types.ts @@ -0,0 +1,25 @@ +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/RecordUtils.ts b/KeeperSdk/src/records/RecordUtils.ts index 74630faa..f510d6af 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -209,14 +209,37 @@ export function getRecordUrl(record: DRecord): string | undefined { return getRecordSummary(record).url } +export function getRecordDescription(record: DRecord): string { + if (record.version === 6) return 'PAM Configuration' + + 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(' @ ') : '' +} + +export function getRecordCategory(record: DRecord): 'Classic' | 'Nested' { + return record.isKeeperDriveData ? 'Nested' : 'Classic' +} + const wordCache = new WeakMap() export function searchRecords(records: DRecord[], criteria: string): DRecord[] { - if (!criteria.trim()) return records + const trimmed = criteria.trim() + if (!trimmed) return records - const searchWords = criteria.toLowerCase().split(/\s+/) + 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) @@ -246,11 +269,67 @@ function collectRecordWords(record: DRecord): string[] { } } - words.push(record.uid) + words.push(record.uid.toLowerCase()) return words } -export function formatRecord(record: DRecord, showDetails = false): string { +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, + } +} + +function formatFieldValue(field: RecordField, unmask: boolean): string { + 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) => (typeof v === 'string' ? v : JSON.stringify(v))).join(', ') + } + return MASKED_VALUE +} + +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 + const label = TOTP_FIELD_TYPES.has(field.type) + ? 'TOTP URL' + : (field.label || field.type).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + fields.push({ name: label, value: formatFieldValue(field, 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, @@ -261,17 +340,23 @@ export function formatRecord(record: DRecord, showDetails = false): string { 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 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(', ')}`) + lines.push(`${label}: ${formatFieldValue(field, unmask)}`) } const totpUrl = getRecordTotpUrl(record) + if (unmask && totpUrl) { + lines.push(`TOTP URL: ${totpUrl}`) + } const code = totpUrl ? getTotpCode(totpUrl) : null if (code) { lines.push(`Two Factor Code: ${code.code} valid for ${code.secondsRemaining} sec`) diff --git a/KeeperSdk/src/records/Totp.ts b/KeeperSdk/src/records/Totp.ts index 1ba67d53..92980757 100644 --- a/KeeperSdk/src/records/Totp.ts +++ b/KeeperSdk/src/records/Totp.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'crypto' +import { getSdkPlatform } from '../platform' export type TotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512' @@ -22,10 +22,7 @@ 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 cleaned = input.replace(/=+$/g, '').replace(/\s+/g, '').toUpperCase() const out: number[] = [] let buffer = 0 let bits = 0 @@ -76,10 +73,11 @@ export function parseTotpUrl(url: string): TotpParams | null { } } -function counterToBuffer(counter: number): Buffer { - const buf = Buffer.alloc(8) - buf.writeUInt32BE(Math.floor(counter / UINT32_MAX), 0) - buf.writeUInt32BE(counter % UINT32_MAX, 4) +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 } @@ -101,9 +99,8 @@ export function getTotpCode(urlOrParams: string | TotpParams, now: number = Date 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 diff --git a/KeeperSdk/src/records/listRecordsTable.ts b/KeeperSdk/src/records/listRecordsTable.ts new file mode 100644 index 00000000..17977855 --- /dev/null +++ b/KeeperSdk/src/records/listRecordsTable.ts @@ -0,0 +1,95 @@ +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/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 89424b97..1ddbe973 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -37,6 +37,9 @@ export class InMemoryStorage implements VaultStorage { 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()) } diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 24d29f8b..a50889ce 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -89,6 +89,10 @@ export enum UserErrorCode { TeamUserRemoveFailed = 'team_user_remove_failed', } +export enum SyncErrorCode { + SyncFailed = 'sync_failed', +} + export const ResultCodes = { INVALID_CREDENTIALS: AuthErrorCode.InvalidCredentials, MISSING_USERNAME: AuthErrorCode.MissingUsername, @@ -149,6 +153,7 @@ export const ResultCodes = { NO_TEAMS_FOR_USER_OP: UserErrorCode.NoTeamsForUserOp, TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, + SYNC_FAILED: SyncErrorCode.SyncFailed, } as const export const KEEPER_PUBLIC_HOSTS: Record = { diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index a7a638f0..7b53cf56 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -20,6 +20,8 @@ export { EMAIL_LIST_SEPARATOR_PATTERN, TOKEN_SEPARATOR_PATTERN, REGEX_ESCAPE_PATTERN, + TRAILING_EQUALS_PATTERN, + WHITESPACE_PATTERN, isValidEmail, escapeRegExp, resolveSearchPattern, diff --git a/KeeperSdk/src/utils/patterns.ts b/KeeperSdk/src/utils/patterns.ts index d65cc00f..fe15b54c 100644 --- a/KeeperSdk/src/utils/patterns.ts +++ b/KeeperSdk/src/utils/patterns.ts @@ -12,10 +12,14 @@ export const TOKEN_SEPARATOR_PATTERN = /[\s\-_.,;:!?@#$%^&*()[\]{}|\\/<>]+/ /** Characters that must be escaped when embedding user input into a RegExp. */ 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 { diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 52c3055e..79ea58f7 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -8,11 +8,16 @@ import { DTeam, DUserFolder, Authentication, + normal64Bytes, } 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 { getSdkPlatform } from '../platform' +import { + toSessionParams, + type SessionRestoreInput, +} from '../auth/sessionRestore' import { searchRecords, formatRecord, getRecordTitle, getRecordType } from '../records/RecordUtils' import { addRecord as addRecordOp, @@ -99,7 +104,15 @@ import type { TeamUserResult, FormattedTeamUserTable, } from '../users/userTypes' -import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' +import { + ConsoleLogger, + LogLevel, + KeeperSdkError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + ResultCodes, +} from '../utils' import type { ILogger } from '../utils' enum VaultStatus { @@ -157,7 +170,7 @@ export class KeeperVault { 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() + this.authUI = config?.authUI ?? getSdkPlatform().createAuthUI(this.config.useConsoleAuth) const authProvider = () => this.getAuthOrThrow() this.folderManager = new FolderManager(this.storage, this.folderSession, authProvider) @@ -265,10 +278,75 @@ export class KeeperVault { 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 + ) + } + + this.synced = false + this.log.info(`Session restored for ${params.username}`) + } + + public async getAccountUsername(): Promise { + return this.sessionManager.getLastUsername() + } + public async resumeSession(): Promise { const username = await this.sessionManager.getLastUsername() if (!username) { @@ -317,14 +395,22 @@ export class KeeperVault { 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 + 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 + } } public async batch(fn: () => Promise): Promise { @@ -356,7 +442,10 @@ export class KeeperVault { } public getRecordByUid(uid: string): DRecord | undefined { - return this.storage.getByUid(VaultObjectKind.Record, uid) + 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 { diff --git a/KeeperSdk/tsconfig.json b/KeeperSdk/tsconfig.json index 74c99318..15eafc2b 100644 --- a/KeeperSdk/tsconfig.json +++ b/KeeperSdk/tsconfig.json @@ -7,7 +7,7 @@ "skipLibCheck": true, "esModuleInterop": true, "declaration": true, - "rootDir": ".", + "rootDir": "src", "outDir": "dist", "types": ["node"] }, From a9c6dfb453c06ded218352c896c6eef1aa93a4b2 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 16 Jun 2026 23:06:05 +0530 Subject: [PATCH 02/48] NSF command implementation (list, get, ln and rm) (#169) --- KeeperSdk/package-lock.json | 8 +- KeeperSdk/package.json | 2 +- KeeperSdk/src/index.ts | 54 ++ .../NestedShareFolderManager.ts | 97 ++++ KeeperSdk/src/nestedShareFolders/getNsf.ts | 500 ++++++++++++++++++ KeeperSdk/src/nestedShareFolders/index.ts | 77 +++ .../src/nestedShareFolders/linkNsfRecord.ts | 139 +++++ KeeperSdk/src/nestedShareFolders/listNsf.ts | 173 ++++++ .../src/nestedShareFolders/nsfConstants.ts | 56 ++ .../src/nestedShareFolders/nsfHelpers.ts | 326 ++++++++++++ .../src/nestedShareFolders/removeNsfRecord.ts | 237 +++++++++ KeeperSdk/src/storage/InMemoryStorage.ts | 4 + KeeperSdk/src/utils/constants.ts | 36 +- KeeperSdk/src/utils/index.ts | 1 + KeeperSdk/src/vault/KeeperVault.ts | 55 ++ examples/sdk_example/package.json | 4 + .../src/nestedShareFolders/get_nsf.ts | 51 ++ .../src/nestedShareFolders/link_nsf.ts | 44 ++ .../src/nestedShareFolders/list_nsf.ts | 69 +++ .../src/nestedShareFolders/remove_nsf.ts | 119 +++++ 20 files changed, 2043 insertions(+), 9 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts create mode 100644 KeeperSdk/src/nestedShareFolders/getNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/index.ts create mode 100644 KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/listNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfConstants.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfHelpers.ts create mode 100644 KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/get_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/link_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/list_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/remove_nsf.ts diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 0eed8f14..99c62392 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "17.2.3", + "@keeper-security/keeperapi": "17.2.7", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, @@ -56,9 +56,9 @@ } }, "node_modules/@keeper-security/keeperapi": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.2.3.tgz", - "integrity": "sha512-RT1ZnvfonFrPuLij8mPso/1eyTzurm2ikJxydzOA7oLQlqNRcH2FRramCan0sN85U5RNnM5QS05NVaJq4K72pg==", + "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", diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 7a1fe816..a6e66e17 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -21,7 +21,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "17.2.6", + "@keeper-security/keeperapi": "17.2.7", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 5e93ce18..317c7da8 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -25,6 +25,7 @@ export { extractResultCode, SdkDefaults, AuthDefaults, + NsfErrorCode, ResultCodes, AuthErrorCode, SessionErrorCode, @@ -443,6 +444,59 @@ export { export { UserManager } from './users/UserManager' +export { + ROOT_FOLDER_UID, + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + ListNsfFormat, + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, + GetNsfFormat, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, + linkNestedShareRecord, + NsfRemoveOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, + NestedShareFolderManager, +} from './nestedShareFolders' +export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, + LinkNsfRecordResult, + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, +} from './nestedShareFolders' + export type { DRecord, DRecordMetadata, diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts new file mode 100644 index 00000000..4a21c74f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -0,0 +1,97 @@ +import type { Auth } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + formatListNsfOutput, + formatListNsfTable, + listNestedShareFolders, + renderListNsfAsciiTable, + type FormattedListNsfTable, + type ListNsfFormatInput, + type ListNsfOptions, + type ListNsfRow, +} from './listNsf' +import { + formatNsfDetail as renderNsfDetail, + formatNsfFolderDetail as renderNsfFolderDetail, + formatNsfRecordDetail as renderNsfRecordDetail, + getNestedShareFolder, + type GetNsfOptions, + type GetNsfResult, + type NsfFolderView, + type NsfRecordView, +} from './getNsf' +import { linkNestedShareRecord, type LinkNsfRecordResult } from './linkNsfRecord' +import { + formatRemoveNsfPreview, + removeNestedShareRecords, + type RemoveNsfRecordInput, + type RemoveNsfRecordResult, +} from './removeNsfRecord' + +export type AuthProvider = () => Auth + +export class NestedShareFolderManager { + private readonly storage: InMemoryStorage + private readonly 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 + } + + public listNestedShareFolders(options: ListNsfOptions = {}): ListNsfRow[] { + return listNestedShareFolders(this.storage, 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 = 'table'): string { + return formatListNsfOutput(rows, format) + } + + public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { + return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) + } + + public formatNsfDetail(result: GetNsfResult, verbose = false): string { + return renderNsfDetail(result, verbose) + } + + public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { + return renderNsfFolderDetail(view, verbose) + } + + public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { + return renderNsfRecordDetail(view, verbose) + } + + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string + ): Promise { + return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) + } + + public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { + return removeNestedShareRecords(this.storage, this.requireAuth(), input) + } + + public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { + return formatRemoveNsfPreview(preview) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts new file mode 100644 index 00000000..9130ad20 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -0,0 +1,500 @@ +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' +import { + getRecordFields, + getRecordLogin, + getRecordPassword, + getRecordTitle, + getRecordType, + getRecordUrl, +} from '../records/RecordUtils' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + buildFolderPath, + collectRecordsInFolder, + findRecordFolderLocation, + folderAccessDisplayRole, + formatAccessType, + getFolderAccessEntries, + getKeeperDriveFolder, + getKeeperDriveRecord, + isFolderShareAdministrator, + isFolderUserPermission, + isSensitiveFieldType, + normalizeParentUid, + resolveAccessUsername, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { + NSF_FOLDER_LABEL_WIDTH, + NSF_FOLDER_SHARE_ADMINS_HEADING, + NSF_FOLDER_USER_PERMISSIONS_HEADING, + NSF_MASKED_VALUE, + NSF_RECORD_LABEL_WIDTH, + NSF_RECORD_USER_PERMISSIONS_HEADING, + NSF_TOP_LEVEL_FIELD_TYPES, + NSF_UNKNOWN_RECORD_TITLES, +} from './nsfConstants' + +function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { + if (value == null) return undefined + return typeof value === 'number' ? value : value.toNumber() +} + +function formatNsfFieldParts(values: unknown[]): string[] { + 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) +} + +export enum GetNsfFormat { + Detail = 'detail', + JSON = 'json', +} + +export type GetNsfFormatInput = GetNsfFormat | `${GetNsfFormat}` + +export type GetNsfOptions = { + format?: GetNsfFormatInput + verbose?: boolean + unmask?: boolean +} + +export type NsfFolderAccessRow = { + username: string + role: string +} + +export type NsfFolderPermission = { + accessTypeUid: string + accessType: string + accessRoleType: string + inherited?: boolean + hidden?: boolean + canAdd?: boolean + canRemove?: boolean + canDelete?: boolean + canListAccess?: boolean + canUpdateAccess?: boolean + canEditRecords?: boolean + canViewRecords?: boolean + canListRecords?: boolean +} + +export type NsfRecordPermission = { + username: string + accountUid?: string + owner: boolean + shareAdmin: boolean + shareable: boolean + editable: boolean + awaitingApproval: boolean + expiration?: number +} + +export type NsfFolderView = { + objectType: 'folder' + folderUid: string + name: string + parentUid: string + path: string + userPermissions: NsfFolderAccessRow[] + shareAdmins: NsfFolderAccessRow[] + teamPermissions: NsfFolderPermission[] + records: { uid: string; title: string; type: string }[] +} + +export type NsfRecordView = { + objectType: 'record' + recordUid: string + title: string + type: string + revision: number + version: number + folderLocation: string + login?: string + password?: string + url?: string + notes?: string + fields: { type: string; label?: string; value: string }[] + userPermissions: NsfRecordPermission[] + shareAdmins: string[] +} + +export type GetNsfResult = { kind: 'folder'; view: NsfFolderView } | { kind: 'record'; view: NsfRecordView } + +function folderDetailRow(label: string, value: string): string { + return `${label.padStart(NSF_FOLDER_LABEL_WIDTH)}: ${value}` +} + +function recordDetailRow(label: string, value: string): string { + 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 bytesToUid(bytes: Uint8Array | null | undefined): string | undefined { + return bytes?.length ? webSafe64FromBytes(bytes) : undefined +} + +function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { + const permission = entry.permission + return { + accessTypeUid: entry.accessTypeUid, + accessType: formatAccessType(entry.accessType), + accessRoleType: folderAccessDisplayRole(entry), + inherited: entry.inherited, + hidden: entry.hidden, + canAdd: permission?.canAdd ?? undefined, + canRemove: permission?.canRemove ?? undefined, + canDelete: permission?.canDelete ?? undefined, + canListAccess: permission?.canListAccess ?? undefined, + canUpdateAccess: permission?.canUpdateAccess ?? undefined, + canEditRecords: permission?.canEditRecords ?? undefined, + canViewRecords: permission?.canViewRecords ?? undefined, + canListRecords: permission?.canListRecords ?? undefined, + } +} + +function buildFolderAccessRow( + storage: InMemoryStorage, + folder: DKdFolder, + entry: DKdFolderAccess +): NsfFolderAccessRow { + return { + username: resolveAccessUsername(storage, entry.accessTypeUid, folder), + role: folderAccessDisplayRole(entry), + } +} + +function splitFolderPermissions(storage: InMemoryStorage, folder: DKdFolder) { + const entries = getFolderAccessEntries(storage, folder.uid) + const userPermissions: NsfFolderAccessRow[] = [] + const shareAdmins: NsfFolderAccessRow[] = [] + const teamPermissions: NsfFolderPermission[] = [] + for (const entry of entries) { + if (isFolderUserPermission(entry)) { + userPermissions.push(buildFolderAccessRow(storage, folder, entry)) + } + if (isFolderShareAdministrator(entry)) { + shareAdmins.push(buildFolderAccessRow(storage, folder, entry)) + } + if (entry.accessType === Folder.AccessType.AT_TEAM) { + teamPermissions.push(mapFolderPermission(entry)) + } + } + return { userPermissions, shareAdmins, teamPermissions } +} + +function prependOwnerRow(rows: NsfFolderAccessRow[], ownerUsername: string): NsfFolderAccessRow[] { + if (rows.some((entry) => entry.username === ownerUsername)) return rows + return [{ username: ownerUsername, role: '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), + } +} + +function buildRecordFields(record: DRecord, unmask: boolean): NsfRecordView['fields'] { + return getRecordFields(record) + .filter((field) => !NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) + .map((field) => { + const rawValues = Array.isArray(field.value) ? field.value : [field.value] + const displayValue = formatNsfFieldParts(rawValues).join(', ') + return { field, displayValue } + }) + .filter(({ displayValue }) => displayValue.length > 0) + .map(({ field, displayValue }) => ({ + type: field.type, + label: field.label, + value: !unmask && isSensitiveFieldType(field.type) ? NSF_MASKED_VALUE : displayValue, + })) +} + +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') + lines.push(` Shareable: ${entry.shareable ? 'Yes' : 'No'}`) + lines.push(` Read-Only: ${entry.editable ? 'No' : 'Yes'}`) + return lines +} + +async function fetchRecordPermissions(auth: Auth, recordUid: string): Promise { + try { + const response = await auth.executeRest( + recordDetailsMessage(recordUid, Records.RecordDetailsInclude.SHARE_ONLY) + ) + const detail = response.recordDataWithAccessInfo?.[0] + return (detail?.userPermission ?? []).map((entry) => ({ + username: entry.username || '', + accountUid: bytesToUid(entry.accountUid), + owner: !!entry.owner, + shareAdmin: !!entry.shareAdmin, + shareable: !!entry.sharable, + editable: !!entry.editable, + awaitingApproval: !!entry.awaitingApproval, + expiration: longToNumber(entry.expiration as number | null | undefined), + })) + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}` + ) + } +} + +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 + } +} + +function buildFolderView(storage: InMemoryStorage, folderUid: string): NsfFolderView { + const folder = getKeeperDriveFolder(storage, folderUid) + if (!folder) { + throw new KeeperSdkError(`Nested share folder not found: ${folderUid}`, ResultCodes.NSF_NOT_FOUND) + } + + const split = splitFolderPermissions(storage, folder) + const { userPermissions, shareAdmins } = ensureFolderOwnerListed( + folder, + split.userPermissions, + split.shareAdmins + ) + + return { + objectType: 'folder', + folderUid, + name: folder.data.name || 'Unnamed', + parentUid: normalizeParentUid(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 +): 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, recordUid), + fetchRecordShareAdmins(auth, recordUid), + ]) + const notes = + typeof record.data?.notes === 'string' && record.data.notes.trim() ? record.data.notes.trim() : undefined + + return { + objectType: 'record', + recordUid, + title, + type: getRecordType(record), + revision: record.revision, + version: record.version, + 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 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 = {} +): 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: 'folder', view: buildFolderView(storage, folderUid) } + } + + const recordUid = resolveNsfRecord(storage, trimmed) + if (recordUid) { + const view = await buildRecordView(auth, storage, recordUid, options.unmask ?? false) + return { kind: '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), + '', + NSF_FOLDER_USER_PERMISSIONS_HEADING, + ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), + '', + 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)) + if (view.records.length > 0) { + 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}`) + } + } + 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), + ] + + 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) { + const label = field.label || field.type + lines.push(recordDetailRow(label.charAt(0).toUpperCase() + label.slice(1), field.value)) + } + + if (view.userPermissions.length > 0) { + lines.push('', NSF_RECORD_USER_PERMISSIONS_HEADING) + for (const entry of view.userPermissions) { + lines.push('', ...formatRecordUserPermissionBlock(entry)) + } + } + + if (view.shareAdmins.length > 0) { + lines.push('', `Share Admins (${view.shareAdmins.length}):`) + for (const admin of view.shareAdmins) { + lines.push(` ${admin}`) + } + } + + if (verbose) { + lines.push( + '', + recordDetailRow('Folder', view.folderLocation), + recordDetailRow('Revision', String(view.revision)), + recordDetailRow('Version', String(view.version)) + ) + } + + return lines.join('\n') +} + +export function formatNsfDetail(result: GetNsfResult, verbose = false): string { + return result.kind === 'folder' + ? formatNsfFolderDetail(result.view, verbose) + : formatNsfRecordDetail(result.view, verbose) +} diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts new file mode 100644 index 00000000..d73f6513 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -0,0 +1,77 @@ +export { + ROOT_FOLDER_UID, + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + resolveAccessUsername, + folderAccessDisplayRole, + isNestedShareRecord, + isNestedShareFolder, + ensureNestedShareRecord, + ensureNestedShareFolder, + resolveNsfRecordIdentifier, + resolveNsfFolderIdentifier, + findNestedShareFoldersForRecord, + checkRecordDeletePermission, +} from './nsfHelpers' + +export { + ListNsfFormat, + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, +} from './listNsf' +export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, +} from './listNsf' + +export { + GetNsfFormat, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, +} from './getNsf' +export type { + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, +} from './getNsf' + +export { linkNestedShareRecord } from './linkNsfRecord' +export type { LinkNsfRecordResult } from './linkNsfRecord' + +export { + NsfRemoveOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, +} from './removeNsfRecord' +export type { + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, +} from './removeNsfRecord' + +export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts new file mode 100644 index 00000000..e277ffad --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -0,0 +1,139 @@ +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' +import { + ensureNestedShareFolder, + ensureNestedShareRecord, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' + +function resolveRecordKeyType( + 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, + } + } + 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 +): 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 { 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 +): 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 + 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 +): Promise { + 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) + } + + 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 + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts new file mode 100644 index 00000000..4971753f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -0,0 +1,173 @@ +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { + NsfItemType, + findRecordFolderLocation, + getKeeperDriveFolders, + getKeeperDriveRecords, + getRecordDescription, + normalizeParentUid, +} 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' + +export enum ListNsfFormat { + Table = 'table', + CSV = 'csv', + JSON = 'json', +} + +export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}` + +export type ListNsfOptions = { + folders?: boolean + records?: boolean + format?: ListNsfFormatInput +} + +export type ListNsfRow = { + itemType: NsfItemType + uid: string + title: string + type: string + description: string + parentOrFolder: string +} + +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: normalizeParentUid(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: findRecordFolderLocation(storage, record.uid) || 'root', + })) +} + +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) +} + +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)}...` +} + +export function formatListNsfTable( + 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 } +} + +export function renderListNsfAsciiTable( + 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') +} + +function escapeCsvCell(value: string): string { + 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') +} + +export function formatListNsfJson(rows: ListNsfRow[]): string { + return JSON.stringify( + rows.map((row) => ({ + item_type: row.itemType, + uid: row.uid, + title: row.title, + type: row.type, + description: row.description, + parent_or_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)) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts new file mode 100644 index 00000000..8648a7b0 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -0,0 +1,56 @@ +import { Folder } from '@keeper-security/keeperapi' + +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." + +export const NSF_LEGACY_FOLDER_MSG = + "Folder '{0}' is a legacy folder. Nested Share Folder commands operate only on Nested Share Folders." + +export const NSF_ACCESS_ROLE_LABELS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: 'navigator', + [Folder.AccessRoleType.REQUESTOR]: 'requestor', + [Folder.AccessRoleType.VIEWER]: 'viewer', + [Folder.AccessRoleType.SHARED_MANAGER]: 'shared-manager', + [Folder.AccessRoleType.CONTENT_MANAGER]: 'content-manager', + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: 'content-share-manager', + [Folder.AccessRoleType.MANAGER]: 'manager', + [Folder.AccessRoleType.UNRESOLVED]: 'unresolved', +} + +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', +} + +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_FOLDER_USER_PERMISSIONS_HEADING = ' User Permissions:' +export const NSF_FOLDER_SHARE_ADMINS_HEADING = ' Share Administrators:' +export const NSF_RECORD_USER_PERMISSIONS_HEADING = 'User Permissions:' + +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_REMOVALS = 500 diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts new file mode 100644 index 00000000..646fd074 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -0,0 +1,326 @@ +import type { + DRecord, + DUser, + DKdFolder, + DKdFolderAccess, + DKdFolderRecord, + DKdRecordAccess, +} from '@keeper-security/keeperapi' +import { Folder, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { VaultObjectKind } from '../folders/folderHelpers' +import { KeeperSdkError, ResultCodes } from '../utils' +import { getRecordTitle } from '../records/RecordUtils' +import { + NSF_ACCESS_ROLE_LABELS, + NSF_ACCESS_TYPE_LABELS, + NSF_LEGACY_FOLDER_MSG, + NSF_LEGACY_RECORD_MSG, + NSF_NOTE_FIELD_TYPES, + NSF_RECORD_DESCRIPTION_MAX_LENGTH, + NSF_SENSITIVE_FIELD_TYPES, + ROOT_FOLDER_UID, +} from './nsfConstants' + +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', +} + +export enum NsfItemType { + Folder = 'Folder', + Record = 'Record', +} + +export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string): boolean { + return !!getKeeperDriveRecord(storage, recordUid) +} + +export function isNestedShareFolder(storage: InMemoryStorage, folderUid: string): boolean { + if (!folderUid) return false + if (isRootFolderUid(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 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 +): 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 +} + +function resolveFolderByPath(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim().replace(/^\/+/, '') + if (!trimmed) return ROOT_FOLDER_UID + + const targetPath = `/${trimmed.toLowerCase()}` + for (const folder of getKeeperDriveFolders(storage)) { + if (buildFolderPath(storage, folder.uid).toLowerCase() === targetPath) { + return folder.uid + } + } + 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 anyRecord = storage.getByUid(VaultObjectKind.Record, trimmed) + if (anyRecord) return anyRecord.uid + + return resolveRecordByTitleSearch(storage, trimmed)?.uid +} + +export function resolveNsfFolderIdentifier(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim() + if (!trimmed) return undefined + + if (isRootFolderUid(trimmed) || trimmed.toLowerCase() === 'root') return ROOT_FOLDER_UID + + const byUidOrName = resolveByUidOrName( + getKeeperDriveFolders(storage), + trimmed, + (folder) => folder.uid, + (folder) => folder.data.name || '' + ) + if (byUidOrName) return byUidOrName.uid + + return resolveFolderByPath(storage, trimmed) +} + +export function findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { + return storage + .getAll(KeeperDriveKind.FolderRecord) + .filter((entry) => entry.recordUid === recordUid) + .map((entry) => entry.folderUid) +} + +export function checkRecordDeletePermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid?: Uint8Array +): void { + const entries = storage + .getAll(KeeperDriveKind.RecordAccess) + .filter((entry) => entry.recordUid === recordUid) + if (entries.length === 0) return + + const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' + for (const entry of entries) { + const isCurrentUser = + (entry.accessType === Folder.AccessType.AT_USER && + entry.accessTypeUid === accountUidStr) || + (username && + storage.getAll('user').some( + (user) => + user.username === username && + webSafe64FromBytes(user.accountUid) === entry.accessTypeUid + )) + if (!isCurrentUser) continue + if (entry.owner || entry.canDelete) return + throw new KeeperSdkError( + 'You do not have permission to delete this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to delete this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): string { + if (role == null) return 'unknown' + return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}` +} + +export function formatAccessType(type: Folder.AccessType | null | undefined): string { + if (type == null) return 'unknown' + return NSF_ACCESS_TYPE_LABELS[type] ?? `type-${type}` +} + +export function normalizeParentUid(parentUid: string | undefined | null): string { + const value = (parentUid ?? '').trim() + return !value || value === ROOT_FOLDER_UID ? ROOT_FOLDER_UID : value +} + +export function isRootFolderUid(folderUid: string | undefined | null): boolean { + return normalizeParentUid(folderUid) === ROOT_FOLDER_UID +} + +export function getKeeperDriveFolders(storage: InMemoryStorage): DKdFolder[] { + return storage.getAll(KeeperDriveKind.Folder) +} + +export function getKeeperDriveRecords(storage: InMemoryStorage): DRecord[] { + return storage.getRecords().filter((record) => record.isKeeperDriveData) +} + +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 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(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 buildFolderPath(storage: InMemoryStorage, folderUid: string): string { + if (isRootFolderUid(folderUid)) return '/' + + const segments: string[] = [] + let currentUid: string | undefined = folderUid + const seen = new Set() + + while (currentUid && !isRootFolderUid(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 normalizedFolderUid = normalizeParentUid(folderUid) + const records: DRecord[] = [] + for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { + if (normalizeParentUid(entry.folderUid) !== normalizedFolderUid) 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) + } + } + + 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) +} + +export function resolveAccessUsername( + storage: InMemoryStorage, + accessTypeUid: string, + folder?: DKdFolder +): string { + 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 +} + +export function folderAccessDisplayRole(entry: DKdFolderAccess): string { + if (entry.accessType === Folder.AccessType.AT_OWNER) return '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 + ) +} + +export function isFolderUserPermission(entry: DKdFolderAccess): boolean { + return ( + entry.accessType === Folder.AccessType.AT_USER || + entry.accessType === Folder.AccessType.AT_OWNER + ) +} + diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts new file mode 100644 index 00000000..2a8dc5f4 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -0,0 +1,237 @@ +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 { + checkRecordDeletePermission, + ensureNestedShareRecord, + findNestedShareFoldersForRecord, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { NSF_MAX_REMOVALS } 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', +} + +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.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 hasPreviewErrors(preview: NsfRemovePreviewItem[]): boolean { + return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) +} + +type RemovalSpec = { + recordUid: string + folderUid?: string + operation: FolderProto.v3.remove.RecordOperationType +} + +function buildRemovals( + 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_REMOVALS) { + throw new KeeperSdkError(`Maximum ${NSF_MAX_REMOVALS} 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 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) + checkRecordDeletePermission(storage, recordUid, auth.username, auth.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], + }) + } + return removals +} + +async function executeRemove( + 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, + }) + ) +} + +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}`) + } + } + 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 +): 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) + + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError(formatRemoveNsfPreview(preview) || 'Removal preview failed.', ResultCodes.NSF_REMOVE_FAILED) + } + + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, preview } + } + + 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/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 89424b97..4ba42312 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -137,6 +137,7 @@ export class InMemoryStorage implements VaultStorage { token?: string sharedFolderUid?: string recordUid?: string + folderUid?: string accountUid?: string | Uint8Array teamUid?: string } @@ -157,6 +158,9 @@ export class InMemoryStorage implements VaultStorage { 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/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 24d29f8b..dfa22bee 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,8 +1,13 @@ +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: 'c17.0.0', - DEVICE_NAME: 'JavaScript Keeper SDK', - CONFIG_DIR: '.keeper', - LOG_FORMAT: '!', + 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 = { @@ -49,6 +54,19 @@ export enum RoleErrorCode { RoleEnforcementFailed = 'role_enforcement_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', + MissingKey = 'nsf_missing_key', +} + export enum TeamErrorCode { TeamRequired = 'team_required', TeamNotFound = 'team_not_found', @@ -116,6 +134,16 @@ export const ResultCodes = { 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_MISSING_KEY: NsfErrorCode.MissingKey, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index a7a638f0..cef46a3a 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -8,6 +8,7 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + NsfErrorCode, KEEPER_PUBLIC_HOSTS, } from './constants' export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger } from './Logger' diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 52c3055e..80f6e5c0 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -75,6 +75,11 @@ import { type UpdateRoleResult, } from '../roles' import { UserManager } from '../users/UserManager' +import { NestedShareFolderManager } from '../nestedShareFolders/NestedShareFolderManager' +import type { ListNsfOptions, ListNsfRow, ListNsfFormatInput, FormattedListNsfTable } from '../nestedShareFolders/listNsf' +import type { GetNsfOptions, GetNsfResult } from '../nestedShareFolders/getNsf' +import type { LinkNsfRecordResult } from '../nestedShareFolders/linkNsfRecord' +import type { RemoveNsfRecordInput, RemoveNsfRecordResult } from '../nestedShareFolders/removeNsfRecord' import type { ListUserRow, ListUsersOptions, @@ -142,6 +147,7 @@ export class KeeperVault { private readonly teamManager: TeamManager private readonly roleManager: RoleManager private readonly userManager: UserManager + private readonly nestedShareFolderManager: NestedShareFolderManager constructor(config?: KeeperVaultConfig) { this.config = { @@ -165,6 +171,11 @@ export class KeeperVault { this.teamManager = new TeamManager(authProvider) this.roleManager = new RoleManager(authProvider) this.userManager = new UserManager(authProvider) + this.nestedShareFolderManager = new NestedShareFolderManager(this.storage, authProvider) + } + + public getNestedShareFolderManager(): NestedShareFolderManager { + return this.nestedShareFolderManager } public getFolderManager(): FolderManager { @@ -696,6 +707,50 @@ export class KeeperVault { 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 shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index f53fc689..165e6c7b 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -29,6 +29,10 @@ "roles:add": "ts-node src/roles/addRole.ts", "roles:update": "ts-node src/roles/updateRole.ts", "roles:delete": "ts-node src/roles/deleteRole.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", + "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts new file mode 100644 index 00000000..a997403b --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts @@ -0,0 +1,51 @@ +import { + cleanup, + extractErrorMessage, + GetNsfFormat, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function getNsf() { + const vault = await login() + + try { + const identifier = (await prompt('Record UID, folder UID, or title: ')).trim() + if (!identifier) { + logger.info('No UID or title given.') + return + } + + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const verbose = isYes(await prompt('Verbose permissions? [y/N]: ')) + const unmask = isYes(await prompt('Unmask secrets? [y/N]: ')) + + const restore = suppressLogs() + let result + try { + result = await vault.getNestedShareFolder(identifier, { + format: asJson ? GetNsfFormat.JSON : GetNsfFormat.Detail, + verbose, + unmask, + }) + } finally { + restore() + } + + logger.info('') + const output = asJson ? JSON.stringify(result.view, null, 2) : vault.formatNsfDetail(result, verbose) + process.stdout.write(`${output}\n`) + logger.info('') + } catch (err) { + logger.error(`Lookup failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(getNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts new file mode 100644 index 00000000..896ed6bb --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts @@ -0,0 +1,44 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function linkNsf() { + const vault = await login() + + try { + const recordIdentifier = (await prompt('Record UID or title: ')).trim() + const folderIdentifier = (await prompt('Destination folder UID or name: ')).trim() + if (!recordIdentifier || !folderIdentifier) { + logger.info('Both record and folder are required.') + return + } + + const restore = suppressLogs() + let result + try { + result = await vault.linkNestedShareRecord(recordIdentifier, folderIdentifier) + } finally { + restore() + } + + logger.info('') + logger.info(result.message) + logger.info(`Record: ${result.recordUid}`) + logger.info(`Folder: ${result.folderUid}`) + logger.info(`Status: ${result.status}`) + logger.info('') + } catch (err) { + logger.error(`Link failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(linkNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts new file mode 100644 index 00000000..20896bbc --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts @@ -0,0 +1,69 @@ +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 } from '../utils/format' + +type ListMode = 'all' | 'folders' | 'records' + +const MODE_BY_INPUT: Record = { + '': 'all', + '1': 'all', + '2': 'folders', + '3': 'records', + all: 'all', + folders: 'folders', + records: 'records', +} + +function parseMode(input: string): ListMode { + return MODE_BY_INPUT[input.trim().toLowerCase()] ?? 'all' +} + +async function listNsf() { + const vault = await login() + + try { + logger.info('Show: 1) all 2) folders only 3) records only') + const mode = parseMode(await prompt('Choose [1]: ')) + 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.listNestedShareFolders({ + folders: mode !== 'records', + records: mode !== 'folders', + }) + + if (rows.length === 0) { + logger.info('No nested share folder items found.') + return + } + + const output = vault.formatListNsfOutput(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} item${rows.length === 1 ? '' : 's'}`) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(listNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts new file mode 100644 index 00000000..d4ce1c7d --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts @@ -0,0 +1,119 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfRemoveOperation, + prompt, + suppressLogs, + type RemoveNsfRecordInput, + type RemoveNsfRecordResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveOperation.OwnerTrash, + '1': NsfRemoveOperation.OwnerTrash, + '2': NsfRemoveOperation.FolderTrash, + '3': NsfRemoveOperation.Unlink, + 'owner-trash': NsfRemoveOperation.OwnerTrash, + 'folder-trash': NsfRemoveOperation.FolderTrash, + unlink: NsfRemoveOperation.Unlink, +} + +function parseOperation(input: string): NsfRemoveOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveOperation.OwnerTrash +} + +function printPreview(vault: Awaited>, result: RemoveNsfRecordResult): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(vault.formatRemoveNsfPreview(result.preview)) + logger.info('') +} + +function printPreviewWarnings(result: RemoveNsfRecordResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function removeNestedShareRecords( + vault: Awaited>, + input: RemoveNsfRecordInput +): Promise { + const restore = suppressLogs() + try { + return await vault.removeNestedShareRecords(input) + } finally { + restore() + } +} + +async function removeNsf() { + const vault = await login() + + try { + const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() + const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + logger.info('Operation: 1) owner-trash 2) folder-trash 3) unlink') + const operation = parseOperation(await prompt('Choose [1]: ')) + const folder = + operation === NsfRemoveOperation.Unlink + ? (await prompt('Folder UID or name (required for unlink): ')).trim() + : (await prompt('Folder UID or name (optional): ')).trim() + 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: RemoveNsfRecordInput = { + records, + folder: folder || undefined, + operation, + } + + if (dryRun) { + const result = await removeNestedShareRecords(vault, { ...baseInput, dryRun: true }) + printPreview(vault, result) + logger.info('[Dry-run] No records were removed.') + return + } + + if (force) { + const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) + printPreview(vault, result) + if (result.confirmed && result.message) { + logger.info(result.message) + } + return + } + + const preview = await removeNestedShareRecords(vault, { ...baseInput, force: false }) + printPreview(vault, preview) + printPreviewWarnings(preview) + + if (!isYes(await prompt('Do you want to proceed with deletion? [y/n]: '))) { + logger.info('Removal cancelled.') + return + } + + const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) + if (result.confirmed && result.message) { + logger.info(result.message) + } + } catch (err) { + logger.error(`Remove failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(removeNsf) From 51510ea2891006f8bf47720063e702e97baa45af Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 16 Jun 2026 23:18:10 +0530 Subject: [PATCH 03/48] Extra space remove --- KeeperSdk/src/nestedShareFolders/nsfConstants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index 8648a7b0..9cb0514c 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -37,8 +37,8 @@ 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_FOLDER_USER_PERMISSIONS_HEADING = ' User Permissions:' -export const NSF_FOLDER_SHARE_ADMINS_HEADING = ' Share Administrators:' +export const NSF_FOLDER_USER_PERMISSIONS_HEADING = 'User Permissions:' +export const NSF_FOLDER_SHARE_ADMINS_HEADING = 'Share Administrators:' export const NSF_RECORD_USER_PERMISSIONS_HEADING = 'User Permissions:' export const NSF_LIST_TABLE_HEADERS = ['#', 'Item Type', 'UID', 'Title', 'Type', 'Description'] as const From b5f34af33f17b06acaed77ff6b63e366ebf35d36 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 17 Jun 2026 11:28:59 +0530 Subject: [PATCH 04/48] nsf record initial commit --- KeeperSdk/src/index.ts | 27 ++ .../NestedShareFolderManager.ts | 51 ++++ .../nestedShareFolders/getNsfRecordDetails.ts | 130 +++++++++ KeeperSdk/src/nestedShareFolders/index.ts | 43 +++ KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 213 +++++++++++++++ .../src/nestedShareFolders/nsfConstants.ts | 6 + .../src/nestedShareFolders/nsfHelpers.ts | 142 ++++++++++ .../src/nestedShareFolders/nsfRecordCrypto.ts | 95 +++++++ .../src/nestedShareFolders/removeNsfFolder.ts | 247 ++++++++++++++++++ .../src/nestedShareFolders/updateNsfRecord.ts | 193 ++++++++++++++ KeeperSdk/src/utils/constants.ts | 8 + KeeperSdk/src/vault/KeeperVault.ts | 50 ++++ examples/sdk_example/package.json | 4 + .../get_nsf_record_details.ts | 46 ++++ .../src/nestedShareFolders/mkdir_nsf.ts | 56 ++++ .../src/nestedShareFolders/rmdir_nsf.ts | 131 ++++++++++ .../nestedShareFolders/update_nsf_record.ts | 71 +++++ keeperapi/src/restMessages.ts | 83 +++++- 18 files changed, 1594 insertions(+), 2 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts create mode 100644 KeeperSdk/src/nestedShareFolders/mkdirNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts create mode 100644 KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts create mode 100644 KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 317c7da8..75bdd02f 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -475,6 +475,17 @@ export { NsfRemoveOperation, removeNestedShareRecords, formatRemoveNsfPreview, + mkdirNestedShareFolder, + NSF_FOLDER_COLORS, + NsfRemoveFolderOperation, + removeNestedShareFolders, + formatRemoveNsfFolderPreview, + GetNsfRecordDetailsFormat, + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, + updateNestedShareRecords, + checkRecordEditPermission, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -495,6 +506,22 @@ export type { RemoveNsfRecordInput, NsfRemovePreviewItem, RemoveNsfRecordResult, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, + NsfFolderColor, + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, + UpdateNsfRecordInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, + UpdateNsfRecordFieldMap, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index 4a21c74f..552f28d6 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -28,6 +28,24 @@ import { type RemoveNsfRecordInput, type RemoveNsfRecordResult, } from './removeNsfRecord' +import { mkdirNestedShareFolder, type MkdirNsfInput, type MkdirNsfResult } from './mkdirNsf' +import { + formatRemoveNsfFolderPreview, + removeNestedShareFolders, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from './removeNsfFolder' +import { + getNestedShareRecordDetails, + formatNsfRecordDetailsOutput, + type GetNsfRecordDetailsInput, + type GetNsfRecordDetailsResult, +} from './getNsfRecordDetails' +import { + updateNestedShareRecords, + type UpdateNsfRecordInput, + type UpdateNsfRecordResult, +} from './updateNsfRecord' export type AuthProvider = () => Auth @@ -94,4 +112,37 @@ export class NestedShareFolderManager { public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { return formatRemoveNsfPreview(preview) } + + public async mkdirNestedShareFolder(input: MkdirNsfInput): Promise { + return mkdirNestedShareFolder(this.storage, this.requireAuth(), input) + } + + 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): Promise { + return updateNestedShareRecords(this.storage, this.requireAuth(), input) + } } diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts new file mode 100644 index 00000000..4e7d0b2b --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -0,0 +1,130 @@ +import type { Auth } 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 { + ensureNestedShareRecord, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { decryptRecordTitleAndType } from './nsfRecordCrypto' + +function longToNumber(value: number | { toNumber: () => number } | null | undefined): number { + if (value == null) return 0 + return typeof value === 'number' ? value : value.toNumber() +} + +export enum GetNsfRecordDetailsFormat { + Table = 'table', + JSON = 'json', +} + +export type GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat | `${GetNsfRecordDetailsFormat}` + +export type NsfRecordDetailsItem = { + recordUid: string + title: string + type: string + revision: number + version: number +} + +export type GetNsfRecordDetailsResult = { + data: NsfRecordDetailsItem[] + forbiddenRecords: string[] +} + +export type GetNsfRecordDetailsInput = { + records: string[] + format?: GetNsfRecordDetailsFormatInput +} + +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) + } + + const recordUids: string[] = [] + for (const identifier of identifiers) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + recordUids.push(recordUid) + } + return recordUids +} + +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('') + } + lines.push(`Total records retrieved: ${result.data.length}`) + return lines.join('\n').trimEnd() +} + +export function formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table +): string { + const value = String(format).toLowerCase() + if (value === GetNsfRecordDetailsFormat.JSON || value === 'json') { + return JSON.stringify(result, null, 2) + } + return formatNsfRecordDetailsTable(result) +} + +export async function getNestedShareRecordDetails( + storage: InMemoryStorage, + auth: Auth, + input: GetNsfRecordDetailsInput +): Promise { + const recordUids = resolveRecordUids(storage, input.records) + + 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 recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' + if (!recordUid) continue + const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) + data.push({ + recordUid, + title, + type, + revision: longToNumber(item.revision), + version: item.version ?? 0, + }) + } + + 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 d73f6513..e33fa890 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -19,8 +19,13 @@ export { ensureNestedShareFolder, resolveNsfRecordIdentifier, resolveNsfFolderIdentifier, + resolveNsfFolderUidOrName, findNestedShareFoldersForRecord, checkRecordDeletePermission, + checkRecordEditPermission, + checkFolderDeletePermission, + parseNsfPath, + findExistingChildFolder, } from './nsfHelpers' export { @@ -74,4 +79,42 @@ export type { RemoveNsfRecordResult, } from './removeNsfRecord' +export { mkdirNestedShareFolder } from './mkdirNsf' +export type { MkdirNsfInput, MkdirNsfResult, NsfFolderColorInput } from './mkdirNsf' +export { NSF_FOLDER_COLORS } from './nsfConstants' +export type { NsfFolderColor } from './nsfConstants' + +export { + NsfRemoveFolderOperation, + removeNestedShareFolders, + formatRemoveNsfFolderPreview, +} from './removeNsfFolder' +export type { + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, +} from './removeNsfFolder' + +export { + GetNsfRecordDetailsFormat, + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, +} from './getNsfRecordDetails' +export type { + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, +} from './getNsfRecordDetails' + +export { updateNestedShareRecords } from './updateNsfRecord' +export type { + UpdateNsfRecordInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, + UpdateNsfRecordFieldMap, +} from './updateNsfRecord' + export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts new file mode 100644 index 00000000..4d616786 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -0,0 +1,213 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + folderAddMessage, + 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 { + cacheNewNsfFolder, + findExistingChildFolder, + isNestedShareFolder, + isRootFolderUid, + parseNsfPath, +} from './nsfHelpers' + +export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` + +export type MkdirNsfInput = { + folder: string + color?: NsfFolderColorInput + noInheritPermissions?: boolean + baseFolderUid?: string | null +} + +export type MkdirNsfResult = { + folderUid: string + created: boolean + message?: string +} + +function normalizeColor(color?: NsfFolderColorInput): NsfFolderColor | undefined { + if (!color) return undefined + const value = color as NsfFolderColor + if (!(NSF_FOLDER_COLORS as readonly string[]).includes(value)) { + throw new KeeperSdkError( + `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(', ')}.`, + ResultCodes.NSF_MKDIR_FAILED + ) + } + return value +} + +function resolveBaseFolderUid( + storage: InMemoryStorage, + baseFolderUid: string | null | undefined +): string | null { + if (!baseFolderUid) return null + if (isNestedShareFolder(storage, baseFolderUid)) return baseFolderUid + return null +} + +async function resolveFolderKeyEncryptionKey( + storage: InMemoryStorage, + auth: Auth, + parentUid: string | null +): Promise { + const normalizedParent = parentUid && !isRootFolderUid(parentUid) ? parentUid : null + if (normalizedParent) { + const parentKey = await storage.getKeyBytes(normalizedParent) + if (parentKey) return parentKey + } + if (!auth.dataKey) { + throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) + } + return auth.dataKey +} + +async function prepareFolderData( + storage: InMemoryStorage, + auth: Auth, + folderName: string, + parentUid: string | null, + color: NsfFolderColor | undefined, + inheritPermissions: boolean +): Promise<{ folderUid: string; folderData: Folder.IFolderData }> { + const folderUid = generateUid() + const folderKey = platform.getRandomBytes(32) + await storage.saveKeyBytes(folderUid, folderKey) + + const metadata: { name: string; color?: string } = { name: folderName } + if (color && color !== 'none') metadata.color = color + + const encryptedData = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(metadata)), + folderKey + ) + + const normalizedParent = parentUid && !isRootFolderUid(parentUid) ? parentUid : null + const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, normalizedParent) + const encryptedFolderKey = await platform.aesGcmEncrypt(folderKey, encryptionKey) + + return { + folderUid, + folderData: { + folderUid: normal64Bytes(folderUid), + parentUid: normalizedParent ? normal64Bytes(normalizedParent) : undefined, + data: encryptedData, + folderKey: encryptedFolderKey, + type: Folder.FolderUsageType.UT_NORMAL, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + }, + } +} + +async function createFolderV3( + storage: InMemoryStorage, + auth: Auth, + folderName: string, + parentUid: string | null, + color: NsfFolderColor | undefined, + inheritPermissions: boolean +): Promise<{ folderUid: string; success: boolean; message: string }> { + const { folderUid, folderData } = await prepareFolderData( + storage, + auth, + folderName, + parentUid, + color, + inheritPermissions + ) + + const response = await auth.executeRest(folderAddMessage({ folderData: [folderData] })) + const result = response.folderAddResults?.[0] + if (!result) { + throw new KeeperSdkError('No results from folder creation.', ResultCodes.NSF_MKDIR_FAILED) + } + + const statusName = Folder.FolderModifyStatus[result.status ?? Folder.FolderModifyStatus.SUCCESS] ?? 'UNKNOWN' + const success = result.status === Folder.FolderModifyStatus.SUCCESS + if (!success) { + throw new KeeperSdkError(result.message || `Folder creation failed (${statusName}).`, ResultCodes.NSF_MKDIR_FAILED) + } + + await cacheNewNsfFolder(storage, auth, folderUid, folderName, parentUid, inheritPermissions) + return { + folderUid, + success, + message: result.message || 'Folder created successfully', + } +} + +export async function mkdirNestedShareFolder( + 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 baseFolderUid = resolveBaseFolderUid(storage, input.baseFolderUid) + const segments = parseNsfPath(folderPath) + let parentUid = baseFolderUid + const lastIdx = segments.length - 1 + let createdUid: string | undefined + + 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) { + if (isLeaf) { + return { + folderUid: existingUid, + created: false, + message: `Folder "${segment}" already exists.`, + } + } + parentUid = existingUid + continue + } + + const segColor = isLeaf ? color : undefined + const segInherit = isLeaf ? inheritPermissions : true + const result = await createFolderV3(storage, auth, segment, parentUid, segColor, segInherit) + createdUid = result.folderUid + parentUid = createdUid + } + + if (!createdUid) { + throw new KeeperSdkError('Folder creation did not return a UID.', ResultCodes.NSF_MKDIR_FAILED) + } + + 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 + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index 9cb0514c..e687147a 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -54,3 +54,9 @@ export const NSF_LIST_DEFAULT_COLUMN_WIDTH = 40 export const NSF_LIST_MIN_TRUNCATE_PREFIX = 3 export const NSF_MAX_REMOVALS = 500 +export const NSF_MAX_FOLDER_REMOVALS = 100 + +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] diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 646fd074..622adaf0 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -17,6 +17,7 @@ import { NSF_LEGACY_FOLDER_MSG, NSF_LEGACY_RECORD_MSG, NSF_NOTE_FIELD_TYPES, + NSF_PATH_SENTINEL, NSF_RECORD_DESCRIPTION_MAX_LENGTH, NSF_SENSITIVE_FIELD_TYPES, ROOT_FOLDER_UID, @@ -141,6 +142,112 @@ export function resolveNsfFolderIdentifier(storage: InMemoryStorage, identifier: return resolveFolderByPath(storage, trimmed) } +export function resolveNsfFolderUidOrName(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim() + if (!trimmed) return undefined + + if (getKeeperDriveFolder(storage, trimmed)) return trimmed + + 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 +} + +export function findExistingChildFolder( + storage: InMemoryStorage, + segment: string, + parentUid: string | null | undefined +): string | undefined { + const normalizedParent = normalizeParentUid(parentUid) + const lower = segment.toLowerCase() + for (const folder of getKeeperDriveFolders(storage)) { + const folderParent = normalizeParentUid(folder.parentUid) + const name = folder.data.name || '' + if (folderParent === normalizedParent && name.toLowerCase() === lower) { + return folder.uid + } + } + return undefined +} + +export async function cacheNewNsfFolder( + storage: InMemoryStorage, + auth: { username?: string; accountUid?: Uint8Array }, + folderUid: string, + name: string, + parentUid: string | null | undefined, + inheritPermissions: boolean +): Promise { + const normalizedParent = isRootFolderUid(parentUid) ? undefined : parentUid?.trim() || undefined + 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 +): void { + if (isRootFolderUid(folderUid)) { + throw new KeeperSdkError('The root folder cannot be removed.', ResultCodes.NSF_PERMISSION_DENIED) + } + + const entries = getFolderAccessEntries(storage, folderUid) + if (entries.length === 0) return + + const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' + for (const entry of entries) { + const isCurrentUser = + (entry.accessType === Folder.AccessType.AT_USER && entry.accessTypeUid === accountUidStr) || + (entry.accessType === Folder.AccessType.AT_OWNER && entry.accessTypeUid === accountUidStr) || + (username && + storage.getAll('user').some( + (user) => + user.username === username && + webSafe64FromBytes(user.accountUid) === entry.accessTypeUid + )) + if (!isCurrentUser) 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) @@ -183,6 +290,41 @@ export function checkRecordDeletePermission( ) } +export function checkRecordEditPermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid?: Uint8Array +): void { + const entries = storage + .getAll(KeeperDriveKind.RecordAccess) + .filter((entry) => entry.recordUid === recordUid) + if (entries.length === 0) return + + const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' + for (const entry of entries) { + const isCurrentUser = + (entry.accessType === Folder.AccessType.AT_USER && + entry.accessTypeUid === accountUidStr) || + (username && + storage.getAll('user').some( + (user) => + user.username === username && + webSafe64FromBytes(user.accountUid) === entry.accessTypeUid + )) + if (!isCurrentUser) continue + if (entry.owner || entry.canEdit) return + throw new KeeperSdkError( + '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): string { if (role == null) return 'unknown' return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}` diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts new file mode 100644 index 00000000..8ffb0d0f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts @@ -0,0 +1,95 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Records, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { findNestedShareFoldersForRecord } from './nsfHelpers' + +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 +): 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 { + try { + return await platform.aesCbcDecrypt(encryptedKey, folderKey, true) + } catch { + // try next folder key + } + } + } + return undefined +} + +export async function resolveRecordKeyBytes( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + encryptedKey?: Uint8Array | null, + keyType?: Records.RecordKeyType | null +): Promise { + const cached = await storage.getKeyBytes(recordUid) + if (cached) return cached + + 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 { + // fall through to folder keys + } + + return decryptWithFolderKeys(storage, recordUid, encryptedKey) +} + +export async function decryptRecordTitleAndType( + 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) { + return { title: 'Unknown', type: 'Unknown' } + } + + const encryptedData = decodePayload(recordData.encryptedRecordData) + if (!encryptedData.length) { + return { title: 'Unknown', type: 'Unknown' } + } + + 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', + type: parsed.type?.trim() || 'Unknown', + } + } catch { + return { title: 'Unknown', type: 'Unknown' } + } +} diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts new file mode 100644 index 00000000..21b17b47 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -0,0 +1,247 @@ +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 { + checkFolderDeletePermission, + ensureNestedShareFolder, + getFolderDisplayName, + resolveNsfFolderUidOrName, +} from './nsfHelpers' + +const { RemoveAction, FolderOperationType, RemoveStatus } = folder.v3.remove +const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] + +export enum NsfRemoveFolderOperation { + FolderTrash = 'folder-trash', + DeletePermanent = 'delete-permanent', +} + +export type NsfRemoveFolderOperationInput = NsfRemoveFolderOperation | `${NsfRemoveFolderOperation}` + +export type RemoveNsfFolderInput = { + 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 } +} + +export type RemoveNsfFolderResult = { + confirmed: boolean + dryRun: boolean + operation: NsfRemoveFolderOperation + preview: NsfRemoveFolderPreviewItem[] + message?: string +} + +const OPERATION_MAP: Record = { + [NsfRemoveFolderOperation.FolderTrash]: FolderOperationType.FOLDER_MOVE_TO_FOLDER_TRASH, + [NsfRemoveFolderOperation.DeletePermanent]: FolderOperationType.FOLDER_DELETE_PERMANENT, +} + +function normalizeOperation( + 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 + ) +} + +type RemovalSpec = { + folderUid: string + name: string + operation: FolderProto.v3.remove.FolderOperationType +} + +function buildRemovals( + 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 + ) + } + + const removals: RemovalSpec[] = [] + for (const identifier of folderIdentifiers) { + const folderUid = resolveNsfFolderUidOrName(storage, identifier) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, identifier) + checkFolderDeletePermission(storage, folderUid, auth.username, auth.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, + } +} + +async function executeRemove( + auth: Auth, + removals: RemovalSpec[], + action: FolderProto.v3.remove.RemoveAction, + confirmationToken?: Uint8Array +): Promise { + 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 mapPreview( + removals: RemovalSpec[], + response: FolderProto.v3.remove.IRemoveResponse +): NsfRemoveFolderPreviewItem[] { + return response.results.map((item, index) => mapPreviewItem(removals[index] ?? removals[0], item)) +} + +function hasPreviewErrors(preview: NsfRemoveFolderPreviewItem[]): boolean { + return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) +} + +export function formatRemoveNsfFolderPreview( + preview: NsfRemoveFolderPreviewItem[], + operation: NsfRemoveFolderOperation, + quiet = false +): string { + const action = + operation === NsfRemoveFolderOperation.DeletePermanent ? 'permanently deleted' : 'moved to trash' + 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() +} + +export async function removeNestedShareFolders( + 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 + ) + } + + 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/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts new file mode 100644 index 00000000..8f42c298 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -0,0 +1,193 @@ +import type { Auth, DRecord } from '@keeper-security/keeperapi' +import { + Records, + 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 { + checkRecordEditPermission, + ensureNestedShareRecord, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' + +export type UpdateNsfRecordFieldMap = Record + +export type UpdateNsfRecordInput = { + records: string[] + title?: string + recordType?: string + notes?: string + fields?: UpdateNsfRecordFieldMap +} + +export type UpdateNsfRecordResultItem = { + recordUid: string + success: boolean + status: string + message?: string + revision?: number +} + +export type UpdateNsfRecordResult = { + updated: UpdateNsfRecordResultItem[] +} + +const MIN_RECORD_PAD_BYTES = 384 +const PAD_BLOCK_SIZE = 16 + +function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { + if (value == null) return undefined + return typeof value === 'number' ? value : value.toNumber() +} + +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 platform.stringToBytes(padded) +} + +function normalizeFieldValue(value: string | string[]): string[] { + return Array.isArray(value) ? value : [value] +} + +function mergeRecordData( + existing: Record, + input: Pick +): Record { + const data: Record = { + ...existing, + fields: Array.isArray(existing.fields) ? [...(existing.fields as Record[])] : [], + } + + if (input.title !== undefined) data.title = input.title + if (input.recordType !== undefined) data.type = input.recordType + if (input.notes !== undefined) data.notes = input.notes + + if (input.fields) { + const fields = data.fields as { type?: string; value?: string[] }[] + const byType = new Map() + for (const field of fields) { + const fieldType = field?.type + if (!fieldType) continue + if (!byType.has(fieldType)) byType.set(fieldType, []) + byType.get(fieldType)!.push(field) + } + + for (const [fieldType, value] of Object.entries(input.fields)) { + const normalized = normalizeFieldValue(value) + const matches = byType.get(fieldType) + if (matches?.length) { + matches[0].value = normalized + } else { + fields.push({ type: fieldType, value: normalized }) + } + } + data.fields = fields + } + + return data +} + +function loadExistingRecordData(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: [] } +} + +async function updateSingleRecord( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + input: UpdateNsfRecordInput +): Promise { + const record = 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 merged = mergeRecordData(loadExistingRecordData(storage, recordUid), input) + const encryptedData = await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey) + + const response = await auth.executeRest( + keeperDriveRecordsUpdate({ + records: [ + { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + revision: record?.revision ?? 0, + data: encryptedData, + }, + ], + clientTime: Date.now(), + }) + ) + + const result = response.records?.[0] + const success = result?.status === Records.RecordModifyResult.RS_SUCCESS || result?.status == null + const statusName = + result?.status != null ? Records.RecordModifyResult[result.status] ?? String(result.status) : 'RS_SUCCESS' + + if (!success) { + throw new KeeperSdkError(result?.message || `Record update failed (${statusName}).`, ResultCodes.NSF_UPDATE_FAILED) + } + + if (record) { + await storage.put({ + ...record, + data: merged, + revision: longToNumber(response.revision) ?? record.revision, + clientModifiedTime: Date.now(), + }) + } + + return { + recordUid, + success: true, + status: statusName, + message: result?.message || 'Record updated successfully', + revision: longToNumber(response.revision ?? record?.revision), + } +} + +export async function updateNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordInput +): Promise { + const identifiers = input.records ?? [] + if (identifiers.length === 0) { + throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) + } + + const updated: UpdateNsfRecordResultItem[] = [] + try { + for (const identifier of identifiers) { + 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, auth.accountUid) + updated.push(await updateSingleRecord(storage, auth, recordUid, input)) + } + 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 + ) + } +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index dfa22bee..ece9907f 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -64,7 +64,11 @@ export enum NsfErrorCode { 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', } export enum TeamErrorCode { @@ -143,7 +147,11 @@ export const ResultCodes = { 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, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 80f6e5c0..0766f654 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -80,6 +80,11 @@ import type { ListNsfOptions, ListNsfRow, ListNsfFormatInput, FormattedListNsfTa import type { GetNsfOptions, GetNsfResult } from '../nestedShareFolders/getNsf' import type { LinkNsfRecordResult } from '../nestedShareFolders/linkNsfRecord' import type { RemoveNsfRecordInput, RemoveNsfRecordResult } from '../nestedShareFolders/removeNsfRecord' +import type { MkdirNsfInput, MkdirNsfResult } from '../nestedShareFolders/mkdirNsf' +import type { RemoveNsfFolderInput, RemoveNsfFolderResult } from '../nestedShareFolders/removeNsfFolder' +import type { GetNsfRecordDetailsInput, GetNsfRecordDetailsResult } from '../nestedShareFolders/getNsfRecordDetails' +import type { UpdateNsfRecordInput, UpdateNsfRecordResult } from '../nestedShareFolders/updateNsfRecord' +import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' import type { ListUserRow, ListUsersOptions, @@ -751,6 +756,51 @@ export class KeeperVault { 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 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): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareRecords(input) + if (result.updated.some((item) => item.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() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 165e6c7b..2785e3ea 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -33,6 +33,10 @@ "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", + "nsf:mkdir": "ts-node src/nestedShareFolders/mkdir_nsf.ts", + "nsf:rmdir": "ts-node src/nestedShareFolders/rmdir_nsf.ts", + "nsf:record-details": "ts-node src/nestedShareFolders/get_nsf_record_details.ts", + "nsf:record-update": "ts-node src/nestedShareFolders/update_nsf_record.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts new file mode 100644 index 00000000..61ccb2d2 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts @@ -0,0 +1,46 @@ +import { + cleanup, + extractErrorMessage, + GetNsfRecordDetailsFormat, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function getNsfRecordDetails() { + const vault = await login() + + try { + const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() + const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const formatInput = (await prompt('Output format (table/json) [table]: ')).trim().toLowerCase() + const format = + formatInput === 'json' ? GetNsfRecordDetailsFormat.JSON : GetNsfRecordDetailsFormat.Table + + const restore = suppressLogs() + let result + try { + result = await vault.getNestedShareRecordDetails({ records, format }) + } finally { + restore() + } + + logger.info('') + logger.info(vault.formatNsfRecordDetailsOutput(result, format)) + logger.info('') + } catch (err) { + logger.error(`Record details failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(getNsfRecordDetails) \ No newline at end of file diff --git a/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts new file mode 100644 index 00000000..a4a82898 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts @@ -0,0 +1,56 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_FOLDER_COLORS, + prompt, + suppressLogs, + type MkdirNsfInput, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function mkdirNsf() { + const vault = await login() + + try { + 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 restore = suppressLogs() + let result + try { + result = await vault.mkdirNestedShareFolder({ + folder, + color, + noInheritPermissions: noInherit, + }) + } finally { + restore() + } + + logger.info('') + if (result.message) logger.info(result.message) + logger.info(`Folder UID: ${result.folderUid}`) + logger.info(`Created: ${result.created ? 'Yes' : 'No'}`) + logger.info('') + } catch (err) { + logger.error(`mkdir failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(mkdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts new file mode 100644 index 00000000..19b95260 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts @@ -0,0 +1,131 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfRemoveFolderOperation, + prompt, + suppressLogs, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveFolderOperation.FolderTrash, + '1': NsfRemoveFolderOperation.FolderTrash, + '2': NsfRemoveFolderOperation.DeletePermanent, + 'folder-trash': NsfRemoveFolderOperation.FolderTrash, + 'delete-permanent': NsfRemoveFolderOperation.DeletePermanent, +} + +function parseOperation(input: string): NsfRemoveFolderOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash +} + +function printPreview( + vault: Awaited>, + 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 printPreviewWarnings(result: RemoveNsfFolderResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function removeNestedShareFolders( + vault: Awaited>, + input: RemoveNsfFolderInput +): Promise { + const restore = suppressLogs() + try { + return await vault.removeNestedShareFolders(input) + } finally { + restore() + } +} + +async function rmdirNsf() { + const vault = await login() + + try { + const foldersInput = (await prompt('Folder UID(s) or name(s), comma-separated: ')).trim() + const folders = foldersInput.split(',').map((value) => value.trim()).filter(Boolean) + 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('') + logger.info('*** WARNING ***') + logger.info('delete-permanent is IRREVERSIBLE.') + logger.info('All sub-folders and records inside will be permanently destroyed.') + logger.info('') + } + + const baseInput: RemoveNsfFolderInput = { + folders, + operation, + quiet, + } + + if (dryRun) { + const result = await removeNestedShareFolders(vault, { ...baseInput, dryRun: true }) + printPreview(vault, result, quiet) + logger.info('[Dry-run] No folders were deleted.') + return + } + + if (force) { + const result = await removeNestedShareFolders(vault, { ...baseInput, force: true }) + printPreview(vault, result, quiet) + if (result.confirmed && result.message) { + logger.info(result.message) + } + return + } + + const preview = await removeNestedShareFolders(vault, { ...baseInput, force: false }) + printPreview(vault, preview, quiet) + printPreviewWarnings(preview) + + const promptText = + 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(promptText))) { + logger.info('Removal cancelled.') + return + } + + const result = await removeNestedShareFolders(vault, { ...baseInput, force: true }) + if (result.confirmed && result.message) { + logger.info(result.message) + } + } catch (err) { + logger.error(`rmdir failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rmdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts new file mode 100644 index 00000000..5ea33197 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts @@ -0,0 +1,71 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, + type UpdateNsfRecordFieldMap, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +function parseFieldSpecs(input: string): UpdateNsfRecordFieldMap { + const fields: UpdateNsfRecordFieldMap = {} + for (const spec of input.split(',').map((value) => value.trim()).filter(Boolean)) { + const separator = spec.indexOf('=') + if (separator <= 0) continue + const type = spec.slice(0, separator).trim() + const value = spec.slice(separator + 1).trim() + if (type) fields[type] = value + } + return fields +} + +async function updateNsfRecord() { + const vault = await login() + + try { + const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() + const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const title = (await prompt('New title (optional): ')).trim() + const recordType = (await prompt('Record type (optional): ')).trim() + const notes = (await prompt('Notes (optional): ')).trim() + const fieldsInput = (await prompt('Fields (type=value, comma-separated, optional): ')).trim() + const fields = fieldsInput ? parseFieldSpecs(fieldsInput) : undefined + + const restore = suppressLogs() + let result + try { + result = await vault.updateNestedShareRecords({ + records, + title: title || undefined, + recordType: recordType || undefined, + notes: notes || undefined, + fields, + }) + } finally { + restore() + } + + logger.info('') + for (const item of result.updated) { + logger.info(`Record: ${item.recordUid}`) + logger.info(` Status: ${item.status}`) + if (item.message) logger.info(` Message: ${item.message}`) + if (item.revision != null) logger.info(` Revision: ${item.revision}`) + logger.info('') + } + } catch (err) { + logger.error(`Record update failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(updateNsfRecord) \ No newline at end of file diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 6d9ab14f..bfb8f9ec 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, @@ -472,6 +472,85 @@ 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 { + recordUids: Uint8Array[] + 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) + } + for (const uid of message.recordUids) { + writer.uint32(26).bytes(uid) + } + return writer + }, +} + +const RecordDetailsDataResponse = { + decode(data: Uint8Array): IRecordDetailsDataResponse { + const reader = Reader.create(data) + const response: IRecordDetailsDataResponse = { + data: [], + forbiddenRecords: [], + } + while (reader.pos < reader.len) { + const tag = reader.uint32() + switch (tag >>> 3) { + case 1: + response.data.push(Records.RecordData.decode(reader, reader.uint32())) + break + case 2: + response.forbiddenRecords.push(reader.bytes() as Uint8Array) + break + default: + reader.skipType(tag & 7) + break + } + } + return response + }, +} + +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 recordsAddMessage = ( data: Records.IRecordsAddRequest ): RestMessage => @@ -998,7 +1077,7 @@ export const keeperDriveRecordsAdd = ( export const keeperDriveRecordsUpdate = ( data: Records.IRecordsUpdateRequest ): RestMessage => - createMessage(data, '/vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) + createMessage(data, 'vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) export const getSharingAdminsMessage = ( data: Enterprise.IGetSharingAdminsRequest From 0e76ce5937b6e754716c048a18a4b25434bab0f4 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 17 Jun 2026 23:39:58 +0530 Subject: [PATCH 05/48] Align NSF permission checks with KeeperDrive MRD (canRemove/canDelete) --- KeeperSdk/src/nestedShareFolders/index.ts | 1 + .../src/nestedShareFolders/nsfHelpers.ts | 153 +++++++++++++++--- .../src/nestedShareFolders/removeNsfRecord.ts | 20 ++- 3 files changed, 150 insertions(+), 24 deletions(-) diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index d73f6513..e024b165 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -20,6 +20,7 @@ export { resolveNsfRecordIdentifier, resolveNsfFolderIdentifier, findNestedShareFoldersForRecord, + checkFolderRemovePermission, checkRecordDeletePermission, } from './nsfHelpers' diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 646fd074..f2f2b524 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -148,35 +148,141 @@ export function findNestedShareFoldersForRecord(storage: InMemoryStorage, record .map((entry) => entry.folderUid) } -export function checkRecordDeletePermission( +function toRequiredAccountUidStr(accountUid: Uint8Array): string { + if (!accountUid.length) { + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + } + return webSafe64FromBytes(accountUid) +} + +function isCurrentUserRecordAccess( storage: InMemoryStorage, - recordUid: string, + entry: DKdRecordAccess, username: string, - accountUid?: Uint8Array -): void { - const entries = storage + 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 + )) + ) +} + +function isCurrentUserFolderAccess( + 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 + )) + ) +} + +function isFolderOwnerAccount(storage: InMemoryStorage, folderUid: string, accountUidStr: string): boolean { + if (isRootFolderUid(folderUid)) return true + const folder = getKeeperDriveFolder(storage, folderUid) + return folder?.ownerInfo?.accountUid === accountUidStr +} + +type FolderPermissionFlag = 'canRemove' | 'canDelete' + +function hasFolderPermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array, + permission: FolderPermissionFlag +): boolean { + 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 +} + +function getRecordAccessEntries(storage: InMemoryStorage, recordUid: string): DKdRecordAccess[] { + return storage .getAll(KeeperDriveKind.RecordAccess) .filter((entry) => entry.recordUid === recordUid) - if (entries.length === 0) return +} + +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 - const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' for (const entry of entries) { - const isCurrentUser = - (entry.accessType === Folder.AccessType.AT_USER && - entry.accessTypeUid === accountUidStr) || - (username && - storage.getAll('user').some( - (user) => - user.username === username && - webSafe64FromBytes(user.accountUid) === entry.accessTypeUid - )) - if (!isCurrentUser) continue - if (entry.owner || entry.canDelete) return - throw new KeeperSdkError( - 'You do not have permission to delete this record.', - ResultCodes.NSF_PERMISSION_DENIED - ) + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue + if (entry.owner || entry.canDelete) return true + if ( + folderUid && + !isRootFolderUid(folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') + ) { + return true + } + return false } + + return ( + !!folderUid && + !isRootFolderUid(folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') + ) +} + +export function checkFolderRemovePermission( + storage: InMemoryStorage, + folderUid: string, + recordUid: string, + username: string, + accountUid: Uint8Array +): void { + if (hasFolderPermission(storage, folderUid, username, accountUid, 'canRemove')) return + // Folder-trash and unlink are less destructive than owner-trash. Allow when the user + // can permanently delete the record, or owns it without explicit record-access entries + // (common for records in a personal drive root with no folder-access sync data). + 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 +): 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 @@ -313,7 +419,8 @@ 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.CONTENT_SHARE_MANAGER || + entry.accessRoleType === Folder.AccessRoleType.SHARED_MANAGER ) } diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts index 2a8dc5f4..ae87725b 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -3,9 +3,11 @@ import { folder, normal64Bytes, removeRecordMessage, webSafe64FromBytes } from ' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' import { + checkFolderRemovePermission, checkRecordDeletePermission, ensureNestedShareRecord, findNestedShareFoldersForRecord, + isRootFolderUid, resolveNsfFolderIdentifier, resolveNsfRecordIdentifier, } from './nsfHelpers' @@ -124,6 +126,11 @@ function buildRemovals( 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 removals: RemovalSpec[] = [] for (const identifier of recordIdentifiers) { const recordUid = resolveNsfRecordIdentifier(storage, identifier) @@ -131,7 +138,6 @@ function buildRemovals( throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) } ensureNestedShareRecord(storage, recordUid, identifier) - checkRecordDeletePermission(storage, recordUid, auth.username, auth.accountUid) let ctxFolder = folderUid if (!ctxFolder) { @@ -145,6 +151,18 @@ function buildRemovals( ctxFolder = folders[0] } + if (operation === NsfRemoveOperation.OwnerTrash) { + checkRecordDeletePermission(storage, recordUid, auth.username, accountUid, ctxFolder) + } else { + if (!ctxFolder || isRootFolderUid(ctxFolder)) { + throw new KeeperSdkError( + `Folder context is required for '${operation}' operation.`, + ResultCodes.NSF_FOLDER_REQUIRED + ) + } + checkFolderRemovePermission(storage, ctxFolder, recordUid, auth.username, accountUid) + } + removals.push({ recordUid, folderUid: ctxFolder, From 291651aa889a3ee720e1de5cf946ebd22d36c96c Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 18 Jun 2026 15:52:26 +0530 Subject: [PATCH 06/48] nsf record and folder implementation --- KeeperSdk/src/index.ts | 8 + .../NestedShareFolderManager.ts | 5 + .../src/nestedShareFolders/addNsfRecord.ts | 168 +++++++++++++++++ .../nestedShareFolders/getNsfRecordDetails.ts | 19 +- KeeperSdk/src/nestedShareFolders/index.ts | 11 ++ KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 10 +- .../src/nestedShareFolders/nsfHelpers.ts | 87 ++++++--- .../src/nestedShareFolders/nsfRecordCrypto.ts | 18 +- .../src/nestedShareFolders/nsfRecordData.ts | 131 ++++++++++++++ .../src/nestedShareFolders/removeNsfFolder.ts | 21 ++- .../src/nestedShareFolders/updateNsfRecord.ts | 84 +++------ KeeperSdk/src/utils/constants.ts | 2 + KeeperSdk/src/vault/KeeperVault.ts | 7 + examples/sdk_example/package.json | 6 +- .../src/nestedShareFolders/add_nsf_record.ts | 51 ++++++ .../get_nsf_record_details.ts | 15 +- .../src/nestedShareFolders/mkdir_nsf.ts | 56 ------ .../src/nestedShareFolders/nsf_folder.ts | 169 ++++++++++++++++++ .../src/nestedShareFolders/rmdir_nsf.ts | 131 -------------- .../nestedShareFolders/update_nsf_record.ts | 35 ++-- examples/sdk_example/src/utils/format.ts | 14 ++ keeperapi/src/restMessages.ts | 2 +- 22 files changed, 706 insertions(+), 344 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/addNsfRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordData.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts delete mode 100644 examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/nsf_folder.ts delete mode 100644 examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 75bdd02f..f4dd449e 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -485,6 +485,9 @@ export { formatNsfRecordDetailsTable, formatNsfRecordDetailsOutput, updateNestedShareRecords, + addNestedShareRecord, + buildNsfRecordData, + parseNsfFieldStrings, checkRecordEditPermission, NestedShareFolderManager, } from './nestedShareFolders' @@ -522,6 +525,11 @@ export type { UpdateNsfRecordResult, UpdateNsfRecordResultItem, UpdateNsfRecordFieldMap, + AddNsfRecordInput, + AddNsfRecordResult, + NsfRecordFieldMap, + NsfRecordCustomField, + ParsedNsfFieldStrings, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index 552f28d6..d98b5c54 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -46,6 +46,7 @@ import { type UpdateNsfRecordInput, type UpdateNsfRecordResult, } from './updateNsfRecord' +import { addNestedShareRecord, type AddNsfRecordInput, type AddNsfRecordResult } from './addNsfRecord' export type AuthProvider = () => Auth @@ -145,4 +146,8 @@ export class NestedShareFolderManager { public async updateNestedShareRecords(input: UpdateNsfRecordInput): Promise { return updateNestedShareRecords(this.storage, this.requireAuth(), input) } + + public async addNestedShareRecord(input: AddNsfRecordInput): Promise { + return addNestedShareRecord(this.storage, this.requireAuth(), input) + } } diff --git a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts new file mode 100644 index 00000000..d5e0f6bb --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts @@ -0,0 +1,168 @@ +import type { Auth, record as RecordProto } from '@keeper-security/keeperapi' +import { + Folder, + Records, + generateUid, + keeperDriveRecordsAdd, + normal64Bytes, + platform, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + buildNsfRecordData, + getPaddedJsonBytes, + type NsfRecordCustomField, + type NsfRecordFieldMap, +} from './nsfRecordData' +import { + ensureNestedShareFolder, + nsfToNumber, + resolveNsfFolderIdentifier, +} from './nsfHelpers' + +const RECORD_KEY_BYTE_LENGTH = 32 + +export type { NsfRecordFieldMap, NsfRecordCustomField } from './nsfRecordData' + +export type AddNsfRecordInput = { + title: string + recordType: string + folder?: string + notes?: string + fields?: NsfRecordFieldMap + custom?: NsfRecordCustomField[] + recordData?: Record + force?: boolean + hasFileFields?: boolean +} + +export type AddNsfRecordResult = { + recordUid: string + success: boolean + status: string + message?: string + revision?: number +} + +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 resolveFolderKey(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 buildRecordAdd( + storage: InMemoryStorage, + auth: Auth, + recordData: Record, + folderUid?: string +): Promise<{ recordUid: string; recordAdd: RecordProto.v3.IRecordAdd }> { + const recordUid = generateUid() + const recordKey = platform.getRandomBytes(RECORD_KEY_BYTE_LENGTH) + 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 resolveFolderKey(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 { + if (!auth.dataKey) { + throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) + } + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, auth.dataKey) + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm + } + + return { recordUid, recordAdd } +} + +export async function addNestedShareRecord( + storage: InMemoryStorage, + auth: Auth, + input: AddNsfRecordInput +): Promise { + 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 + ) + } + + const recordData = + input.recordData ?? + buildNsfRecordData({ + title: input.title, + recordType: input.recordType, + notes: input.notes, + fields: input.fields, + custom: input.custom, + }) + + const folderUid = resolveFolderUid(storage, input.folder) + + try { + const { recordUid, recordAdd } = await buildRecordAdd(storage, auth, recordData, folderUid) + const response = await auth.executeRest( + keeperDriveRecordsAdd({ + records: [recordAdd], + clientTime: Date.now(), + }) + ) + + const result = response.records?.[0] + if (!result) { + throw new KeeperSdkError('No results from record creation.', ResultCodes.NSF_ADD_FAILED) + } + + const success = result.status === Records.RecordModifyResult.RS_SUCCESS || result.status == null + const statusName = + result.status != null ? Records.RecordModifyResult[result.status] ?? String(result.status) : 'RS_SUCCESS' + + if (!success) { + throw new KeeperSdkError(result.message || `Record creation failed (${statusName}).`, ResultCodes.NSF_ADD_FAILED) + } + + return { + recordUid, + success: true, + status: statusName, + message: result.message || 'Record created successfully', + revision: nsfToNumber(response.revision), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to add nested share record: ${extractErrorMessage(err)}`, + ResultCodes.NSF_ADD_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts index 4e7d0b2b..25ef429f 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -2,16 +2,8 @@ import type { Auth } 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 { - ensureNestedShareRecord, - resolveNsfRecordIdentifier, -} from './nsfHelpers' import { decryptRecordTitleAndType } from './nsfRecordCrypto' - -function longToNumber(value: number | { toNumber: () => number } | null | undefined): number { - if (value == null) return 0 - return typeof value === 'number' ? value : value.toNumber() -} +import { ensureNestedShareRecord, nsfToNumber, resolveNsfRecordIdentifier } from './nsfHelpers' export enum GetNsfRecordDetailsFormat { Table = 'table', @@ -80,8 +72,7 @@ export function formatNsfRecordDetailsOutput( result: GetNsfRecordDetailsResult, format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table ): string { - const value = String(format).toLowerCase() - if (value === GetNsfRecordDetailsFormat.JSON || value === 'json') { + if (String(format).toLowerCase() === GetNsfRecordDetailsFormat.JSON) { return JSON.stringify(result, null, 2) } return formatNsfRecordDetailsTable(result) @@ -103,7 +94,7 @@ export async function getNestedShareRecordDetails( ) const data: NsfRecordDetailsItem[] = [] - for (const item of response.data) { + for (const item of response.data ?? []) { const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' if (!recordUid) continue const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) @@ -111,14 +102,14 @@ export async function getNestedShareRecordDetails( recordUid, title, type, - revision: longToNumber(item.revision), + revision: nsfToNumber(item.revision, 0) ?? 0, version: item.version ?? 0, }) } return { data, - forbiddenRecords: response.forbiddenRecords.map((uid) => webSafe64FromBytes(uid)), + forbiddenRecords: (response.forbiddenRecords ?? []).map((uid) => webSafe64FromBytes(uid)), } } catch (err) { if (err instanceof KeeperSdkError) throw err diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index ccc024cd..48c9d683 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -118,4 +118,15 @@ export type { UpdateNsfRecordFieldMap, } from './updateNsfRecord' +export { addNestedShareRecord } from './addNsfRecord' +export type { AddNsfRecordInput, AddNsfRecordResult } from './addNsfRecord' + +export { + buildNsfRecordData, + parseNsfFieldStrings, + type NsfRecordFieldMap, + type NsfRecordCustomField, + type ParsedNsfFieldStrings, +} from './nsfRecordData' + export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts index 4d616786..08fb4d98 100644 --- a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -20,6 +20,8 @@ import { parseNsfPath, } from './nsfHelpers' +const FOLDER_KEY_BYTE_LENGTH = 32 + export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` export type MkdirNsfInput = { @@ -81,7 +83,7 @@ async function prepareFolderData( inheritPermissions: boolean ): Promise<{ folderUid: string; folderData: Folder.IFolderData }> { const folderUid = generateUid() - const folderKey = platform.getRandomBytes(32) + const folderKey = platform.getRandomBytes(FOLDER_KEY_BYTE_LENGTH) await storage.saveKeyBytes(folderUid, folderKey) const metadata: { name: string; color?: string } = { name: folderName } @@ -118,7 +120,7 @@ async function createFolderV3( parentUid: string | null, color: NsfFolderColor | undefined, inheritPermissions: boolean -): Promise<{ folderUid: string; success: boolean; message: string }> { +): Promise<{ folderUid: string; message: string }> { const { folderUid, folderData } = await prepareFolderData( storage, auth, @@ -135,15 +137,13 @@ async function createFolderV3( } const statusName = Folder.FolderModifyStatus[result.status ?? Folder.FolderModifyStatus.SUCCESS] ?? 'UNKNOWN' - const success = result.status === Folder.FolderModifyStatus.SUCCESS - if (!success) { + if (result.status !== Folder.FolderModifyStatus.SUCCESS) { throw new KeeperSdkError(result.message || `Folder creation failed (${statusName}).`, ResultCodes.NSF_MKDIR_FAILED) } await cacheNewNsfFolder(storage, auth, folderUid, folderName, parentUid, inheritPermissions) return { folderUid, - success, message: result.message || 'Folder created successfully', } } diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index c5f97e2e..2ffbf9d7 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -37,6 +37,14 @@ export enum NsfItemType { Record = 'Record', } +export function nsfToNumber( + value: number | { toNumber: () => number } | null | undefined, + fallback?: number +): number | undefined { + if (value == null) return fallback + return typeof value === 'number' ? value : value.toNumber() +} + export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string): boolean { return !!getKeeperDriveRecord(storage, recordUid) } @@ -169,18 +177,62 @@ export function parseNsfPath(folderPath: string): string[] { return segments } +function getKnownKeeperDriveFolderUids(storage: InMemoryStorage): Set { + return new Set(getKeeperDriveFolders(storage).map((folder) => folder.uid)) +} + +function isVirtualDriveRootParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { + const trimmed = parentUid?.trim() + if (!trimmed || isRootFolderUid(trimmed)) return true + return !getKnownKeeperDriveFolderUids(storage).has(trimmed) +} + +function isRootLevelParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { + return isVirtualDriveRootParent(storage, parentUid) +} + +function folderParentsMatch( + storage: InMemoryStorage, + folderParentUid: string | undefined, + searchParentUid: string | null | undefined +): boolean { + if (normalizeParentUid(folderParentUid) === normalizeParentUid(searchParentUid)) return true + return isRootLevelParent(storage, searchParentUid) && isRootLevelParent(storage, folderParentUid) +} + export function findExistingChildFolder( storage: InMemoryStorage, segment: string, parentUid: string | null | undefined ): string | undefined { - const normalizedParent = normalizeParentUid(parentUid) const lower = segment.toLowerCase() + const exactMatches: string[] = [] + const rootMatches: string[] = [] + for (const folder of getKeeperDriveFolders(storage)) { - const folderParent = normalizeParentUid(folder.parentUid) const name = folder.data.name || '' - if (folderParent === normalizedParent && name.toLowerCase() === lower) { - return folder.uid + if (name.toLowerCase() !== lower) continue + + if (normalizeParentUid(folder.parentUid) === normalizeParentUid(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 +} + +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) && !isRootFolderUid(parentUid)) { + return parentUid } } return undefined @@ -194,7 +246,10 @@ export async function cacheNewNsfFolder( parentUid: string | null | undefined, inheritPermissions: boolean ): Promise { - const normalizedParent = isRootFolderUid(parentUid) ? undefined : parentUid?.trim() || undefined + const normalizedParent = + parentUid && !isRootFolderUid(parentUid) + ? parentUid.trim() + : resolveKeeperDriveRootParentUid(storage) await storage.put({ kind: 'keeper_drive_folder', uid: folderUid, @@ -372,9 +427,6 @@ export function checkFolderRemovePermission( accountUid: Uint8Array ): void { if (hasFolderPermission(storage, folderUid, username, accountUid, 'canRemove')) return - // Folder-trash and unlink are less destructive than owner-trash. Allow when the user - // can permanently delete the record, or owns it without explicit record-access entries - // (common for records in a personal drive root with no folder-access sync data). if (canRecordBeDeleted(storage, recordUid, username, accountUid, folderUid)) return throw new KeeperSdkError( 'You do not have permission to remove records from this folder.', @@ -400,25 +452,14 @@ export function checkRecordEditPermission( storage: InMemoryStorage, recordUid: string, username: string, - accountUid?: Uint8Array + accountUid: Uint8Array ): void { - const entries = storage - .getAll(KeeperDriveKind.RecordAccess) - .filter((entry) => entry.recordUid === recordUid) + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getRecordAccessEntries(storage, recordUid) if (entries.length === 0) return - const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' for (const entry of entries) { - const isCurrentUser = - (entry.accessType === Folder.AccessType.AT_USER && - entry.accessTypeUid === accountUidStr) || - (username && - storage.getAll('user').some( - (user) => - user.username === username && - webSafe64FromBytes(user.accountUid) === entry.accessTypeUid - )) - if (!isCurrentUser) continue + 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.', diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts index 8ffb0d0f..1a76c12d 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts @@ -3,6 +3,8 @@ import { Records, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-se import type { InMemoryStorage } from '../storage/InMemoryStorage' import { findNestedShareFoldersForRecord } from './nsfHelpers' +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 @@ -23,7 +25,7 @@ async function decryptWithFolderKeys( try { return await platform.aesCbcDecrypt(encryptedKey, folderKey, true) } catch { - // try next folder key + continue } } } @@ -50,10 +52,8 @@ export async function resolveRecordKeyBytes( } return await platform.aesGcmDecrypt(encryptedKey, auth.dataKey) } catch { - // fall through to folder keys + return decryptWithFolderKeys(storage, recordUid, encryptedKey) } - - return decryptWithFolderKeys(storage, recordUid, encryptedKey) } export async function decryptRecordTitleAndType( @@ -71,12 +71,12 @@ export async function decryptRecordTitleAndType( recordData.recordKeyType ) if (!recordKey) { - return { title: 'Unknown', type: 'Unknown' } + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } } const encryptedData = decodePayload(recordData.encryptedRecordData) if (!encryptedData.length) { - return { title: 'Unknown', type: 'Unknown' } + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } } try { @@ -86,10 +86,10 @@ export async function decryptRecordTitleAndType( type?: string } return { - title: parsed.title?.trim() || 'Unknown', - type: parsed.type?.trim() || 'Unknown', + title: parsed.title?.trim() || UNKNOWN_RECORD_LABEL, + type: parsed.type?.trim() || UNKNOWN_RECORD_LABEL, } } catch { - return { title: 'Unknown', type: 'Unknown' } + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } } } diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts new file mode 100644 index 00000000..56dc7b3e --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts @@ -0,0 +1,131 @@ +import { platform } from '@keeper-security/keeperapi' + +const MIN_RECORD_PAD_BYTES = 384 +const PAD_BLOCK_SIZE = 16 +const LEGACY_RECORD_TYPES = new Set(['legacy', 'general']) + +export type NsfRecordFieldMap = Record + +export type NsfRecordCustomField = { + type: string + label?: string + value: string | string[] +} + +export type BuildNsfRecordDataInput = { + title: string + recordType: string + notes?: string + fields?: NsfRecordFieldMap + custom?: NsfRecordCustomField[] +} + +export function normalizeNsfFieldValue(value: string | string[]): string[] { + return Array.isArray(value) ? value : [value] +} + +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, ' ')) +} + +function fieldsMapToArray(fields: NsfRecordFieldMap): { type: string; value: string[] }[] { + return Object.entries(fields).map(([type, value]) => ({ + type, + value: normalizeNsfFieldValue(value), + })) +} + +export function buildNsfRecordData(input: BuildNsfRecordDataInput): Record { + const title = input.title.trim() + const recordType = input.recordType.trim() + const data: Record = { + type: LEGACY_RECORD_TYPES.has(recordType) ? 'login' : recordType, + title, + fields: input.fields ? fieldsMapToArray(input.fields) : [], + custom: (input.custom ?? []).map((field) => ({ + type: field.type, + label: field.label ?? '', + value: normalizeNsfFieldValue(field.value), + })), + } + const notes = input.notes?.trim() + if (notes) data.notes = notes + return data +} + +export function mergeNsfRecordData( + existing: Record, + input: Partial +): Record { + const data: Record = { + ...existing, + fields: Array.isArray(existing.fields) ? [...(existing.fields as Record[])] : [], + } + + if (input.title !== undefined) data.title = input.title.trim() + if (input.recordType !== undefined) { + const recordType = input.recordType.trim() + data.type = LEGACY_RECORD_TYPES.has(recordType) ? 'login' : recordType + } + if (input.notes !== undefined) data.notes = input.notes + + if (input.fields) { + const fields = data.fields as { type?: string; value?: string[] }[] + const byType = new Map() + for (const field of fields) { + const fieldType = field?.type + if (!fieldType) continue + if (!byType.has(fieldType)) byType.set(fieldType, []) + byType.get(fieldType)!.push(field) + } + for (const [fieldType, value] of Object.entries(input.fields)) { + const normalized = normalizeNsfFieldValue(value) + const matches = byType.get(fieldType) + if (matches?.length) matches[0].value = normalized + else fields.push({ type: fieldType, value: normalized }) + } + data.fields = fields + } + + return data +} + +export type ParsedNsfFieldStrings = { + fields: NsfRecordFieldMap + custom: NsfRecordCustomField[] + hasFileFields: boolean +} + +export function parseNsfFieldStrings(rawFields: string[]): ParsedNsfFieldStrings { + const fields: NsfRecordFieldMap = {} + const custom: NsfRecordCustomField[] = [] + let hasFileFields = false + + for (const raw of rawFields) { + const field = raw.trim() + if (!field) continue + const separator = field.indexOf('=') + if (separator <= 0) continue + + const key = field.slice(0, separator).trim() + const value = field.slice(separator + 1).trim() + if (!key) continue + + if (key.toLowerCase() === 'file') { + hasFileFields = true + continue + } + + const labeled = key.match(/^"(.+)"$/) + if (labeled) { + custom.push({ type: 'text', label: labeled[1], value }) + continue + } + + fields[key] = value + } + + return { fields, custom, hasFileFields } +} diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts index 21b17b47..19878b37 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -12,6 +12,8 @@ import { 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' export enum NsfRemoveFolderOperation { FolderTrash = 'folder-trash', @@ -88,6 +90,11 @@ function buildRemovals( ) } + 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 folderIdentifiers) { const folderUid = resolveNsfFolderUidOrName(storage, identifier) @@ -95,7 +102,7 @@ function buildRemovals( throw new KeeperSdkError(`Folder '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) } ensureNestedShareFolder(storage, folderUid, identifier) - checkFolderDeletePermission(storage, folderUid, auth.username, auth.accountUid) + checkFolderDeletePermission(storage, folderUid, auth.username, accountUid) removals.push({ folderUid, name: getFolderDisplayName(storage, folderUid), @@ -154,7 +161,13 @@ function mapPreview( removals: RemovalSpec[], response: FolderProto.v3.remove.IRemoveResponse ): NsfRemoveFolderPreviewItem[] { - return response.results.map((item, index) => mapPreviewItem(removals[index] ?? removals[0], 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 { @@ -167,7 +180,9 @@ export function formatRemoveNsfFolderPreview( quiet = false ): string { const action = - operation === NsfRemoveFolderOperation.DeletePermanent ? 'permanently deleted' : 'moved to trash' + operation === NsfRemoveFolderOperation.DeletePermanent + ? PERMANENT_DELETE_ACTION_LABEL + : TRASH_ACTION_LABEL const lines: string[] = [] for (const item of preview) { diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts index 8f42c298..cdc0a5c0 100644 --- a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -8,21 +8,23 @@ import { import type { InMemoryStorage } from '../storage/InMemoryStorage' import { VaultObjectKind } from '../folders/folderHelpers' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import { getPaddedJsonBytes, mergeNsfRecordData, type NsfRecordFieldMap } from './nsfRecordData' import { checkRecordEditPermission, ensureNestedShareRecord, + nsfToNumber, resolveNsfRecordIdentifier, } from './nsfHelpers' -import { resolveRecordKeyBytes } from './nsfRecordCrypto' -export type UpdateNsfRecordFieldMap = Record +export type { NsfRecordFieldMap as UpdateNsfRecordFieldMap } from './nsfRecordData' export type UpdateNsfRecordInput = { records: string[] title?: string recordType?: string notes?: string - fields?: UpdateNsfRecordFieldMap + fields?: NsfRecordFieldMap } export type UpdateNsfRecordResultItem = { @@ -37,63 +39,6 @@ export type UpdateNsfRecordResult = { updated: UpdateNsfRecordResultItem[] } -const MIN_RECORD_PAD_BYTES = 384 -const PAD_BLOCK_SIZE = 16 - -function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { - if (value == null) return undefined - return typeof value === 'number' ? value : value.toNumber() -} - -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 platform.stringToBytes(padded) -} - -function normalizeFieldValue(value: string | string[]): string[] { - return Array.isArray(value) ? value : [value] -} - -function mergeRecordData( - existing: Record, - input: Pick -): Record { - const data: Record = { - ...existing, - fields: Array.isArray(existing.fields) ? [...(existing.fields as Record[])] : [], - } - - if (input.title !== undefined) data.title = input.title - if (input.recordType !== undefined) data.type = input.recordType - if (input.notes !== undefined) data.notes = input.notes - - if (input.fields) { - const fields = data.fields as { type?: string; value?: string[] }[] - const byType = new Map() - for (const field of fields) { - const fieldType = field?.type - if (!fieldType) continue - if (!byType.has(fieldType)) byType.set(fieldType, []) - byType.get(fieldType)!.push(field) - } - - for (const [fieldType, value] of Object.entries(input.fields)) { - const normalized = normalizeFieldValue(value) - const matches = byType.get(fieldType) - if (matches?.length) { - matches[0].value = normalized - } else { - fields.push({ type: fieldType, value: normalized }) - } - } - data.fields = fields - } - - return data -} - function loadExistingRecordData(storage: InMemoryStorage, recordUid: string): Record { const record = storage.getByUid(VaultObjectKind.Record, recordUid) if (record?.data && typeof record.data === 'object') { @@ -102,6 +47,14 @@ function loadExistingRecordData(storage: InMemoryStorage, recordUid: string): Re return { fields: [] } } +function requireAccountUid(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 +} + async function updateSingleRecord( storage: InMemoryStorage, auth: Auth, @@ -117,7 +70,7 @@ async function updateSingleRecord( ) } - const merged = mergeRecordData(loadExistingRecordData(storage, recordUid), input) + const merged = mergeNsfRecordData(loadExistingRecordData(storage, recordUid), input) const encryptedData = await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey) const response = await auth.executeRest( @@ -143,11 +96,12 @@ async function updateSingleRecord( throw new KeeperSdkError(result?.message || `Record update failed (${statusName}).`, ResultCodes.NSF_UPDATE_FAILED) } + const revision = nsfToNumber(response.revision) ?? record?.revision if (record) { await storage.put({ ...record, data: merged, - revision: longToNumber(response.revision) ?? record.revision, + revision: revision ?? record.revision, clientModifiedTime: Date.now(), }) } @@ -157,7 +111,7 @@ async function updateSingleRecord( success: true, status: statusName, message: result?.message || 'Record updated successfully', - revision: longToNumber(response.revision ?? record?.revision), + revision, } } @@ -171,7 +125,9 @@ export async function updateNestedShareRecords( throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) } + const accountUid = requireAccountUid(auth) const updated: UpdateNsfRecordResultItem[] = [] + try { for (const identifier of identifiers) { const recordUid = resolveNsfRecordIdentifier(storage, identifier) @@ -179,7 +135,7 @@ export async function updateNestedShareRecords( throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) } ensureNestedShareRecord(storage, recordUid, identifier) - checkRecordEditPermission(storage, recordUid, auth.username, auth.accountUid) + checkRecordEditPermission(storage, recordUid, auth.username, accountUid) updated.push(await updateSingleRecord(storage, auth, recordUid, input)) } return { updated } diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index ece9907f..f67696b5 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -69,6 +69,7 @@ export enum NsfErrorCode { MkdirFailed = 'nsf_mkdir_failed', UpdateFailed = 'nsf_update_failed', DetailsFailed = 'nsf_details_failed', + AddFailed = 'nsf_add_failed', } export enum TeamErrorCode { @@ -152,6 +153,7 @@ export const ResultCodes = { 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, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 0766f654..363236ea 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -84,6 +84,7 @@ import type { MkdirNsfInput, MkdirNsfResult } from '../nestedShareFolders/mkdirN import type { RemoveNsfFolderInput, RemoveNsfFolderResult } from '../nestedShareFolders/removeNsfFolder' import type { GetNsfRecordDetailsInput, GetNsfRecordDetailsResult } from '../nestedShareFolders/getNsfRecordDetails' import type { UpdateNsfRecordInput, UpdateNsfRecordResult } from '../nestedShareFolders/updateNsfRecord' +import type { AddNsfRecordInput, AddNsfRecordResult } from '../nestedShareFolders/addNsfRecord' import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' import type { ListUserRow, @@ -801,6 +802,12 @@ export class KeeperVault { 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() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 2785e3ea..a84d3b35 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -33,10 +33,12 @@ "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", - "nsf:mkdir": "ts-node src/nestedShareFolders/mkdir_nsf.ts", - "nsf:rmdir": "ts-node src/nestedShareFolders/rmdir_nsf.ts", + "nsf:folder": "ts-node src/nestedShareFolders/nsf_folder.ts", + "nsf:mkdir": "ts-node src/nestedShareFolders/nsf_folder.ts", + "nsf:rmdir": "ts-node src/nestedShareFolders/nsf_folder.ts", "nsf:record-details": "ts-node src/nestedShareFolders/get_nsf_record_details.ts", "nsf:record-update": "ts-node src/nestedShareFolders/update_nsf_record.ts", + "nsf:record-add": "ts-node src/nestedShareFolders/add_nsf_record.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts new file mode 100644 index 00000000..c3193558 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts @@ -0,0 +1,51 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + parseNsfFieldStrings, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, withSuppressedLogs } from '../utils/format' + +async function addNsfRecord() { + const vault = await login() + + try { + const title = (await prompt('Record title: ')).trim() + const recordType = (await prompt('Record type (e.g. login, general): ')).trim() + const folder = (await prompt('Folder name or UID (optional, Enter for root): ')).trim() + const notes = (await prompt('Notes (optional): ')).trim() + const fieldsInput = (await prompt('Fields (field=value, space-separated, optional): ')).trim() + const force = isYes(await prompt('Ignore warnings (e.g. attachments)? [y/N]: ')) + + const parsed = fieldsInput ? parseNsfFieldStrings(fieldsInput.split(/\s+/)) : undefined + const result = await withSuppressedLogs(() => + vault.addNestedShareRecord({ + title, + recordType, + folder: folder || undefined, + notes: notes || undefined, + fields: parsed?.fields, + custom: parsed?.custom, + hasFileFields: parsed?.hasFileFields, + force, + }) + ) + + logger.info('') + logger.info(`Record UID: ${result.recordUid}`) + logger.info(`Status: ${result.status}`) + if (result.message) logger.info(`Message: ${result.message}`) + if (result.revision != null) logger.info(`Revision: ${result.revision}`) + logger.info('') + } catch (err) { + logger.error(`Record add failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(addNsfRecord) diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts index 61ccb2d2..57d275d7 100644 --- a/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts @@ -5,16 +5,15 @@ import { login, logger, prompt, - suppressLogs, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' async function getNsfRecordDetails() { const vault = await login() try { - const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() - const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + 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 @@ -24,13 +23,7 @@ async function getNsfRecordDetails() { const format = formatInput === 'json' ? GetNsfRecordDetailsFormat.JSON : GetNsfRecordDetailsFormat.Table - const restore = suppressLogs() - let result - try { - result = await vault.getNestedShareRecordDetails({ records, format }) - } finally { - restore() - } + const result = await withSuppressedLogs(() => vault.getNestedShareRecordDetails({ records, format })) logger.info('') logger.info(vault.formatNsfRecordDetailsOutput(result, format)) @@ -43,4 +36,4 @@ async function getNsfRecordDetails() { } } -runExample(getNsfRecordDetails) \ No newline at end of file +runExample(getNsfRecordDetails) diff --git a/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts deleted file mode 100644 index a4a82898..00000000 --- a/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - cleanup, - extractErrorMessage, - login, - logger, - NSF_FOLDER_COLORS, - prompt, - suppressLogs, - type MkdirNsfInput, -} from '@keeper-security/keeper-sdk-javascript' -import { runExample } from '../utils/runner' - -async function mkdirNsf() { - const vault = await login() - - try { - 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 restore = suppressLogs() - let result - try { - result = await vault.mkdirNestedShareFolder({ - folder, - color, - noInheritPermissions: noInherit, - }) - } finally { - restore() - } - - logger.info('') - if (result.message) logger.info(result.message) - logger.info(`Folder UID: ${result.folderUid}`) - logger.info(`Created: ${result.created ? 'Yes' : 'No'}`) - logger.info('') - } catch (err) { - logger.error(`mkdir failed: ${extractErrorMessage(err)}`) - process.exitCode = 1 - } finally { - cleanup(vault) - } -} - -runExample(mkdirNsf) 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/rmdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts deleted file mode 100644 index 19b95260..00000000 --- a/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - cleanup, - extractErrorMessage, - login, - logger, - NsfRemoveFolderOperation, - prompt, - suppressLogs, - type RemoveNsfFolderInput, - type RemoveNsfFolderResult, -} from '@keeper-security/keeper-sdk-javascript' -import { runExample } from '../utils/runner' -import { isYes } from '../utils/format' - -const OPERATION_BY_INPUT: Record = { - '': NsfRemoveFolderOperation.FolderTrash, - '1': NsfRemoveFolderOperation.FolderTrash, - '2': NsfRemoveFolderOperation.DeletePermanent, - 'folder-trash': NsfRemoveFolderOperation.FolderTrash, - 'delete-permanent': NsfRemoveFolderOperation.DeletePermanent, -} - -function parseOperation(input: string): NsfRemoveFolderOperation { - return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash -} - -function printPreview( - vault: Awaited>, - 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 printPreviewWarnings(result: RemoveNsfFolderResult): void { - for (const item of result.preview) { - for (const warning of item.impact?.warnings ?? []) { - logger.info(`Warning: ${warning}`) - } - } -} - -async function removeNestedShareFolders( - vault: Awaited>, - input: RemoveNsfFolderInput -): Promise { - const restore = suppressLogs() - try { - return await vault.removeNestedShareFolders(input) - } finally { - restore() - } -} - -async function rmdirNsf() { - const vault = await login() - - try { - const foldersInput = (await prompt('Folder UID(s) or name(s), comma-separated: ')).trim() - const folders = foldersInput.split(',').map((value) => value.trim()).filter(Boolean) - 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('') - logger.info('*** WARNING ***') - logger.info('delete-permanent is IRREVERSIBLE.') - logger.info('All sub-folders and records inside will be permanently destroyed.') - logger.info('') - } - - const baseInput: RemoveNsfFolderInput = { - folders, - operation, - quiet, - } - - if (dryRun) { - const result = await removeNestedShareFolders(vault, { ...baseInput, dryRun: true }) - printPreview(vault, result, quiet) - logger.info('[Dry-run] No folders were deleted.') - return - } - - if (force) { - const result = await removeNestedShareFolders(vault, { ...baseInput, force: true }) - printPreview(vault, result, quiet) - if (result.confirmed && result.message) { - logger.info(result.message) - } - return - } - - const preview = await removeNestedShareFolders(vault, { ...baseInput, force: false }) - printPreview(vault, preview, quiet) - printPreviewWarnings(preview) - - const promptText = - 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(promptText))) { - logger.info('Removal cancelled.') - return - } - - const result = await removeNestedShareFolders(vault, { ...baseInput, force: true }) - if (result.confirmed && result.message) { - logger.info(result.message) - } - } catch (err) { - logger.error(`rmdir failed: ${extractErrorMessage(err)}`) - process.exitCode = 1 - } finally { - cleanup(vault) - } -} - -runExample(rmdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts index 5ea33197..b2a662a7 100644 --- a/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts +++ b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts @@ -3,30 +3,17 @@ import { extractErrorMessage, login, logger, + parseNsfFieldStrings, prompt, - suppressLogs, - type UpdateNsfRecordFieldMap, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' - -function parseFieldSpecs(input: string): UpdateNsfRecordFieldMap { - const fields: UpdateNsfRecordFieldMap = {} - for (const spec of input.split(',').map((value) => value.trim()).filter(Boolean)) { - const separator = spec.indexOf('=') - if (separator <= 0) continue - const type = spec.slice(0, separator).trim() - const value = spec.slice(separator + 1).trim() - if (type) fields[type] = value - } - return fields -} +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' async function updateNsfRecord() { const vault = await login() try { - const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() - const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + 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 @@ -36,21 +23,19 @@ async function updateNsfRecord() { const recordType = (await prompt('Record type (optional): ')).trim() const notes = (await prompt('Notes (optional): ')).trim() const fieldsInput = (await prompt('Fields (type=value, comma-separated, optional): ')).trim() - const fields = fieldsInput ? parseFieldSpecs(fieldsInput) : undefined + const fields = fieldsInput + ? parseNsfFieldStrings(splitCommaSeparated(fieldsInput)).fields + : undefined - const restore = suppressLogs() - let result - try { - result = await vault.updateNestedShareRecords({ + const result = await withSuppressedLogs(() => + vault.updateNestedShareRecords({ records, title: title || undefined, recordType: recordType || undefined, notes: notes || undefined, fields, }) - } finally { - restore() - } + ) logger.info('') for (const item of result.updated) { @@ -68,4 +53,4 @@ async function updateNsfRecord() { } } -runExample(updateNsfRecord) \ No newline at end of file +runExample(updateNsfRecord) diff --git a/examples/sdk_example/src/utils/format.ts b/examples/sdk_example/src/utils/format.ts index b9482be6..4f322c38 100644 --- a/examples/sdk_example/src/utils/format.ts +++ b/examples/sdk_example/src/utils/format.ts @@ -2,6 +2,7 @@ import { EMAIL_LIST_SEPARATOR_PATTERN, EMAIL_PATTERN, isValidEmail, + suppressLogs, } from '@keeper-security/keeper-sdk-javascript' export { EMAIL_PATTERN } @@ -40,6 +41,19 @@ export function isYes(answer: string): boolean { return normalized === 'y' || normalized === 'yes' } +export function splitCommaSeparated(input: string): string[] { + return input.split(',').map((value) => value.trim()).filter(Boolean) +} + +export async function withSuppressedLogs(fn: () => Promise): Promise { + const restore = suppressLogs() + try { + return await fn() + } finally { + restore() + } +} + export function parseEmails(raw: string): { emails: string[]; invalid: string[] } { const tokens = raw .split(EMAIL_LIST_SEPARATOR_PATTERN) diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index bfb8f9ec..0445ec56 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -1072,7 +1072,7 @@ export const pamGetOnlineControllersMessage = (): RestOutMessage => - createMessage(data, '/vault/records/v3/add', record.v3.RecordsAddRequest, Records.RecordsModifyResponse) + createMessage(data, 'vault/records/v3/add', record.v3.RecordsAddRequest, Records.RecordsModifyResponse) export const keeperDriveRecordsUpdate = ( data: Records.IRecordsUpdateRequest From 490e65480d057b6c420525737008195e0058cdd0 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Thu, 18 Jun 2026 16:02:05 +0530 Subject: [PATCH 07/48] fixed formatting for command outputs --- KeeperSdk/README.md | 142 ++++++++--- KeeperSdk/src/account/whoamiInfo.ts | 109 ++++++++ KeeperSdk/src/api.ts | 4 + KeeperSdk/src/auth/UnavailableAuthUI.ts | 1 - KeeperSdk/src/auth/node/FileConfigLoader.ts | 2 +- KeeperSdk/src/auth/sessionRestore.ts | 8 - KeeperSdk/src/cli/README.md | 124 ++++++++++ KeeperSdk/src/cli/access.ts | 1 - KeeperSdk/src/cli/account/whoamiFormat.ts | 73 ++++++ KeeperSdk/src/cli/builtinCommands.ts | 1 - KeeperSdk/src/cli/commander/get.ts | 28 +-- KeeperSdk/src/cli/commander/getCore.ts | 1 - KeeperSdk/src/cli/commander/listCore.ts | 116 +++++++++ KeeperSdk/src/cli/commander/listSfCore.ts | 66 +++++ KeeperSdk/src/cli/commander/lsCore.ts | 233 ++++++++++++++++++ KeeperSdk/src/cli/commander/misc.ts | 183 +++++++------- KeeperSdk/src/cli/commander/nav.ts | 171 +++++-------- KeeperSdk/src/cli/commander/reportOutput.ts | 58 +++++ KeeperSdk/src/cli/commands/help.ts | 12 +- KeeperSdk/src/cli/commands/login.ts | 55 ++--- KeeperSdk/src/cli/commands/logout.ts | 5 +- KeeperSdk/src/cli/commands/restoreSession.ts | 61 +++-- KeeperSdk/src/cli/commands/sync.ts | 8 +- KeeperSdk/src/cli/commands/vault.ts | 11 +- KeeperSdk/src/cli/dispatch.ts | 16 +- KeeperSdk/src/cli/help.ts | 171 ++++++++++--- KeeperSdk/src/cli/index.ts | 12 +- KeeperSdk/src/cli/jsonArg.ts | 5 - KeeperSdk/src/cli/parse.ts | 62 ++++- KeeperSdk/src/cli/parser.ts | 15 +- KeeperSdk/src/cli/table.ts | 1 - KeeperSdk/src/cli/types.ts | 36 ++- KeeperSdk/src/folders/changeDirectory.ts | 2 +- KeeperSdk/src/folders/folderTree.ts | 21 +- KeeperSdk/src/folders/listFolder.ts | 27 ++ KeeperSdk/src/records/RecordUtils.ts | 104 ++++++-- .../src/sharedFolders/listSharedFolders.ts | 36 ++- KeeperSdk/src/vault/KeeperVault.ts | 30 ++- README.md | 48 +++- 39 files changed, 1584 insertions(+), 475 deletions(-) create mode 100644 KeeperSdk/src/account/whoamiInfo.ts create mode 100644 KeeperSdk/src/cli/README.md create mode 100644 KeeperSdk/src/cli/account/whoamiFormat.ts create mode 100644 KeeperSdk/src/cli/commander/listCore.ts create mode 100644 KeeperSdk/src/cli/commander/listSfCore.ts create mode 100644 KeeperSdk/src/cli/commander/lsCore.ts create mode 100644 KeeperSdk/src/cli/commander/reportOutput.ts diff --git a/KeeperSdk/README.md b/KeeperSdk/README.md index 4c2fc428..314b3778 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -1,6 +1,6 @@ # @keeper-security/keeper-sdk-javascript -Keeper Javascript SDK for Node.js. +Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folders, and a Commander-style CLI (`dispatchCliLine`). [![NPM](https://img.shields.io/npm/v/@keeper-security/keeper-sdk-javascript?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeper-sdk-javascript) @@ -10,40 +10,40 @@ Keeper Javascript SDK for Node.js. npm install @keeper-security/keeper-sdk-javascript ``` -## Quickstart +## Entry points + +| Environment | Import | Notes | +|-------------|--------|--------| +| Node | `@keeper-security/keeper-sdk-javascript` → `dist/index.js` | `ConsoleAuthUI`, `FileConfigLoader`, full auth | +| Browser | same package → `dist/browser.js` | Platform shim only; use in-memory session + `restore-session` | + +## Quickstart (Node) ```typescript import { KeeperVault, ConsoleAuthUI, FileConfigLoader } from '@keeper-security/keeper-sdk-javascript' const vault = new KeeperVault({ authUI: new ConsoleAuthUI(), - configLoader: new FileConfigLoader('./keeper-config.json'), + sessionStorage: new FileConfigLoader('./keeper-config.json'), }) -await vault.login() -await vault.syncDown() +await vault.login('user@example.com', 'password') +await vault.sync() -console.log(`Loaded ${vault.records.size} records`) +console.log(`Loaded ${vault.getRecords().length} records`) ``` -## Supported functionality - -`KeeperVault` exposes vault operations. Enterprise features require an enterprise administrator account. +## Built-in shell CLI -- **Authentication**: Login, session token login, resume session, sync, logout -- **Records**: List, search, add, update, delete, move, history -- **Folders**: List, get, create, rename, delete, change directory, folder tree -- **Shared folders**: List shared folders, share with users, update permissions -- **Sharing**: Share and unshare records, check share info -- **Teams / users / roles** (enterprise admin): Available via the SDK API; not exposed as shell commands in this release +**CLI docs & changelog:** [`src/cli/README.md`](src/cli/README.md) -## Built-in shell CLI +Commander-aligned commands via `dispatchCliLine(line, host)` or `createKeeperCliParser()`. -The package includes a Commander-style CLI (`dispatchCliLine`, `createKeeperCliParser`) for auth, records, and folders. +### Before login -**Before login:** `help`, `login`, `restore-session` +`help`, `login`, `restore-session`, `register-device` -**After login:** +### After login | Area | Commands | |------|----------| @@ -52,55 +52,121 @@ The package includes a Commander-style CLI (`dispatchCliLine`, `createKeeperCliP | Folders | `ls`, `cd`, `tree`, `mkdir`, `list-sf` (`lsf`) | | Vault info | `vault summary` | -Every command supports `--help`. Record/folder write operations (`add`, `update`, `delete`, `share`, …) are SDK-only — see the examples below. +Every command supports `--help` / `-h`. Record/folder **write** operations (`add`, `update`, `delete`, `share`, …) are SDK API only — see [`examples/sdk_example`](../examples/sdk_example). ### Finding records and folders | Goal | Command | |------|---------| | Record by UID (exact) | `get ` | -| Record by title | `get "Gmail Login"` or `search gmail` | -| Text in title/fields | `search ` (all terms must match) | +| Record by title | `get "Gmail Login"` | +| Text in title/fields | `search ` (all terms must match; not for raw UID paste) | | Shared folder by UID | `get ` or `list-sf ` | | Folder by path/UID | `get `, `ls`, `cd`, `tree` | | All records (table) | `list` or `list --verbose` | -| Account summary | `whoami` or `vault summary` | -`search` only covers **vault records** (title, fields, UID). It does not search teams or enterprise users — use the SDK API (`vault.viewTeam`, `vault.listTeams`, …) or `examples/sdk_example` scripts for those. +`search` covers **vault records** only (title, fields, UID substring). For exact UID lookup use **`get`**, not `search`. + +### `get` (Commander-style detail) + +Default `--format detail` prints aligned fields and, when the host exposes `getRecordShareInfo`, fetches share metadata: + +```text + UID: DAqb-wR89VrcdUxzrcW6ww + Type: login + Title: TestingParam + +User Permissions: + + User: user@example.com + Owner: Yes + Shareable: Yes + Read-Only: No + +Shared Folder Permissions: + + Shared Folder UID: cYEzoDium40DV9VlBwmRJQ + +Share Admins (9): + admin1@example.com + ... +``` + +Other formats: `--format json`, `--format password`, `--format fields`, `--unmask`. + +Implementation: `src/cli/commander/getFormat.ts` + `getCore.ts`. + +### `whoami` + +Uses `vault.getWhoamiInfo()` (account summary, server, data center, license). `--verbose` / `-v` syncs and includes vault counts; `--json` emits Commander-compatible JSON. + +Hosts **must** wire `getWhoamiInfo` on `KeeperCliVault` (see below). + +### CLI usage ```typescript import { dispatchCliLine, type KeeperCliHost } from '@keeper-security/keeper-sdk-javascript' -await dispatchCliLine('restore-session --from-json session.json', host) -await dispatchCliLine('list', host) -await dispatchCliLine('ls', host) +const result = await dispatchCliLine('get DAqb-wR89VrcdUxzrcW6ww', host) +console.log(result.out) ``` -## Examples +Shell parity example: -Runnable SDK scripts are in [`examples/sdk_example`](../examples/sdk_example): +```bash +cd examples/sdk_example +npm run records:list:shell-cli -- --from-json /path/to/session.json +``` + +## Embedding the CLI (`KeeperCliHost`) + +Thin hosts (browser shell, tests) implement `KeeperCliHost` and expose a `KeeperCliVault` adapter around `KeeperVault`. + +**Required for session commands:** `isLoggedIn`, `login`, `loginWithSessionToken`, `logout`, `sync`, `getRecords`, `getSharedFolders`, `restoreSession`. + +**Optional — commands fail with a clear message if missing:** + +| Method | Commands | +|--------|----------| +| `findRecord`, `findRecords` | `get`, `search` | +| `getRecordShareInfo` | `get` (share sections in detail output) | +| `getWhoamiInfo` | `whoami` | +| `getSummary` | `vault summary` | +| `listFolder`, `cd`, `tree`, `mkdir`, … | folder navigation | +| `listSharedFolders` | `list-sf` | + +Full type: `src/cli/types.ts` (`KeeperCliVault`, `KeeperCliHost`). + +A full `KeeperVault` adapter should forward **all** optional methods used by built-in commands — do not omit `getWhoamiInfo` or `getRecordShareInfo` if you want parity with Commander. + +## Supported vault API + +`KeeperVault` exposes auth, records, folders, sharing, and enterprise helpers (teams/users require admin). See `src/vault/KeeperVault.ts` and [`examples/sdk_example`](../examples/sdk_example). + +## Examples ```bash cd examples/sdk_example npm install npm run auth:login npm run records:list +npm run records:get # interactive; similar to CLI get + share info npm run folders:ls npm run shared-folders:list-sf ``` -Shell CLI parity (same dispatch path as the vault shell): +## Local development + +From repo root, build keeperapi first: ```bash -npm run records:list:shell-cli -- --from-json /path/to/session.json +cd keeperapi && npm install && npm run build +cd ../KeeperSdk && npm install && npm run link-local && npm run build ``` -## Local development +Watch types: `npm run types` (in `KeeperSdk/`). -From the `KeeperSdk/` directory: +## Related -```bash -npm install -npm run link-local -npm run build -``` +- [`keeperapi/README.md`](../keeperapi/README.md) — core client +- [`../README.md`](../README.md) — monorepo overview diff --git a/KeeperSdk/src/account/whoamiInfo.ts b/KeeperSdk/src/account/whoamiInfo.ts new file mode 100644 index 00000000..caedf053 --- /dev/null +++ b/KeeperSdk/src/account/whoamiInfo.ts @@ -0,0 +1,109 @@ +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 index def13c4e..0e6ef8b9 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -84,6 +84,10 @@ export type { 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 { formatWhoamiJson, formatWhoamiOutput } from './cli/account/whoamiFormat' + export type { SessionRestoreInput } from './auth/sessionRestore' export { toSessionParams, diff --git a/KeeperSdk/src/auth/UnavailableAuthUI.ts b/KeeperSdk/src/auth/UnavailableAuthUI.ts index 16278777..b0b69cb6 100644 --- a/KeeperSdk/src/auth/UnavailableAuthUI.ts +++ b/KeeperSdk/src/auth/UnavailableAuthUI.ts @@ -4,7 +4,6 @@ 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).' -/** Placeholder when Node readline-based ConsoleAuthUI must not be used. */ export class UnavailableAuthUI implements AuthUI3 { public async waitForDeviceApproval(_channels: DeviceApprovalChannel[], _isCloud: boolean): Promise { throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN) diff --git a/KeeperSdk/src/auth/node/FileConfigLoader.ts b/KeeperSdk/src/auth/node/FileConfigLoader.ts index 61c4d08e..328fa254 100644 --- a/KeeperSdk/src/auth/node/FileConfigLoader.ts +++ b/KeeperSdk/src/auth/node/FileConfigLoader.ts @@ -5,7 +5,7 @@ import type { ConfigLoader, KeeperJsonConfig } from '../config' import { isValidKeeperConfig } from '../config' import { logger, extractErrorMessage, SdkDefaults } from '../../utils' -/** Node-only: read/write `~/.keeper/config.json`. */ +/** CAUTION: This is a Node-only class. */ export class FileConfigLoader implements ConfigLoader { public readonly configDir: string diff --git a/KeeperSdk/src/auth/sessionRestore.ts b/KeeperSdk/src/auth/sessionRestore.ts index 0e64a4e0..746ee0ff 100644 --- a/KeeperSdk/src/auth/sessionRestore.ts +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -9,7 +9,6 @@ const UserTypeValues = { onsiteSso: 'onsite_sso' as UserType, } -/** String form of {@link SessionParams} as exported from extension / vault storage. */ export type SessionRestoreInput = { accountUid: string clientKey: string @@ -100,7 +99,6 @@ export function validateSessionRestoreInput(input: Partial) 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) @@ -140,7 +138,6 @@ function looksLikeInlineJson(text: string): boolean { return t.startsWith('{') || t.startsWith('[') || t.startsWith('"') } -/** File path / URL — not inline JSON (avoid JSON.parse on `/path/to/conf.json`). */ function looksLikeFilePath(text: string): boolean { const t = text.trim() if (/^https?:\/\//i.test(t)) return true @@ -150,10 +147,6 @@ function looksLikeFilePath(text: string): boolean { return false } -/** - * Parse session JSON. One JSON.parse when the payload is an object; two when the CLI - * value is a JSON-encoded string (e.g. JSON.stringify(conf) wrapped in quotes). - */ export function sessionRestoreFromJson(json: string): SessionRestoreInput { const text = json.trim().replace(/^\uFEFF/, '') let parsed: unknown @@ -177,7 +170,6 @@ export function sessionRestoreFromJson(json: string): SessionRestoreInput { return validateSessionRestoreInput(parsed as Partial) } -/** Parse `--from-json` payload, or read a file when the value is not JSON. */ export async function resolveSessionRestorePayload( raw: string, readFile?: (path: string) => Promise diff --git a/KeeperSdk/src/cli/README.md b/KeeperSdk/src/cli/README.md new file mode 100644 index 00000000..6a282593 --- /dev/null +++ b/KeeperSdk/src/cli/README.md @@ -0,0 +1,124 @@ +# Keeper SDK — CLI layer + +Commander-aligned shell commands for browser shell. +Entry point: `dispatchCliLine(line, host)` or `createKeeperCliParser()`. + +## Commands + +### Before login + +| Command | Aliases | Notes | +|---------|---------|--------| +| `help` | `?` | All commands or `help ` | +| `login` | | Interactive / flags | +| `restore-session` | | Extension JSON / env | +| `register-device` | | Device token for session login | + +### After login + +| Area | Commands | +|------|----------| +| Session | `logout`, `sync` (`syncdown`, `sync-down`, `d`), `whoami` | +| Records | `list` (`l`), `search` (`s`), `get` (`g`) | +| Folders | `ls`, `cd`, `tree`, `mkdir` | +| Shared folders | `list-sf` (`lsf`) | +| Vault counts | `vault summary` | + +Every command supports `--help` / `-h`. + +Record/folder **writes** (`add`, `update`, `delete`, `share`, …) are SDK API only — not CLI commands yet. + +## Embedding (`KeeperCliHost`) + +Implement `KeeperCliHost` and expose a `KeeperCliVault` adapter (usually wrapping `KeeperVault`). + +**Required:** `isLoggedIn`, `login`, `loginWithSessionToken`, `logout`, `sync`, `getRecords`, `getSharedFolders`, `restoreSession`. + +**Optional** (command fails with a clear message if missing): + +| Method | Commands | +|--------|----------| +| `findRecord`, `findRecords` | `get`, `search` | +| `getRecordShareInfo` | `get` (share sections in detail output — planned) | +| `getWhoamiInfo` | `whoami` | +| `getSummary` | `vault summary` | +| `listFolder`, `changeDirectory`, `tree`, `mkdir`, … | folder navigation | +| `listSharedFolders` | `list-sf` | + +Types: `types.ts`. + +```typescript +import { dispatchCliLine, type KeeperCliHost } from '@keeper-security/keeper-sdk-javascript' + +const result = await dispatchCliLine('whoami', host) +process.stdout.write(result.out) +if (result.err) process.stderr.write(result.err) +``` + +## Commander parity + +### `whoami` + +Uses `vault.getWhoamiInfo()` → account summary API (user, server, data center, admin, license, storage, BreachWatch). + +- `--verbose` / `-v` — syncs vault, adds record/shared-folder/team counts and reporting status +- `--json` — Commander-compatible field names (`data_center`, `breachwatch`, …) + +Formatting: `account/whoamiFormat.ts`. + +### `get` + +| `--format` | Behavior | +|------------|----------| +| `detail` (default) | Record fields via `formatRecord` | +| `json` | Full `DRecord` JSON | +| `password` | Password field only | +| `fields` | Field name/value JSON | + +Target resolution: shared folder UID → folder path/UID → record UID/title. + +**Target Commander detail output** (UID/Type/Title + User Permissions + Shared Folder Permissions + Share Admins) requires wiring `getRecordShareInfo` in the host and a dedicated formatter — tracked below. + +### `search` vs `get` + +- **`get `** — exact UID (record, shared folder, or folder) +- **`search `** — text match across record title/fields (not raw UID lookup) + +## Extending + +```typescript +import { registerCliCommand, type CliCommandDefinition } from '@keeper-security/keeper-sdk-javascript' + +registerCliCommand({ + name: 'my-cmd', + description: '…', + usage: 'my-cmd', + help: { description: '…' }, + async run(host, parsed) { + return { code: 0, out: 'ok\n', err: '' } + }, +}) +``` + +Call `ensureKeeperCliRegistry()` before dispatch if you register commands after import. + +## Changelog (CLI) + +Track CLI-only changes here — not in the package root `KeeperSdk/README.md`. + +### Unreleased + +- **`whoami`** — Commander-style sections (User Info / Account); data in `account/whoamiInfo.ts`, format in `cli/account/whoamiFormat.ts`; `--verbose`, `--json` +- **`getDetailedHelpPage`** — exported from `help.ts` / `cli/index.ts` for public API (`getDetailedHelpPageForRegistry` for filtered registries) +- **Help system** — argparse-style `--help` via `formatArgparseHelp` (`help.ts`) + +### Planned / in progress + +- **`get --format detail`** — Commander layout with permissions and share admins (`getRecordShareInfo`) +- **Report commands** — `reportOutput.ts` helpers for table/csv/json; PDF not supported in JS CLI host + +### Conventions + +- Commander parity formatters live under `cli/commander/` or `cli/account/` +- Domain data builders stay outside `cli/` when used by `KeeperVault` +- Update this changelog when adding or changing commands, flags, or output format diff --git a/KeeperSdk/src/cli/access.ts b/KeeperSdk/src/cli/access.ts index 027c0467..136fb0d7 100644 --- a/KeeperSdk/src/cli/access.ts +++ b/KeeperSdk/src/cli/access.ts @@ -1,7 +1,6 @@ import type { CliCommandDefinition } from './types' import { listCliCommands, resolveCliCommandName } from './registry' -/** Commands available before a vault session exists. */ export const AUTH_CLI_COMMAND_NAMES = new Set([ 'help', 'login', diff --git a/KeeperSdk/src/cli/account/whoamiFormat.ts b/KeeperSdk/src/cli/account/whoamiFormat.ts new file mode 100644 index 00000000..bbd5c284 --- /dev/null +++ b/KeeperSdk/src/cli/account/whoamiFormat.ts @@ -0,0 +1,73 @@ +import type { WhoamiInfo } from '../../account/whoamiInfo' + +const SECTION_RULE = '─'.repeat(50) +const LABEL_WIDTH = 24 + +export function formatWhoamiJson(info: WhoamiInfo, options: { verbose?: boolean } = {}): string { + const payload: Record = { + logged_in: true, + user: info.user, + server: info.server, + data_center: info.dataCenter, + admin: info.admin, + account_type: info.accountType, + renewal_date: info.renewalDate, + storage_capacity: info.storageCapacity, + storage_usage: info.storageUsage, + storage_renewal_date: info.storageRenewalDate, + breachwatch: info.breachWatch, + } + + if (options.verbose) { + payload.reporting_and_alerts = info.reportingAndAlerts + payload.records_count = info.recordsCount ?? 0 + payload.shared_folders_count = info.sharedFoldersCount ?? 0 + payload.teams_count = info.teamsCount ?? 0 + } + + return JSON.stringify(payload, null, 2) + '\n' +} + +export function formatWhoamiOutput(info: WhoamiInfo, options: { verbose?: boolean } = {}): string { + const userRows = [ + { label: 'User', value: info.user }, + { label: 'Server', value: info.server }, + { label: 'Data Center', value: info.dataCenter }, + { label: 'Admin', value: info.admin ? 'Yes' : 'No' }, + ] + + if (options.verbose) { + userRows.push( + { label: 'Records', value: String(info.recordsCount ?? 0) }, + { label: 'Shared Folders', value: String(info.sharedFoldersCount ?? 0) }, + { label: 'Teams', value: String(info.teamsCount ?? 0) } + ) + } + + const accountRows = [ + { label: 'Account Type', value: info.accountType }, + { label: 'Renewal Date', value: info.renewalDate }, + { label: 'Storage Capacity', value: info.storageCapacity }, + { label: 'Usage', value: info.storageUsage }, + { label: 'Storage Renewal Date', value: info.storageRenewalDate }, + { label: 'BreachWatch', value: info.breachWatch ? 'Yes' : 'No' }, + ] + + if (options.verbose) { + accountRows.push({ + label: 'Reporting & Alerts', + value: info.reportingAndAlerts ? 'Yes' : 'No', + }) + } + + const sections = [renderWhoamiSection('User Info', userRows), renderWhoamiSection('Account', accountRows)] + return `\n${sections.join('\n\n')}\n` +} + +function renderWhoamiSection(title: string, rows: { label: string; value: string }[]): string { + const lines = [` ${title}`, ` ${SECTION_RULE}`] + for (const row of rows) { + lines.push(` ${row.label.padStart(LABEL_WIDTH)}: ${row.value}`) + } + return lines.join('\n') +} diff --git a/KeeperSdk/src/cli/builtinCommands.ts b/KeeperSdk/src/cli/builtinCommands.ts index 1cd33332..1ded245c 100644 --- a/KeeperSdk/src/cli/builtinCommands.ts +++ b/KeeperSdk/src/cli/builtinCommands.ts @@ -10,7 +10,6 @@ import { getCommand } from './commander/get' import { cdCommand, lsCommand, mkdirCommand, treeCommand } from './commander/nav' import { listCommand, listSfCommand, searchCommand, whoamiCommand } from './commander/misc' -/** Built-in CLI commands (Keeper Commander-style vault shell). */ export const BUILTIN_CLI_COMMANDS: readonly CliCommandDefinition[] = [ helpCommand, loginCommand, diff --git a/KeeperSdk/src/cli/commander/get.ts b/KeeperSdk/src/cli/commander/get.ts index 24fd82c1..13b6e3bc 100644 --- a/KeeperSdk/src/cli/commander/get.ts +++ b/KeeperSdk/src/cli/commander/get.ts @@ -11,23 +11,17 @@ export const getCommand: CliCommandDefinition = { usage: 'get [--format {detail,json,password,fields}] [--unmask]', flagOptions: ['--format', '--unmask', '--detail', '--json'], help: { - title: 'get — record/folder details (Keeper Commander)', - synopsis: 'usage: get [--unmask] [--format {detail,json,password,fields}] uid', - description: - ' Resolves a vault object by UID or title. Records support all output formats; folders and shared folders support detail/json.\n' + - ' Prefer get for exact UID lookup; search is for text in titles and fields.', - arguments: ' uid Record, folder, or shared-folder UID or title.', - options: ` --format {detail,json,password,fields} - detail (default): human-readable output. - json: JSON object. - password: password field only (records). - fields: JSON array of {name, value} (records). - --unmask Show sensitive field values (records). - --help, -h Show this help.`, - examples: ` get "Amazon" - get AbCdEf123456 --format json --unmask - get MyFolderUid --format json`, - seeAlso: ' ls, search, list', + description: 'Get the details of a record/folder/team by UID or title', + usage: '[-h] [--unmask] [--format {detail,json,password,fields}] uid', + positionals: [{ name: 'uid', help: 'UID or title to search for' }], + options: [ + { flags: '--unmask', help: 'display hidden field values (records)' }, + { + flags: '--format', + choices: 'detail,json,password,fields', + help: 'output format (default: detail)', + }, + ], }, async run(host, parsed) { if (wantsCliHelp(parsed)) { diff --git a/KeeperSdk/src/cli/commander/getCore.ts b/KeeperSdk/src/cli/commander/getCore.ts index 6d98bde0..bdae4fd1 100644 --- a/KeeperSdk/src/cli/commander/getCore.ts +++ b/KeeperSdk/src/cli/commander/getCore.ts @@ -95,7 +95,6 @@ async function tryGetSharedFolderByUid( return { code: 0, out: `${hit.name ?? '(unnamed)'}\t${hit.uid}\n`, err: '' } } -/** Commander-style `get` (record, folder, or shared folder by UID/title). */ export async function executeGet(host: KeeperCliHost, parsed: ParsedCli, cmd = 'get'): Promise { const target = getGetTarget(parsed) if (!target) { diff --git a/KeeperSdk/src/cli/commander/listCore.ts b/KeeperSdk/src/cli/commander/listCore.ts new file mode 100644 index 00000000..1d76e975 --- /dev/null +++ b/KeeperSdk/src/cli/commander/listCore.ts @@ -0,0 +1,116 @@ +import type { DRecord } from '@keeper-security/keeperapi' +import type { CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getAllOpt, getOpt, hasOpt } from '../parse' +import { ensureSession } from '../commandHelpers' +import { + formatRecordsListTable, + renderRecordsListAsciiTable, +} from '../../records/listRecordsTable' +import { getRecordFields, getRecordType, searchRecords } from '../../records/RecordUtils' +import { + emptyReportResult, + resolveReportFormat, + tableToCsv, + validateReportFormat, + validateReportOutput, +} from './reportOutput' + +function filterByRecordTypes(records: DRecord[], recordTypes: string[]): DRecord[] { + if (recordTypes.length === 0) return records + + const matchers: Array<(record: DRecord) => boolean> = [] + + for (const rt of recordTypes) { + const key = rt.toLowerCase() + if (key === 'app') { + matchers.push((record) => record.version === 5) + } else if (key === 'file') { + matchers.push( + (record) => + (record.version === 3 || record.version === 4) && + getRecordType(record).toLowerCase() === 'file' + ) + } else if (key === 'general' || key === 'legacy') { + matchers.push((record) => record.version === 1 || record.version === 2) + } else if (key === 'pam') { + matchers.push((record) => record.version === 6) + } else { + const typeName = key + matchers.push((record) => getRecordType(record).toLowerCase() === typeName) + } + } + + return records.filter((record) => matchers.some((match) => match(record))) +} + +function filterByFields(records: DRecord[], fieldNames: string[]): DRecord[] { + if (fieldNames.length === 0) return records + return records.filter((record) => { + const fields = getRecordFields(record) + return fieldNames.every((name) => { + const needle = name.toLowerCase() + return fields.some((field) => { + const type = (field.type || '').toLowerCase() + const label = (field.label || '').toLowerCase() + return type === needle || label === needle || type.includes(needle) || label.includes(needle) + }) + }) + }) +} + +function filterListRecords(records: DRecord[], parsed: ParsedCli): DRecord[] { + let result = records + const pattern = parsed.positional[0]?.trim() + if (pattern) { + result = searchRecords(result, pattern) + } + result = filterByRecordTypes(result, getAllOpt(parsed, 'type', 't')) + result = filterByFields(result, getAllOpt(parsed, 'field')) + return result +} + +function recordsToJsonRows(records: DRecord[]): Record[] { + const table = formatRecordsListTable(records, { verbose: true }) + return table.rows.map((row) => ({ + record_uid: row[1], + type: row[2], + title: row[3], + description: row[4], + shared: row[5] === 'True', + record_category: row[6], + })) +} + +export async function executeList(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + + const fmt = resolveReportFormat(parsed) + const badFmt = validateReportFormat('list', fmt) + if (badFmt) return badFmt + + const outputPath = getOpt(parsed.opts, 'output') + const badOut = validateReportOutput('list', fmt, outputPath) + if (badOut) return badOut + + const v = host.getVault() + await v.sync() + const records = filterListRecords(v.getRecords(), parsed) + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + + if (records.length === 0) { + return emptyReportResult('list', fmt, 'No records are found\n', outputPath) + } + + const table = formatRecordsListTable(records, { verbose }) + + if (fmt === 'json') { + return { code: 0, out: `${JSON.stringify(recordsToJsonRows(records), null, 2)}\n`, err: '' } + } + + if (fmt === 'csv') { + return { code: 0, out: tableToCsv(table.headers, table.rows), err: '' } + } + + return { code: 0, out: `${renderRecordsListAsciiTable(table)}\n`, err: '' } +} diff --git a/KeeperSdk/src/cli/commander/listSfCore.ts b/KeeperSdk/src/cli/commander/listSfCore.ts new file mode 100644 index 00000000..18832386 --- /dev/null +++ b/KeeperSdk/src/cli/commander/listSfCore.ts @@ -0,0 +1,66 @@ +import type { CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt } from '../parse' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { + formatSharedFoldersTable, + renderSharedFoldersAsciiTable, + type ListSharedFolderRow, +} from '../../sharedFolders/listSharedFolders' +import { + emptyReportResult, + resolveReportFormat, + tableToCsv, + validateReportFormat, + validateReportOutput, +} from './reportOutput' + +function sharedFoldersToJson(rows: ListSharedFolderRow[]): Record[] { + return rows.map((row) => ({ + shared_folder_uid: row.shared_folder_uid, + name: row.name, + })) +} + +export async function executeListSf(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + + const v = host.getVault() + const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') + if (cap) return cap + + const fmt = resolveReportFormat(parsed) + const badFmt = validateReportFormat('list-sf', fmt) + if (badFmt) return badFmt + + const outputPath = getOpt(parsed.opts, 'output') + const badOut = validateReportOutput('list-sf', fmt, outputPath) + if (badOut) return badOut + + await v.sync() + + const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null + const roeEligible = hasOpt(parsed.opts, 'roe-eligible', 'roe') + const rows = v.listSharedFolders!({ pattern, roeEligible }) + + if (rows.length === 0) { + const message = pattern + ? `(no shared folders matched "${pattern}")\n` + : '(no shared folders)\n' + return emptyReportResult('list-sf', fmt, message, outputPath) + } + + const table = formatSharedFoldersTable(rows, { verbose: true }) + + if (fmt === 'json') { + return { code: 0, out: `${JSON.stringify(sharedFoldersToJson(rows), null, 2)}\n`, err: '' } + } + + if (fmt === 'csv') { + const csvHeaders = ['Shared Folder UID', 'Name'] + const csvRows = rows.map((row) => [row.shared_folder_uid, row.name]) + return { code: 0, out: tableToCsv(csvHeaders, csvRows), err: '' } + } + + return { code: 0, out: `${renderSharedFoldersAsciiTable(table)}\n`, err: '' } +} diff --git a/KeeperSdk/src/cli/commander/lsCore.ts b/KeeperSdk/src/cli/commander/lsCore.ts new file mode 100644 index 00000000..8b15fe0a --- /dev/null +++ b/KeeperSdk/src/cli/commander/lsCore.ts @@ -0,0 +1,233 @@ +import type { ListFolderResult } from '../../folders/listFolder' +import type { CliResult, KeeperCliHost, KeeperCliVault, ParsedCli } from '../types' +import { getOpt, hasOpt } from '../parse' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' +import { + emptyReportResult, + resolveReportFormat, + tableToCsv, + validateReportFormat, + validateReportOutput, +} from './reportOutput' + +function truncateName(name: string, verbose: boolean): string { + if (verbose || name.length <= 40) return name + return `${name.slice(0, 25)}...${name.slice(-12)}` +} + +async function resolveLsContext( + v: KeeperCliVault, + rawPattern?: string +): Promise<{ folderUid: string | null; pattern: string | null }> { + const trimmed = rawPattern?.trim() + if (!trimmed) { + return { folderUid: v.getCurrentFolderUid?.() ?? null, pattern: null } + } + + if (v.tryResolvePath) { + const resolved = await v.tryResolvePath(trimmed) + return { + folderUid: resolved.folderUid, + pattern: resolved.remaining.trim() || null, + } + } + + if (v.changeDirectory) { + try { + const cd = await v.changeDirectory(trimmed) + return { folderUid: cd.folderUid, pattern: null } + } catch { + return { folderUid: v.getCurrentFolderUid?.() ?? null, pattern: trimmed } + } + } + + return { folderUid: v.getCurrentFolderUid?.() ?? null, pattern: trimmed } +} + +function formatLsSimple(result: ListFolderResult, verbose: boolean): string { + if (result.folders.length + result.records.length === 0) return '(empty)\n' + + const names: string[] = [] + for (const folder of result.folders) { + names.push(`${truncateName(folder.name || folder.uid, verbose)}/`) + } + for (const record of result.records) { + names.push(truncateName(record.name || record.uid, verbose)) + } + names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + + const maxName = names.reduce((max, name) => Math.max(max, name.length), 0) + const width = 80 + const cols = Math.max(1, Math.floor(width / (maxName + 2))) + const lines: string[] = [] + for (let i = 0; i < names.length; i += cols) { + const row = names.slice(i, i + cols).map((name) => name.padEnd(maxName)) + lines.push(row.join(' ')) + } + return `${lines.join('\n')}\n` +} + +function formatLsDetailTable(result: ListFolderResult): string { + const chunks: string[] = [] + if (result.folders.length > 0) { + if (result.detail) { + const headers = ['folder_uid', 'name', 'flags', 'type'] + const rows = result.folders.map((folder) => [ + folder.uid, + folder.name, + folder.flags, + folder.folderKind, + ]) + chunks.push(formatTable(headers, rows).trimEnd()) + } else { + const headers = ['folder_uid', 'name', 'type'] + const rows = result.folders.map((folder) => [folder.uid, folder.name, folder.folderKind]) + chunks.push(formatTable(headers, rows).trimEnd()) + } + } + if (result.records.length > 0) { + if (result.detail) { + const headers = ['record_uid', 'type', 'title', 'flags'] + const rows = result.records.map((record) => [record.uid, record.type, record.name, record.flags]) + chunks.push(formatTable(headers, rows).trimEnd()) + } else { + const headers = ['record_uid', 'type', 'title'] + const rows = result.records.map((record) => [record.uid, record.type, record.name]) + chunks.push(formatTable(headers, rows).trimEnd()) + } + } + return `${chunks.join('\n')}\n` +} + +function formatLsJson(result: ListFolderResult): string { + const rows: Record[] = [] + if (result.detail) { + for (const folder of result.folders) { + rows.push({ + type: 'folder', + uid: folder.uid, + name: folder.name, + details: `Flags: ${folder.flags}, Subfolders: ${folder.subfolderCount}, Records: ${folder.recordCount}`, + }) + } + for (const record of result.records) { + rows.push({ + type: 'record', + uid: record.uid, + name: record.name, + details: `Type: ${record.type}, Flags: ${record.flags}`, + }) + } + } else { + for (const folder of result.folders) { + rows.push({ type: 'folder', uid: folder.uid, name: folder.name, details: '' }) + } + for (const record of result.records) { + rows.push({ + type: 'record', + uid: record.uid, + name: record.name, + details: `Type: ${record.type}`, + }) + } + } + rows.sort((a, b) => { + const typeCmp = a.type.localeCompare(b.type) + if (typeCmp !== 0) return typeCmp + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) + return `${JSON.stringify(rows, null, 2)}\n` +} + +function formatLsCsv(result: ListFolderResult): string { + const headers = ['type', 'uid', 'name', 'details'] + const rows: string[][] = [] + if (result.detail) { + for (const folder of result.folders) { + rows.push([ + 'folder', + folder.uid, + folder.name, + `Flags: ${folder.flags}, Subfolders: ${folder.subfolderCount}, Records: ${folder.recordCount}`, + ]) + } + for (const record of result.records) { + rows.push(['record', record.uid, record.name, `Type: ${record.type}, Flags: ${record.flags}`]) + } + } else { + for (const folder of result.folders) { + rows.push(['folder', folder.uid, folder.name, '']) + } + for (const record of result.records) { + rows.push(['record', record.uid, record.name, `Type: ${record.type}`]) + } + } + rows.sort((a, b) => { + const typeCmp = a[0].localeCompare(b[0]) + if (typeCmp !== 0) return typeCmp + return a[2].localeCompare(b[2], undefined, { sensitivity: 'base' }) + }) + return tableToCsv(headers, rows) +} + +export async function executeLs(host: KeeperCliHost, parsed: ParsedCli, cmd = 'ls'): Promise { + const r = await ensureSession(host) + if (r) return r + + const v = host.getVault() + const cap = ensureCapability(v, 'listFolder', cmd) + if (cap) return cap + await v.sync() + + let fmt = resolveReportFormat(parsed) + const badFmt = validateReportFormat(cmd, fmt) + if (badFmt) return badFmt + + const outputPath = getOpt(parsed.opts, 'output') + const badOut = validateReportOutput(cmd, fmt, outputPath) + if (badOut) return badOut + + const foldersOnly = hasOpt(parsed.opts, 'folders', 'f') + const recordsOnly = hasOpt(parsed.opts, 'records', 'r') + let detail = hasOpt(parsed.opts, 'detail', 'list', 'l') + const verbose = hasOpt(parsed.opts, 'verbose', 'v') + const recursive = hasOpt(parsed.opts, 'recursive', 'R') + hasOpt(parsed.opts, 'short', 's') + + if (fmt === 'json' || fmt === 'csv') { + detail = true + } + + const showFolders = foldersOnly ? true : !recordsOnly + const showRecords = recordsOnly ? true : !foldersOnly + + const rawPattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') + const { folderUid, pattern } = await resolveLsContext(v, rawPattern) + + const result = await v.listFolder!({ + folderUid, + pattern, + detail, + showFolders, + showRecords, + recursive, + }) + + if (result.folders.length === 0 && result.records.length === 0) { + if (pattern) { + return { code: 1, out: '', err: `${cmd}: ${pattern}: No such folder or record\n` } + } + return emptyReportResult(cmd, fmt, '(empty)\n', outputPath) + } + + if (fmt === 'json') { + return { code: 0, out: formatLsJson(result), err: '' } + } + if (fmt === 'csv') { + return { code: 0, out: formatLsCsv(result), err: '' } + } + + const out = detail ? formatLsDetailTable(result) : formatLsSimple(result, verbose) + return { code: 0, out, err: '' } +} diff --git a/KeeperSdk/src/cli/commander/misc.ts b/KeeperSdk/src/cli/commander/misc.ts index 5e855aa5..be05a83a 100644 --- a/KeeperSdk/src/cli/commander/misc.ts +++ b/KeeperSdk/src/cli/commander/misc.ts @@ -4,26 +4,10 @@ import { formatDetailedHelpForCommand } from '../help' import { ensureCapability, ensureSession } from '../commandHelpers' import { formatTable } from '../table' import { getRecordTitle } from '../../records/RecordUtils' -import { renderRecordsListTable } from '../../records/listRecordsTable' import { recordUid } from '../utils' -import { formatSharedFoldersTable, renderSharedFoldersAsciiTable } from '../../sharedFolders/listSharedFolders' - -async function runList(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - await v.sync() - const records = v.getRecords() - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(records, null, 2) + '\n', err: '' } - } - if (records.length === 0) { - return { code: 0, out: '(no records)\n', err: '' } - } - const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') - const out = renderRecordsListTable(records, { verbose }) + '\n' - return { code: 0, out, err: '' } -} +import { formatWhoamiJson, formatWhoamiOutput } from '../account/whoamiFormat' +import { executeList } from './listCore' +import { executeListSf } from './listSfCore' async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { const pattern = parsed.positional.join(' ') || getOpt(parsed.opts, 'pattern') @@ -47,68 +31,71 @@ async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r +async function runWhoami(host: KeeperCliHost, parsed: ParsedCli): Promise { const v = host.getVault() - const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') + if (!v.isLoggedIn) { + return { code: 1, out: '', err: 'whoami: not logged in\n' } + } + + const cap = ensureCapability(v, 'getWhoamiInfo', 'whoami') if (cap) return cap - await v.sync() - const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') - const rows = v.listSharedFolders!({ pattern, verbose, includeDetails: verbose }) - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } + if (verbose) { + const syncCap = ensureCapability(v, 'sync', 'whoami') + if (syncCap) return syncCap + await v.sync!() } - if (rows.length === 0) { - return { - code: 0, - out: pattern ? `(no shared folders matched "${pattern}")\n` : '(no shared folders)\n', - err: '', - } - } - const table = formatSharedFoldersTable(rows, { verbose }) - return { code: 0, out: renderSharedFoldersAsciiTable(table) + '\n', err: '' } -} -async function runWhoami(host: KeeperCliHost): Promise { - const v = host.getVault() - if (!v.isLoggedIn) { - return { code: 1, out: '', err: 'whoami: not logged in\n' } - } - const username = - (await host.getAccountUsername?.()) ?? host.envString('KEEPER_USER') ?? host.envString('KEEPER_USERNAME') - const summary = v.getSummary?.() - const lines = [`username: ${username ?? '(unknown)'}`] - if (summary) { - lines.push( - `records: ${summary.recordCount}`, - `folders: ${summary.folderCount}`, - `shared_folders: ${summary.sharedFolderCount}` - ) + const info = await v.getWhoamiInfo!({ includeVaultCounts: verbose }) + + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: formatWhoamiJson(info, { verbose }), err: '' } } - return { code: 0, out: lines.join('\n') + '\n', err: '' } + + return { code: 0, out: formatWhoamiOutput(info, { verbose }), err: '' } } export const listCommand: CliCommandDefinition = { name: 'list', order: 14, aliases: ['l'], - description: 'List all vault records (Commander table).', - usage: 'list [--verbose|-v] [--json]', - flagOptions: ['--json', '--verbose', '-v'], + description: 'List all records', + usage: 'list [--format {table,csv,json,pdf}] [--output OUTPUT] [-v] [-t RECORD_TYPE] [--field FIELD] [pattern]', + flagOptions: ['--format', '--output', '-v', '--verbose', '-t', '--type', '--field', '--json'], + valueShortFlags: ['t'], help: { - title: 'list — all records (Keeper Commander)', - synopsis: 'usage: list [--verbose]', - description: - ' Syncs and prints every record in a Commander-style table: uid, type, title, description, shared, and record category.', - options: ' --verbose, -v Do not truncate long columns (default max width 40).', - seeAlso: ' get, search, ls', + description: 'List all records', + usage: '[-h] [--format {table,csv,json,pdf}] [--output OUTPUT] [-v] [-t RECORD_TYPE] [--field FIELD] [pattern]', + positionals: [{ name: 'pattern', nargs: '?', help: 'search pattern' }], + options: [ + { + flags: '--format', + choices: 'table,csv,json,pdf', + help: 'format of output', + }, + { + flags: '--output', + metavar: 'OUTPUT', + help: 'path to resulting output file (ignored for "table" format)', + }, + { flags: '-v, --verbose', help: 'verbose output' }, + { + flags: '-t, --type', + metavar: 'RECORD_TYPE', + help: 'List records of certain types. Can be repeated', + }, + { + flags: '--field', + metavar: 'FIELD', + help: 'Filter records by specific field(s). Can be specified multiple times.', + }, + ], }, async run(host, parsed) { if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listCommand), err: '' } try { - return await runList(host, parsed) + return await executeList(host, parsed) } catch (e) { return { code: 1, out: '', err: host.formatError('list', e) } } @@ -123,13 +110,17 @@ export const searchCommand: CliCommandDefinition = { usage: 'search [--json]', flagOptions: ['--json', '--pattern'], help: { - title: 'search — find records (Keeper Commander)', - synopsis: 'usage: search ', - description: - ' Space-separated terms; all terms must match somewhere in the record (title, fields, or UID).\n' + - ' For exact lookup by UID, use get instead.', - examples: ' search amazon\n search bank account\n get zhJdqy7lb_zIEeCJT7GLlQ', - seeAlso: ' get, ls', + description: 'Search the vault. Words can be in any order.', + usage: '[-h] [--json] [pattern ...]', + positionals: [ + { + name: 'pattern', + nargs: '*', + help: 'search terms (space-separated, order independent)', + }, + ], + options: [{ flags: '--json', help: 'emit JSON' }], + epilog: 'For exact lookup by UID, use get instead of search.', }, async run(host, parsed) { if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(searchCommand), err: '' } @@ -145,18 +136,35 @@ export const listSfCommand: CliCommandDefinition = { name: 'list-sf', order: 16, aliases: ['lsf'], - description: 'List shared folders.', - usage: 'list-sf [pattern] [--verbose] [--json]', - flagOptions: ['--verbose', '-v', '--json', '--pattern'], + description: 'List all shared folders', + usage: 'list-sf [--format {table,csv,json,pdf}] [--output OUTPUT] [--roe-eligible] [pattern]', + flagOptions: ['--format', '--output', '--roe-eligible', '--roe', '--json', '--pattern'], help: { - title: 'list-sf — shared folders (Keeper Commander)', - synopsis: 'usage: list-sf [pattern]', - seeAlso: ' ls, get', + description: 'List all shared folders', + usage: '[-h] [--format {table,csv,json,pdf}] [--output OUTPUT] [--roe-eligible] [pattern]', + positionals: [{ name: 'pattern', nargs: '?', help: 'search pattern' }], + options: [ + { + flags: '--format', + choices: 'table,csv,json,pdf', + help: 'format of output', + }, + { + flags: '--output', + metavar: 'OUTPUT', + help: 'path to resulting output file (ignored for "table" format)', + }, + { + flags: '--roe-eligible', + help: + 'only list shared folders eligible for --rotate-on-expiration (contain at least one pamUser record with rotation configured)', + }, + ], }, async run(host, parsed) { if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listSfCommand), err: '' } try { - return await runListSf(host, parsed) + return await executeListSf(host, parsed) } catch (e) { return { code: 1, out: '', err: host.formatError('list-sf', e) } } @@ -166,20 +174,21 @@ export const listSfCommand: CliCommandDefinition = { export const whoamiCommand: CliCommandDefinition = { name: 'whoami', order: 18, - description: 'Display current user and vault counts.', - usage: 'whoami', + description: 'Display current user and account information.', + usage: 'whoami [--verbose|-v] [--json]', + flagOptions: ['--verbose', '-v', '--json'], help: { - title: 'whoami — current user (Keeper Commander)', - synopsis: 'usage: whoami', - seeAlso: ' login, sync-down', + description: 'Display current user and account information.', + usage: '[-h] [-v] [--json]', + options: [ + { flags: '-v, --verbose', help: 'include vault counts and reporting status' }, + { flags: '--json', help: 'emit JSON' }, + ], }, async run(host, parsed) { if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(whoamiCommand), err: '' } - if (parsed.opts.size > 0 || parsed.positional.length > 0) { - return { code: 1, out: '', err: 'whoami: unexpected arguments\n' } - } try { - return await runWhoami(host) + return await runWhoami(host, parsed) } catch (e) { return { code: 1, out: '', err: host.formatError('whoami', e) } } diff --git a/KeeperSdk/src/cli/commander/nav.ts b/KeeperSdk/src/cli/commander/nav.ts index 0ff0e268..7d8e85e1 100644 --- a/KeeperSdk/src/cli/commander/nav.ts +++ b/KeeperSdk/src/cli/commander/nav.ts @@ -1,86 +1,8 @@ import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { hasOpt, wantsCliHelp } from '../parse' import { formatDetailedHelpForCommand } from '../help' import { ensureCapability, ensureSession } from '../commandHelpers' -import { formatTable } from '../table' - -function lsPath(parsed: ParsedCli): string | undefined { - return parsed.positional[0] -} - -function formatLs( - result: { - detail: boolean - folders: Array<{ uid: string; name: string }> - records: Array<{ uid: string; name: string; type?: string }> - }, - detail: boolean -): string { - if (result.folders.length + result.records.length === 0) return '(empty)\n' - - const headers = detail ? ['flags', 'uid', 'name', 'type'] : ['kind', 'uid', 'name'] - const rows: string[][] = [] - for (const f of result.folders) { - const flags = ((f as { flags?: string }).flags ?? '').trim() - rows.push(detail ? [flags || 'f---', f.uid, f.name, ''] : ['dir', f.uid, f.name]) - } - for (const r of result.records) { - const flags = ((r as { flags?: string }).flags ?? '').trim() - const type = r.type ?? '' - rows.push(detail ? [flags || 'r---', r.uid, r.name, type] : ['rec', r.uid, r.name]) - } - return formatTable(headers, rows) -} - -async function runLs(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'listFolder', cmd) - if (cap) return cap - await v.sync() - - const detail = hasOpt(parsed.opts, 'detail') || hasOpt(parsed.opts, 'list') || hasOpt(parsed.opts, 'l') - const foldersOnly = hasOpt(parsed.opts, 'folders') || hasOpt(parsed.opts, 'f') - const recordsOnly = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') - const target = lsPath(parsed) - - const listOpts = { - detail, - showFolders: recordsOnly ? false : true, - showRecords: foldersOnly ? false : true, - } - - if (!target) { - const result = await v.listFolder!({ ...listOpts }) - return { code: 0, out: formatLs(result, detail), err: '' } - } - - if (!v.changeDirectory || !v.getCurrentFolderUid) { - return { code: 1, out: '', err: `${cmd}: host lacks navigation capabilities.\n` } - } - - const originalUid = v.getCurrentFolderUid() - let resolvedUid: string | null - try { - const cd = await v.changeDirectory(target) - resolvedUid = cd.folderUid - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } - } - try { - const result = await v.listFolder!({ folderUid: resolvedUid ?? null, ...listOpts }) - return { code: 0, out: formatLs(result, detail), err: '' } - } finally { - if (resolvedUid !== originalUid) { - try { - await v.changeDirectory(originalUid ?? '/') - } catch { - /* best-effort */ - } - } - } -} +import { executeLs } from './lsCore' async function runCd(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { const target = parsed.positional[0] @@ -106,7 +28,8 @@ async function runTree(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Pro if (cap) return cap await v.sync() const folderPath = parsed.positional[0] - const out = await v.tree!(folderPath ? { folderPath, showRecords: true } : { showRecords: true }) + const showRecords = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') + const out = await v.tree!(folderPath ? { folderPath, showRecords } : { showRecords }) return { code: 0, out: out.endsWith('\n') ? out : out + '\n', err: '' } } @@ -137,28 +60,58 @@ async function runMkdir(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Pr } const lsHelp: CliCommandDefinition['help'] = { - title: 'ls — list folder contents (Keeper Commander)', - synopsis: 'usage: ls [-l] [-f] [-r] [pattern]', - description: ' Lists records and subfolders in the current folder, or in PATH if given.', - options: ` -l, --list Detailed list (flags, types). - -f, --folders Folders only. - -r, --records Records only. - --help, -h Show this help.`, - examples: ' ls\n ls "Marketing"\n ls -l', - seeAlso: ' cd, tree, get', + description: 'List folder contents.', + usage: '[-h] [--format {table,csv,json,pdf}] [--output OUTPUT] [-l] [-f] [-r] [-s] [-v] [-R] [pattern]', + positionals: [{ name: 'pattern', nargs: '?', help: 'search pattern' }], + options: [ + { + flags: '--format', + choices: 'table,csv,json,pdf', + help: 'format of output', + }, + { + flags: '--output', + metavar: 'OUTPUT', + help: 'path to resulting output file (ignored for "table" format)', + }, + { flags: '-l, --list', help: 'show detailed list' }, + { flags: '-f, --folders', help: 'display folders only' }, + { flags: '-r, --records', help: 'display records only' }, + { flags: '-s, --short', help: 'Do not display record details. (Not used)' }, + { flags: '-v, --verbose', help: 'verbose output' }, + { flags: '-R, --recursive', help: 'list all folders/records in subfolders' }, + ], } export const lsCommand: CliCommandDefinition = { name: 'ls', order: 11, - description: 'List folder contents (current folder or PATH).', - usage: 'ls [PATH] [-l|--list] [-f|--folders] [-r|--records]', - flagOptions: ['-l', '--list', '-f', '--folders', '-r', '--records', '--detail'], + description: 'List folder contents.', + usage: 'ls [--format {table,csv,json,pdf}] [--output OUTPUT] [-l] [-f] [-r] [-s] [-v] [-R] [pattern]', + flagOptions: [ + '--format', + '--output', + '-l', + '--list', + '-f', + '--folders', + '-r', + '--records', + '-s', + '--short', + '-v', + '--verbose', + '-R', + '--recursive', + '--detail', + '--json', + '--pattern', + ], help: lsHelp, async run(host, parsed) { if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(lsCommand), err: '' } try { - return await runLs(host, parsed, 'ls') + return await executeLs(host, parsed, 'ls') } catch (e) { return { code: 1, out: '', err: host.formatError('ls', e) } } @@ -171,11 +124,9 @@ export const cdCommand: CliCommandDefinition = { description: 'Change current folder.', usage: 'cd ', help: { - title: 'cd — change current folder (Keeper Commander)', - synopsis: 'usage: cd ', - description: ' PATH is a slash-separated folder name/UID sequence, or `/` for vault root.', - examples: ' cd Marketing\n cd ..\n cd /', - seeAlso: ' ls, tree', + description: 'Change current folder', + usage: '[-h] folder', + positionals: [{ name: 'folder', help: 'folder path or UID' }], }, async run(host, parsed) { if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(cdCommand), err: '' } @@ -191,13 +142,13 @@ export const treeCommand: CliCommandDefinition = { name: 'tree', order: 13, description: 'Display the folder structure.', - usage: 'tree [PATH]', + usage: 'tree [PATH] [-r|--records]', + flagOptions: ['-r', '--records'], help: { - title: 'tree — folder structure (Keeper Commander)', - synopsis: 'usage: tree [folder]', - description: - ' Renders an ASCII tree from PATH or the vault root. Each node is tagged [folder], [shared folder], or [record].', - seeAlso: ' ls, cd', + description: 'Display the folder structure.', + usage: '[-h] [-r] [folder]', + positionals: [{ name: 'folder', nargs: '?', help: 'folder path or UID' }], + options: [{ flags: '-r, --records', help: 'show records within each folder' }], }, async run(host, parsed) { if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(treeCommand), err: '' } @@ -216,12 +167,10 @@ export const mkdirCommand: CliCommandDefinition = { usage: 'mkdir [-sf|--shared-folder]', flagOptions: ['-sf', '--shared-folder', '--shared'], help: { - title: 'mkdir — create folder (Keeper Commander)', - synopsis: 'usage: mkdir [-sf]', - description: ' Creates a user folder under the current folder. -sf creates a shared folder.', - options: ' -sf, --shared-folder Create a shared folder.', - examples: ' mkdir Drafts\n mkdir TeamShare -sf', - seeAlso: ' cd, ls', + description: 'Create a folder', + usage: '[-h] [-sf] path', + positionals: [{ name: 'path', help: 'folder path' }], + options: [{ flags: '-sf, --shared-folder', help: 'create a shared folder' }], }, async run(host, parsed) { if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(mkdirCommand), err: '' } diff --git a/KeeperSdk/src/cli/commander/reportOutput.ts b/KeeperSdk/src/cli/commander/reportOutput.ts new file mode 100644 index 00000000..54b96a5f --- /dev/null +++ b/KeeperSdk/src/cli/commander/reportOutput.ts @@ -0,0 +1,58 @@ +import type { CliResult, ParsedCli } from '../types' +import { getOpt, hasOpt } from '../parse' + +export const REPORT_FORMATS = new Set(['table', 'csv', 'json', 'pdf']) + +export function resolveReportFormat(parsed: ParsedCli): string { + const fmt = getOpt(parsed.opts, 'format')?.toLowerCase() + if (fmt) return fmt + if (hasOpt(parsed.opts, 'json')) return 'json' + return 'table' +} + +export function escapeCsvCell(value: string): string { + if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"` + return value +} + +export function tableToCsv(headers: string[], rows: string[][]): string { + const lines = [ + headers.map(escapeCsvCell).join(','), + ...rows.map((row) => row.map((cell) => escapeCsvCell(cell ?? '')).join(',')), + ] + return `${lines.join('\n')}\n` +} + +export function validateReportFormat(command: string, fmt: string): CliResult | null { + if (!REPORT_FORMATS.has(fmt)) { + return { code: 1, out: '', err: `${command}: invalid --format ${fmt} (choose table, csv, json, pdf)\n` } + } + if (fmt === 'pdf') { + return { code: 1, out: '', err: `${command}: pdf output is not supported in this CLI\n` } + } + return null +} + +export function validateReportOutput(command: string, fmt: string, outputPath?: string): CliResult | null { + if (outputPath && fmt !== 'table') { + return { + code: 1, + out: '', + err: `${command}: --output file write is not supported in this host; omit --output to print to stdout\n`, + } + } + return null +} + +export function emptyReportResult(command: string, fmt: string, message: string, outputPath?: string): CliResult { + if (fmt === 'json') { + const bad = validateReportOutput(command, fmt, outputPath) + if (bad) return bad + return { code: 0, out: `${JSON.stringify([], null, 2)}\n`, err: '' } + } + if (fmt === 'csv') { + const bad = validateReportOutput(command, fmt, outputPath) + if (bad) return bad + } + return { code: 0, out: message, err: '' } +} diff --git a/KeeperSdk/src/cli/commands/help.ts b/KeeperSdk/src/cli/commands/help.ts index 17bae201..34e01a62 100644 --- a/KeeperSdk/src/cli/commands/help.ts +++ b/KeeperSdk/src/cli/commands/help.ts @@ -15,15 +15,9 @@ export const helpCommand: CliCommandDefinition = { description: 'Show all commands, or full docs for one command (same as COMMAND --help).', usage: 'help [command] (see also: help --help)', help: { - title: 'help — show commands or short syntax for one command', - synopsis: ' help [COMMAND]', - description: ` Without arguments, lists commands for the current session. - When not logged in, only sign-in commands are listed (login, restore-session, …). - After login, lists vault commands as well. - - With COMMAND, prints usage for that command (sign-in commands only when logged out).`, - options: ' None. This command does not take GNU-style flags.', - seeAlso: ' Each command’s --help output.', + description: 'Show all commands, or full docs for one command (same as COMMAND --help).', + usage: '[-h] [command]', + positionals: [{ name: 'command', nargs: '?', help: 'command name' }], }, async run(host, parsed) { if (wantsCliHelp(parsed)) { diff --git a/KeeperSdk/src/cli/commands/login.ts b/KeeperSdk/src/cli/commands/login.ts index b4e2a710..5881462a 100644 --- a/KeeperSdk/src/cli/commands/login.ts +++ b/KeeperSdk/src/cli/commands/login.ts @@ -122,7 +122,7 @@ export async function ensureLoggedIn(host: KeeperCliHost): Promise { return { code: 0, out: '', err: '' } } if (host.envString('KEEPER_USERNAME')) { - return runLoginCommand(host, { positional: [], opts: new Map() }) + return runLoginCommand(host, { positional: [], opts: new Map(), repeatedOpts: new Map() }) } return { code: 1, out: '', err: 'not logged in\n' } } @@ -144,35 +144,30 @@ export const loginCommand: CliCommandDefinition = { ], allowedOptions: LOGIN_ALLOWED, help: { - title: 'login — authenticate to Keeper (vault session)', - synopsis: ` login [--username|--user EMAIL_OR_NAME] - login [--username|--user U] [--session-token|--token|--st TOKEN] - login [--username|--user U] [--session-token TOKEN] [--session-token-plain]`, - description: ` Establishes a Keeper session. - - Username comes from --username / --user or KEEPER_USERNAME. - - Password MUST NOT appear on the CLI line (logging, proxies, browser history). - Automation: set KEEPER_PASSWORD in the environment when embedding in Node. - Web shell: run login with only a username; the UI prompts for a masked password - and sends it through the login transport, not in "line". - - Session token login: pass the token on the command line or via - KEEPER_SESSION_TOKEN (sensitive — same caveats as any secret on argv). - - --session-token-plain treats the value as plain UTF-8 and encodes base64url - before login (same idea as the session_token_login example). - - Device registration: session token login requires deviceToken + privateKey for - this host in session storage (e.g. ~/.keeper/config.json) or a prior password - login in this shell.`, - options: ` --username, --user Account identifier (often email). - --session-token, --token, --st Session token string (or use KEEPER_SESSION_TOKEN). - --session-token-plain Treat --session-token value as plain UTF-8 and encode base64url.`, - environment: ` KEEPER_USERNAME Default username if not passed on the command line. - KEEPER_PASSWORD Password for non-interactive login (no session token). - KEEPER_SESSION_TOKEN Session token when not passed as a flag. - KEEPER_HOST Optional vault host / region (also: keeper-host attribute).`, + description: + 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN).', + usage: + '[-h] [--username USER] [--session-token TOKEN] [--session-token-plain]', + options: [ + { flags: '--username, --user', metavar: 'USER', help: 'account identifier (often email)' }, + { + flags: '--session-token, --token, --st', + metavar: 'TOKEN', + help: 'session token string (or use KEEPER_SESSION_TOKEN)', + }, + { + flags: '--session-token-plain', + help: 'treat --session-token value as plain UTF-8 and encode base64url', + }, + ], + epilog: `Password MUST NOT appear on the CLI line. Use KEEPER_PASSWORD for automation, or run +login with only a username and enter the password when prompted (masked). + +environment variables: + KEEPER_USERNAME default username if not passed on the command line + KEEPER_PASSWORD password for non-interactive login (no session token) + KEEPER_SESSION_TOKEN session token when not passed as a flag + KEEPER_HOST optional vault host / region`, }, run: (host, parsed) => runLoginCommand(host, parsed), } diff --git a/KeeperSdk/src/cli/commands/logout.ts b/KeeperSdk/src/cli/commands/logout.ts index aa70924a..b6374451 100644 --- a/KeeperSdk/src/cli/commands/logout.ts +++ b/KeeperSdk/src/cli/commands/logout.ts @@ -30,10 +30,7 @@ export const logoutCommand: CliCommandDefinition = { description: 'Log out of the current Keeper session.', usage: 'logout [--help|-h]', help: { - title: 'logout — end the current Keeper session', - synopsis: ' logout', - description: ' Ends the current session if one exists.', - options: ' None.', + description: 'Log out of the current Keeper session.', }, run: (host, parsed) => runLogoutCommand(host, parsed), } diff --git a/KeeperSdk/src/cli/commands/restoreSession.ts b/KeeperSdk/src/cli/commands/restoreSession.ts index 7e416d59..5054abbe 100644 --- a/KeeperSdk/src/cli/commands/restoreSession.ts +++ b/KeeperSdk/src/cli/commands/restoreSession.ts @@ -8,7 +8,6 @@ import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' import { formatDetailedHelpForCommand } from '../help' import { runVaultSync } from './sync' -/** Flags allowed to follow `--from-json ` on the same line. */ export const RESTORE_SESSION_TRAILING_OPTS = [ 'sync', 'account-uid', @@ -143,32 +142,42 @@ export const restoreSessionCommand: CliCommandDefinition = { ], allowedOptions: RESTORE_ALLOWED, help: { - title: 'restore-session — restore SessionParams from extension / vault export', - synopsis: ` restore-session --from-json session.json - restore-session --session-token TOKEN --username U --account-uid B64 …`, - description: ` Loads a full SessionParams snapshot and resumes the session (same path as - the browser extension after login). Use this when you have accountUid, - clientKey, dataKey, keys, sessionToken, username, etc. from extension storage - — deviceToken/device private key are not part of this payload. + description: + 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', + usage: '[-h] [--from-json JSON] [--sync] [session fields as flags]', + options: [ + { + flags: '--from-json', + metavar: 'JSON', + help: 'inline JSON object/string or path to a session JSON file', + }, + { flags: '--account-uid', metavar: 'B64', help: 'account UID (base64)' }, + { flags: '--client-key', metavar: 'B64', help: 'client key (base64)' }, + { flags: '--data-key', metavar: 'B64', help: 'data key (base64)' }, + { flags: '--ecc-private-key', metavar: 'B64', help: 'ECC private key (base64)' }, + { flags: '--ecc-public-key', metavar: 'B64', help: 'ECC public key (base64)' }, + { flags: '--message-session-uid', metavar: 'B64', help: 'message session UID (base64)' }, + { flags: '--private-key', metavar: 'B64', help: 'RSA private key (base64)' }, + { + flags: '--session-token, --st', + metavar: 'TOKEN', + help: 'session token string (as stored; often base64url)', + }, + { flags: '--session-token-type', metavar: 'N', help: 'numeric SessionTokenType enum' }, + { flags: '--username, --user', metavar: 'USER', help: 'account username' }, + { flags: '--user-type', metavar: 'TYPE', help: '0=normal, 1=cloud_sso, 2=onsite_sso' }, + { flags: '--sso-logout-url', metavar: 'URL', help: 'SSO logout URL' }, + { flags: '--sso-session-id', metavar: 'ID', help: 'SSO session ID' }, + { flags: '--enterprise-public-key', metavar: 'B64', help: 'enterprise public key (optional)' }, + { flags: '--enterprise-ecc-public-key', metavar: 'B64', help: 'enterprise ECC public key (optional)' }, + { flags: '--sync', help: 'run syncDown after restoring the session' }, + ], + epilog: `Provide parameters either as one JSON object (--from-json) or as flags / env. +Binary fields are base64 or base64url. sessionToken expires; region must match keeper-host / KEEPER_HOST. - Provide parameters either as one JSON object (--from-json) or as flags / env. - Binary fields are base64 or base64url.`, - options: ` --from-json Inline JSON (object or JSON-stringified object), or a file path - The entire remainder of the command line is passed to JSON.parse (then file read if needed). - --account-uid, --client-key, --data-key, --ecc-private-key, --ecc-public-key - --message-session-uid, --private-key - --session-token, --st Session token string (as stored; often base64url) - --session-token-type Numeric SessionTokenType enum - --username, --user - --user-type 0=normal, 1=cloud_sso, 2=onsite_sso (or string names) - --sso-logout-url, --sso-session-id - --enterprise-public-key, --enterprise-ecc-public-key (optional) - --sync Run syncDown after restoring the session`, - environment: ` RESTORE_SESSION_JSON Same as --from-json - RESTORE_SESSION_ACCOUNT_UID Per-field overrides (see --help flags) - RESTORE_SESSION_SESSION_TOKEN - … (RESTORE_SESSION_ for each field above)`, - note: ' sessionToken expires; region must match keeper-host / KEEPER_HOST.', +environment variables: + RESTORE_SESSION_JSON same as --from-json + RESTORE_SESSION_ per-field overrides (see flags above)`, }, async run(host, parsed) { if (wantsCliHelp(parsed)) { diff --git a/KeeperSdk/src/cli/commands/sync.ts b/KeeperSdk/src/cli/commands/sync.ts index 72d71f67..fb983c28 100644 --- a/KeeperSdk/src/cli/commands/sync.ts +++ b/KeeperSdk/src/cli/commands/sync.ts @@ -17,7 +17,6 @@ function formatSyncSummary(result: SyncResult): string { return lines.join('\n') + '\n' } -/** Download vault data via keeperapi syncDown (KeeperVault.sync). */ export async function runVaultSync(host: KeeperCliHost): Promise { const v = host.getVault() if (!v.isLoggedIn) { @@ -35,12 +34,7 @@ export const syncCommand: CliCommandDefinition = { description: 'Download / refresh vault data from Keeper (syncDown).', usage: 'sync [--help|-h]', help: { - title: 'sync — download vault data (syncDown)', - synopsis: ' sync', - description: ` Pulls records, folders, and related vault data into local storage. - Requires an active session (login or restore-session).`, - options: ' --help, -h Show this help.', - seeAlso: ' restore-session --sync, list, ls', + description: 'Download / refresh vault data from Keeper (syncDown).', }, async run(host, parsed) { if (wantsCliHelp(parsed)) { diff --git a/KeeperSdk/src/cli/commands/vault.ts b/KeeperSdk/src/cli/commands/vault.ts index 4bdaf979..3853ea39 100644 --- a/KeeperSdk/src/cli/commands/vault.ts +++ b/KeeperSdk/src/cli/commands/vault.ts @@ -31,13 +31,10 @@ export const vaultCommand: CliCommandDefinition = { subcommands: ['summary'], flagOptions: ['--json'], help: { - title: 'vault — vault-wide statistics', - synopsis: ' vault summary [--json]', - description: ' Runs sync, then prints counts from the local vault cache.', - arguments: ' summary Print record, shared folder, and user-folder counts.', - options: ' --json Emit JSON.\n --help, -h Show this help.', - examples: ' vault summary\n vault summary --json', - seeAlso: ' sync, list, tree, whoami', + description: 'Vault summary counts (records, folders, shared folders).', + usage: '[-h] [--json] summary', + positionals: [{ name: 'summary', help: 'print record, shared folder, and user-folder counts' }], + options: [{ flags: '--json', help: 'emit JSON' }], }, async run(host, parsed) { if (wantsCliHelp(parsed)) { diff --git a/KeeperSdk/src/cli/dispatch.ts b/KeeperSdk/src/cli/dispatch.ts index ba49e697..9e913453 100644 --- a/KeeperSdk/src/cli/dispatch.ts +++ b/KeeperSdk/src/cli/dispatch.ts @@ -5,6 +5,15 @@ import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' import { formatDetailedHelpForCommand } from './help' import { isAuthCliCommand } from './access' import { getCliCommand } from './registry' +import type { CliCommandDefinition } from './types' + +function valueShortFlagsForCommand(def: CliCommandDefinition): ReadonlySet { + const out = new Set() + for (const flag of def.valueShortFlags ?? []) { + out.add(flag.replace(/^-+/, '').toLowerCase()) + } + return out +} const NOT_LOGGED_IN_ERR = 'Not logged in. Run `login` or `restore-session` (see `help`).\n' @@ -22,7 +31,7 @@ export async function dispatchKeeperCli( if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { return { code: 1, out: '', err: NOT_LOGGED_IN_ERR } } - const parsed = preParsed ?? parseCliArgs(args) + const parsed = preParsed ?? parseCliArgs(args, { valueShortFlags: valueShortFlagsForCommand(def) }) if (wantsCliHelp(parsed)) { return { code: 0, out: formatDetailedHelpForCommand(def), err: '' } } @@ -40,11 +49,12 @@ export async function dispatchCliLine(line: string, host: KeeperCliHost): Promis return { code: 0, out: '', err: '' } } const args = tokens.slice(1) + const def = getCliCommand(name) let preParsed: ParsedCli | undefined - if (name === 'restore-session') { + if (name === 'restore-session' && def) { const json = extractFromJsonFlagValue(trimmed, 'from-json', RESTORE_SESSION_TRAILING_OPTS) if (json) { - preParsed = parseCliArgs(args) + preParsed = parseCliArgs(args, { valueShortFlags: valueShortFlagsForCommand(def) }) preParsed.opts.set('from-json', json) } } diff --git a/KeeperSdk/src/cli/help.ts b/KeeperSdk/src/cli/help.ts index 40773a4f..b4edcd72 100644 --- a/KeeperSdk/src/cli/help.ts +++ b/KeeperSdk/src/cli/help.ts @@ -1,44 +1,141 @@ -import type { CliCommandDefinition, CliHelpDoc } from './types' - -const SECTION_ORDER: (keyof CliHelpDoc)[] = [ - 'synopsis', - 'description', - 'arguments', - 'options', - 'environment', - 'examples', - 'seeAlso', - 'note', -] - -const SECTION_LABELS: Partial> = { - synopsis: 'SYNOPSIS', - description: 'DESCRIPTION', - arguments: 'ARGUMENTS', - options: 'OPTIONS', - environment: 'ENVIRONMENT', - examples: 'EXAMPLES', - seeAlso: 'SEE ALSO', - note: 'NOTE', +import type { CliCommandDefinition, CliHelpDoc, CliHelpOption, CliHelpPositional } from './types' +import { listCliCommands } from './registry' + +const HELP_INDENT = 2 +const MAX_HELP_POSITION = 24 +const HELP_WIDTH = 80 + +export const STANDARD_HELP_OPTION: CliHelpOption = { + flags: '-h, --help', + help: 'show this help message and exit', +} + +function wrapText(text: string, width: number, indent: string): string[] { + const words = text.split(/\s+/).filter(Boolean) + if (words.length === 0) return [] + const lines: string[] = [] + let line = '' + for (const word of words) { + const next = line ? `${line} ${word}` : word + if (next.length > width) { + if (line) lines.push(indent + line) + line = word + } else { + line = next + } + } + if (line) lines.push(indent + line) + return lines +} + +function formatHelpEntry(invocation: string, help: string): string[] { + const prefix = ' '.repeat(HELP_INDENT) + const header = prefix + invocation + const helpCol = HELP_INDENT + MAX_HELP_POSITION + const contIndent = ' '.repeat(helpCol) + const wrapWidth = HELP_WIDTH - helpCol + + const paragraphs = help.split('\n') + const lines: string[] = [] + + for (let p = 0; p < paragraphs.length; p++) { + const paragraph = paragraphs[p].trim() + if (!paragraph) continue + const wrapped = wrapText(paragraph, wrapWidth, contIndent) + + if (p === 0) { + if (header.length < helpCol) { + lines.push(header + ' '.repeat(helpCol - header.length) + wrapped[0].slice(contIndent.length)) + lines.push(...wrapped.slice(1)) + } else { + lines.push(header) + lines.push(...wrapped) + } + } else { + lines.push('') + lines.push(...wrapped) + } + } + + return lines +} + +function positionalUsageToken(pos: CliHelpPositional): string { + if (pos.nargs === '*') return `[${pos.name} ...]` + if (pos.nargs === '+') return `${pos.name} [${pos.name} ...]` + if (pos.nargs === '?') return `[${pos.name}]` + return pos.name +} + +function optionHelpInvocation(opt: CliHelpOption): string { + if (opt.choices) { + const flag = opt.flags.split(',')[0].trim() + return `${flag} {${opt.choices}}` + } + if (opt.metavar) { + const flag = opt.flags.split(',')[0].trim() + return `${flag} ${opt.metavar}` + } + return opt.flags +} + +function buildUsage(def: CliCommandDefinition, doc: CliHelpDoc): string { + if (doc.usage) return `usage: ${def.name} ${doc.usage}` + + const tokens: string[] = ['[-h]'] + for (const opt of doc.options ?? []) { + if (opt.flags.includes('--help') || opt.flags.includes('-h')) continue + tokens.push(optionHelpInvocation(opt)) + } + for (const pos of doc.positionals ?? []) { + tokens.push(positionalUsageToken(pos)) + } + return `usage: ${def.name} ${tokens.join(' ')}` +} + +function appendSection(lines: string[], title: string, body: string[]): void { + if (body.length === 0) return + lines.push('') + lines.push(title) + lines.push(...body) } -export function formatDetailedHelp(doc: CliHelpDoc): string { - const parts: string[] = [doc.title.trim()] - for (const key of SECTION_ORDER) { - const body = doc[key] - if (typeof body !== 'string' || !body.trim()) continue - const label = SECTION_LABELS[key] - if (label) { - parts.push('') - parts.push(label) +export function formatArgparseHelp(def: CliCommandDefinition): string { + const doc = def.help + const lines: string[] = [buildUsage(def, doc), '', doc.description.trim()] + + const positionals = doc.positionals ?? [] + if (positionals.length > 0) { + const body: string[] = [] + for (const pos of positionals) { + body.push(...formatHelpEntry(pos.name, pos.help)) } - parts.push(body.trim()) + appendSection(lines, 'positional arguments:', body) } - return `${parts.join('\n')}\n` + + const options = [...(doc.options ?? []), STANDARD_HELP_OPTION] + const hasHelpAlready = (doc.options ?? []).some( + (o) => o.flags.includes('--help') || o.flags.includes('-h') + ) + const optionList = hasHelpAlready ? (doc.options ?? []) : options + if (optionList.length > 0) { + const body: string[] = [] + for (const opt of optionList) { + body.push(...formatHelpEntry(optionHelpInvocation(opt), opt.help)) + } + appendSection(lines, 'options:', body) + } + + if (doc.epilog?.trim()) { + lines.push('') + lines.push(doc.epilog.trim()) + } + + return `${lines.join('\n')}\n` } export function formatDetailedHelpForCommand(def: CliCommandDefinition): string { - return formatDetailedHelp(def.help) + return formatArgparseHelp(def) } export function getDetailedHelpPageForRegistry( @@ -55,6 +152,10 @@ export function getDetailedHelpPageForRegistry( return null } +export function getDetailedHelpPage(name: string): string | null { + return getDetailedHelpPageForRegistry(listCliCommands(), name) +} + export type CommandsSummaryOptions = { header?: string footer?: string @@ -78,5 +179,5 @@ export function formatAllCommandsSummary( } export function formatShortCommandSummary(def: CliCommandDefinition): string { - return `${def.name} — ${def.description}\n Usage: ${def.usage}\n` + return `${def.name} — ${def.description}\n${buildUsage(def, def.help)}\n` } diff --git a/KeeperSdk/src/cli/index.ts b/KeeperSdk/src/cli/index.ts index a3c67c51..9327694e 100644 --- a/KeeperSdk/src/cli/index.ts +++ b/KeeperSdk/src/cli/index.ts @@ -3,7 +3,6 @@ import { registerCliAlias } from './registry' let registryInitialized = false -/** Register built-in Keeper CLI commands (idempotent). */ export function ensureKeeperCliRegistry(): void { if (registryInitialized) return registryInitialized = true @@ -27,23 +26,18 @@ export { parseCliArgs, hasOpt, getOpt, + getAllOpt, wantsCliHelp, rejectUnknownOptions, } from './parse' export { - formatDetailedHelp, formatDetailedHelpForCommand, formatAllCommandsSummary, formatShortCommandSummary, + getDetailedHelpPageForRegistry, + getDetailedHelpPage, } from './help' -import { getDetailedHelpPageForRegistry } from './help' -import { listCliCommands } from './registry' - -export function getDetailedHelpPage(name: string): string | null { - ensureKeeperCliRegistry() - return getDetailedHelpPageForRegistry(listCliCommands(), name) -} export { registerCliCommand, diff --git a/KeeperSdk/src/cli/jsonArg.ts b/KeeperSdk/src/cli/jsonArg.ts index 6a11c1bc..9162a96c 100644 --- a/KeeperSdk/src/cli/jsonArg.ts +++ b/KeeperSdk/src/cli/jsonArg.ts @@ -1,7 +1,3 @@ -/** - * Everything after `--from-json` on the command line (trimmed). No tokenization — callers use JSON.parse. - * Trailing flags such as `--sync` are stripped via {@link stripTrailingCliFlags}. - */ export function extractFromJsonFlagValue( line: string, flag = 'from-json', @@ -16,7 +12,6 @@ export function extractFromJsonFlagValue( return stripTrailingCliFlags(rest, trailingFlags) } -/** Remove trailing ` --flag` tokens (e.g. `--sync` after a file path or JSON blob). */ export function stripTrailingCliFlags(value: string, flagNames: readonly string[]): string { if (flagNames.length === 0) return value.trim() let s = value.trim() diff --git a/KeeperSdk/src/cli/parse.ts b/KeeperSdk/src/cli/parse.ts index 73f9ef15..1c653264 100644 --- a/KeeperSdk/src/cli/parse.ts +++ b/KeeperSdk/src/cli/parse.ts @@ -2,7 +2,6 @@ import type { CliResult, ParsedCli } from './types' const isWhitespace = (ch: string) => /\s/.test(ch) -/** Split a command line into tokens; respects double quotes and `\\` escapes. */ export function tokenizeArguments(args: string): string[] { const out: string[] = [] const sb: string[] = [] @@ -67,14 +66,32 @@ function setBool(opts: Map, k: string): void { opts.set(k.toLowerCase(), true) } -function setStr(opts: Map, k: string, v: string): void { - opts.set(k.toLowerCase(), v) +function setStr( + opts: Map, + repeated: Map, + k: string, + v: string +): void { + const key = k.toLowerCase() + const prev = opts.get(key) + if (prev !== undefined && prev !== true) { + const list = repeated.get(key) ?? [prev] + list.push(v) + repeated.set(key, list) + } else { + repeated.set(key, [v]) + } + opts.set(key, v) } -/** Parse argv-style tokens after the command name. */ -export function parseCliArgs(tokens: string[]): ParsedCli { +export function parseCliArgs( + tokens: string[], + options: { valueShortFlags?: ReadonlySet } = {} +): ParsedCli { + const valueShortFlags = options.valueShortFlags ?? new Set() const positional: string[] = [] const opts = new Map() + const repeatedOpts = new Map() let i = 0 while (i < tokens.length) { @@ -98,14 +115,14 @@ export function parseCliArgs(tokens: string[]): ParsedCli { } const eq = body.indexOf('=') if (eq >= 0) { - setStr(opts, body.slice(0, eq), body.slice(eq + 1)) + setStr(opts, repeatedOpts, body.slice(0, eq), body.slice(eq + 1)) i++ continue } const name = body const next = tokens[i + 1] if (next && next !== '--' && !next.startsWith('-')) { - setStr(opts, name, next) + setStr(opts, repeatedOpts, name, next) i += 2 continue } @@ -121,7 +138,19 @@ export function parseCliArgs(tokens: string[]): ParsedCli { continue } if (/^[A-Za-z]$/.test(rest)) { - setBool(opts, rest) + const name = rest + const next = tokens[i + 1] + if ( + valueShortFlags.has(name.toLowerCase()) && + next && + next !== '--' && + !next.startsWith('-') + ) { + setStr(opts, repeatedOpts, name, next) + i += 2 + continue + } + setBool(opts, name) i++ continue } @@ -135,7 +164,22 @@ export function parseCliArgs(tokens: string[]): ParsedCli { i++ } - return { positional, opts } + return { positional, opts, repeatedOpts } +} + +export function getAllOpt(parsed: ParsedCli, ...names: string[]): string[] { + const out: string[] = [] + for (const n of names) { + const key = n.toLowerCase() + const repeated = parsed.repeatedOpts.get(key) + if (repeated) { + out.push(...repeated) + continue + } + const v = parsed.opts.get(key) + if (v !== undefined && v !== true) out.push(v) + } + return out } export function hasOpt(opts: Map, ...names: string[]): boolean { diff --git a/KeeperSdk/src/cli/parser.ts b/KeeperSdk/src/cli/parser.ts index 2be6be47..fefefb61 100644 --- a/KeeperSdk/src/cli/parser.ts +++ b/KeeperSdk/src/cli/parser.ts @@ -9,13 +9,20 @@ import { formatAllCommandsSummary, formatDetailedHelpForCommand, formatShortComm const NOT_LOGGED_IN_ERR = 'Not logged in. Run `login` or `restore-session` (see `help`).\n' +function valueShortFlagsForCommand(def: CliCommandDefinition): ReadonlySet { + const out = new Set() + for (const flag of def.valueShortFlags ?? []) { + out.add(flag.replace(/^-+/, '').toLowerCase()) + } + return out +} + export type KeeperCliParserOptions = { prog?: string description?: string epilog?: string } -/** Self-contained CLI parser. Register commands, then `parse()` dispatches a line. */ export class KeeperCliParser { private readonly prog: string private readonly description: string @@ -123,12 +130,13 @@ export class KeeperCliParser { } let parsed: ParsedCli + const parseOpts = { valueShortFlags: valueShortFlagsForCommand(def) } if (def.name === 'restore-session') { const json = extractFromJsonFlagValue(raw, 'from-json', RESTORE_SESSION_TRAILING_OPTS) - parsed = parseCliArgs(rest) + parsed = parseCliArgs(rest, parseOpts) if (json) parsed.opts.set('from-json', json) } else { - parsed = parseCliArgs(rest) + parsed = parseCliArgs(rest, parseOpts) } if (wantsCliHelp(parsed)) { @@ -138,7 +146,6 @@ export class KeeperCliParser { } } -/** Parser pre-loaded with the SDK's built-in commands. */ export function createKeeperCliParser(options: KeeperCliParserOptions = {}): KeeperCliParser { const parser = new KeeperCliParser(options) void loadBuiltinsInto(parser) diff --git a/KeeperSdk/src/cli/table.ts b/KeeperSdk/src/cli/table.ts index 499f206c..e2cefae5 100644 --- a/KeeperSdk/src/cli/table.ts +++ b/KeeperSdk/src/cli/table.ts @@ -1,4 +1,3 @@ -/** Fixed-width column formatter. Last column is left unpadded. */ export function formatTable(headers: string[], rows: string[][]): string { if (rows.length === 0) return '' const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) diff --git a/KeeperSdk/src/cli/types.ts b/KeeperSdk/src/cli/types.ts index fb6cb6fc..109f8682 100644 --- a/KeeperSdk/src/cli/types.ts +++ b/KeeperSdk/src/cli/types.ts @@ -1,6 +1,6 @@ import type { DRecord, DSharedFolder, SyncResult } from '@keeper-security/keeperapi' import type { SessionRestoreInput } from '../auth/sessionRestore' -import type { ChangeDirectoryResult } from '../folders/changeDirectory' +import type { ChangeDirectoryResult, TryResolvePathResult } from '../folders/changeDirectory' import type { FolderTreeBuildOptions } from '../folders/folderTree' import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' @@ -10,12 +10,12 @@ import type { DeleteFolderResult } from '../folders/deleteFolder' import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' import type { RecordShareInfo } from '../sharing/Sharing' import type { VaultSummary } from '../vault/KeeperVault' +import type { WhoamiInfo } from '../account/whoamiInfo' export type CliResult = { code: number out: string err: string - /** Set when the host UI must prompt for a masked password (never on the CLI line). */ needPassword?: boolean loginUsername?: string } @@ -23,6 +23,7 @@ export type CliResult = { export type ParsedCli = { positional: string[] opts: Map + repeatedOpts: Map } /** @@ -39,6 +40,7 @@ export type KeeperCliVault = { getSharedFolders(): DSharedFolder[] restoreSession(input: SessionRestoreInput): Promise getSummary?: () => VaultSummary + getWhoamiInfo?: (options?: { includeVaultCounts?: boolean }) => Promise findRecord?: (uidOrTitle: string) => DRecord | undefined findRecords?: (criteria: string) => DRecord[] getRecordShareInfo?: (recordUid: string) => Promise @@ -46,6 +48,7 @@ export type KeeperCliVault = { listFolder?: (options?: ListFolderOptions) => Promise tree?: (options?: FolderTreeBuildOptions) => Promise changeDirectory?: (path: string) => Promise + tryResolvePath?: (path: string) => Promise getCurrentFolderUid?: () => string | null getWorkingFolderDisplayName?: () => string getFolder?: (uidOrName: string, options?: GetFolderOptions) => Promise @@ -63,16 +66,25 @@ export type KeeperCliHost = { getAccountUsername?: () => Promise } +export type CliHelpPositional = { + name: string + help: string + nargs?: '?' | '*' | '+' +} + +export type CliHelpOption = { + flags: string + help: string + metavar?: string + choices?: string +} + export type CliHelpDoc = { - title: string - synopsis?: string - description?: string - arguments?: string - options?: string - environment?: string - examples?: string - seeAlso?: string - note?: string + description: string + usage?: string + positionals?: CliHelpPositional[] + options?: CliHelpOption[] + epilog?: string } export type CliCommandDefinition = { @@ -83,7 +95,7 @@ export type CliCommandDefinition = { aliases?: readonly string[] subcommands?: readonly string[] flagOptions?: readonly string[] - /** When set, options outside this set are rejected (`--help` / `-h` always allowed). */ + valueShortFlags?: readonly string[] allowedOptions?: ReadonlySet help: CliHelpDoc run: (host: KeeperCliHost, parsed: ParsedCli) => Promise diff --git a/KeeperSdk/src/folders/changeDirectory.ts b/KeeperSdk/src/folders/changeDirectory.ts index 7f8aeb96..dc25c587 100644 --- a/KeeperSdk/src/folders/changeDirectory.ts +++ b/KeeperSdk/src/folders/changeDirectory.ts @@ -5,7 +5,7 @@ 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' diff --git a/KeeperSdk/src/folders/folderTree.ts b/KeeperSdk/src/folders/folderTree.ts index be047709..ee7ba7cb 100644 --- a/KeeperSdk/src/folders/folderTree.ts +++ b/KeeperSdk/src/folders/folderTree.ts @@ -11,7 +11,7 @@ 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 { resolveSingleFolder, VAULT_ROOT_DISPLAY_NAME, type VaultFolderSession } from './changeDirectory' import { FolderKind, VaultObjectKind, sharedFolderFolderName, sharedFolderName, userFolderName } from './folderHelpers' enum TreeItemKind { @@ -21,8 +21,7 @@ enum TreeItemKind { } const TREE_TAG = { - folder: '[folder]', - sharedFolder: '[shared folder]', + sharedFolder: '[SHARED]', record: '[record]', } as const @@ -137,18 +136,16 @@ async function collectSharedFolderPermissions( } function folderTreeTag( - userFolder: DUserFolder | undefined, + _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 + return sharedFolder ? TREE_TAG.sharedFolder : '' } function formatTreeNodeName(baseName: string, tag: string, verbose: boolean, uid?: string): string { const name = verbose && uid ? `${baseName} (${uid})` : baseName - return `${name} ${tag}` + return tag ? `${name} ${tag}` : name } function formatTreeRecordName(title: string, verbose: boolean, recordUid?: string): string { @@ -221,7 +218,7 @@ async function buildFolderSubtree( } async function buildVaultRootTree(storage: InMemoryStorage, opts: BuildOpts): Promise { - const node: FolderTreeNode = { displayName: '', children: [] } + 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) { @@ -316,17 +313,17 @@ function renderNode(node: FolderTreeNode, lines: string[], isRoot: boolean, pref lines.push(node.displayName) } } else { - const connector = isLast ? '\\-- ' : '+-- ' + const connector = isLast ? '└── ' : '├── ' lines.push(prefix + connector + node.displayName) } - const childBase = isRoot ? ' ' : prefix + (isLast ? ' ' : '| ') + 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 ? '\\-- ' : '+-- ' + const connector = isLastItem ? '└── ' : '├── ' lines.push(childBase + connector + item.display) } else { renderNode(item.node, lines, false, childBase, isLastItem) diff --git a/KeeperSdk/src/folders/listFolder.ts b/KeeperSdk/src/folders/listFolder.ts index 619dd4f9..fad61821 100644 --- a/KeeperSdk/src/folders/listFolder.ts +++ b/KeeperSdk/src/folders/listFolder.ts @@ -26,6 +26,7 @@ export type ListFolderOptions = { showFolders?: boolean showRecords?: boolean detail?: boolean + recursive?: boolean } export type ListFolderFolderSimple = { @@ -322,6 +323,32 @@ export async function listFolder(storage: InMemoryStorage, options: ListFolderOp } } + 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) + } + } + } + folderRows.sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) recordRows.sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) diff --git a/KeeperSdk/src/records/RecordUtils.ts b/KeeperSdk/src/records/RecordUtils.ts index f510d6af..fd2f6439 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -44,10 +44,55 @@ type LegacyExtraField = { } function toFieldValueArray(v: unknown): any[] { - if (v == null) return [] + if (v == null || v === '') return [] return Array.isArray(v) ? v : [v] } +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 + } + } + return undefined +} + function getLegacyExtraFields(record: DRecord): RecordField[] { const raw = record.extra if (raw == null) return [] @@ -126,7 +171,7 @@ export function getRecordFields(record: DRecord): RecordField[] { for (const f of record.data.fields) { fields.push({ type: f.type || FieldType.Text, - value: Array.isArray(f.value) ? f.value : [f.value], + value: normalizeTypedFieldValue(f.value), label: f.label, required: f.required, privacyScreen: f.privacyScreen, @@ -139,7 +184,7 @@ export function getRecordFields(record: DRecord): RecordField[] { for (const f of record.data.custom) { fields.push({ type: f.type || FieldType.Text, - value: Array.isArray(f.value) ? f.value : [f.value], + value: normalizeTypedFieldValue(f.value), label: f.label, required: f.required, privacyScreen: f.privacyScreen, @@ -191,10 +236,11 @@ 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() + const url = extractTotpUrlFromValue(v) + if (url) return url } } - return undefined + return getExtraTotpUrl(record) } export function getRecordPassword(record: DRecord): string | undefined { @@ -292,14 +338,32 @@ function resolveFormatRecordOptions(showDetailsOrOptions?: boolean | FormatRecor } 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) => (typeof v === 'string' ? v : JSON.stringify(v))).join(', ') + 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 }[] = [ @@ -318,11 +382,13 @@ export function formatRecordFields(record: DRecord, unmask: boolean): { name: st 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 - const label = TOTP_FIELD_TYPES.has(field.type) - ? 'TOTP URL' - : (field.label || field.type).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + 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 @@ -348,18 +414,20 @@ export function formatRecord(record: DRecord, showDetailsOrOptions?: boolean | F for (const field of summary.fields) { if (field.type === FieldType.Login || field.type === FieldType.Url) continue if (field.type === FieldType.Password) continue - const isTotp = TOTP_FIELD_TYPES.has(field.type) - const label = isTotp ? 'TOTP URL' : field.label || field.type - lines.push(`${label}: ${formatFieldValue(field, unmask)}`) + 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 (unmask && totpUrl) { - lines.push(`TOTP URL: ${totpUrl}`) - } - const code = totpUrl ? getTotpCode(totpUrl) : null - if (code) { - lines.push(`Two Factor Code: ${code.code} valid for ${code.secondsRemaining} sec`) + 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`) + } } } diff --git a/KeeperSdk/src/sharedFolders/listSharedFolders.ts b/KeeperSdk/src/sharedFolders/listSharedFolders.ts index ac484bf3..f5e123a2 100644 --- a/KeeperSdk/src/sharedFolders/listSharedFolders.ts +++ b/KeeperSdk/src/sharedFolders/listSharedFolders.ts @@ -1,4 +1,6 @@ import type { + DRecord, + DRecordRotation, DSharedFolder, DSharedFolderRecord, DSharedFolderTeam, @@ -7,11 +9,13 @@ import type { 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 + roeEligible?: boolean } export type ListSharedFolderRow = { @@ -87,16 +91,39 @@ function countRecordsForFolder(storage: InMemoryStorage, sharedFolderUid: string ) } +function recordHasRotationConfigured(storage: InMemoryStorage, recordUid: string): boolean { + const rotation = storage.getByUid('record_rotation', recordUid) + return rotation != null && rotation.disabled !== true +} + +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 = {} ): ListSharedFolderRow[] { - const { pattern, includeDetails = false } = options - const sharedFolders: DSharedFolder[] = pattern + const { pattern, includeDetails = false, roeEligible = false } = options + let sharedFolders: DSharedFolder[] = pattern ? findSharedFolders(storage, pattern) : storage.getAll(FolderKind.SharedFolder) - return sharedFolders.map((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 } @@ -110,7 +137,8 @@ export function listSharedFolders( row.default_can_share = sharedFolder.defaultCanShare } return row - }) + }) + .sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) } export type FormattedSharedFoldersTable = { diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 79ea58f7..73b8196c 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -50,7 +50,7 @@ import type { } 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 { 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' @@ -104,6 +104,7 @@ import type { TeamUserResult, FormattedTeamUserTable, } from '../users/userTypes' +import { buildWhoamiInfo, type WhoamiInfo } from '../account/whoamiInfo' import { ConsoleLogger, LogLevel, @@ -344,7 +345,28 @@ export class KeeperVault { } public async getAccountUsername(): Promise { - return this.sessionManager.getLastUsername() + 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 { @@ -608,6 +630,10 @@ export class KeeperVault { return this.folderManager.changeDirectory(path) } + public async tryResolvePath(path: string): Promise { + return this.folderManager.tryResolvePath(path) + } + public getCurrentFolderUid(): string | null { return this.folderManager.getCurrentFolderUid() } 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 From ba6a94b47688aeb5dd6c319bea88b0999cb282c8 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 18 Jun 2026 16:30:20 +0530 Subject: [PATCH 08/48] Code format and optimisation --- .../src/nestedShareFolders/addNsfRecord.ts | 44 ++++------ .../nestedShareFolders/getNsfRecordDetails.ts | 38 ++++---- KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 79 ++++++++--------- .../src/nestedShareFolders/nsfHelpers.ts | 88 ++++++++++++++++--- .../src/nestedShareFolders/removeNsfFolder.ts | 6 +- .../src/nestedShareFolders/updateNsfRecord.ts | 44 +++------- keeperapi/src/restMessages.ts | 7 +- 7 files changed, 167 insertions(+), 139 deletions(-) diff --git a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts index d5e0f6bb..847cae4f 100644 --- a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts @@ -2,6 +2,7 @@ import type { Auth, record as RecordProto } from '@keeper-security/keeperapi' import { Folder, Records, + generateEncryptionKey, generateUid, keeperDriveRecordsAdd, normal64Bytes, @@ -18,11 +19,11 @@ import { import { ensureNestedShareFolder, nsfToNumber, + parseRecordModifyStatus, + requireAuthDataKey, resolveNsfFolderIdentifier, } from './nsfHelpers' -const RECORD_KEY_BYTE_LENGTH = 32 - export type { NsfRecordFieldMap, NsfRecordCustomField } from './nsfRecordData' export type AddNsfRecordInput = { @@ -57,7 +58,7 @@ function resolveFolderUid(storage: InMemoryStorage, folderInput?: string): strin return folderUid } -async function resolveFolderKey(storage: InMemoryStorage, folderUid: string): Promise { +async function requireFolderKey(storage: InMemoryStorage, folderUid: string): Promise { const folderKey = await storage.getKeyBytes(folderUid) if (folderKey) return folderKey throw new KeeperSdkError( @@ -73,7 +74,7 @@ async function buildRecordAdd( folderUid?: string ): Promise<{ recordUid: string; recordAdd: RecordProto.v3.IRecordAdd }> { const recordUid = generateUid() - const recordKey = platform.getRandomBytes(RECORD_KEY_BYTE_LENGTH) + const recordKey = generateEncryptionKey() await storage.saveKeyBytes(recordUid, recordKey) const recordAdd: RecordProto.v3.IRecordAdd = { @@ -83,16 +84,13 @@ async function buildRecordAdd( } if (folderUid) { - const folderKey = await resolveFolderKey(storage, 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 { - if (!auth.dataKey) { - throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) - } - recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, auth.dataKey) + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, requireAuthDataKey(auth)) recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm } @@ -127,35 +125,29 @@ export async function addNestedShareRecord( custom: input.custom, }) - const folderUid = resolveFolderUid(storage, input.folder) - try { - const { recordUid, recordAdd } = await buildRecordAdd(storage, auth, recordData, folderUid) + const { recordUid, recordAdd } = await buildRecordAdd( + storage, + auth, + recordData, + resolveFolderUid(storage, input.folder) + ) const response = await auth.executeRest( keeperDriveRecordsAdd({ records: [recordAdd], clientTime: Date.now(), }) ) - - const result = response.records?.[0] - if (!result) { - throw new KeeperSdkError('No results from record creation.', ResultCodes.NSF_ADD_FAILED) - } - - const success = result.status === Records.RecordModifyResult.RS_SUCCESS || result.status == null - const statusName = - result.status != null ? Records.RecordModifyResult[result.status] ?? String(result.status) : 'RS_SUCCESS' - - if (!success) { - throw new KeeperSdkError(result.message || `Record creation failed (${statusName}).`, ResultCodes.NSF_ADD_FAILED) - } + const { statusName, message } = parseRecordModifyStatus( + response.records?.[0], + ResultCodes.NSF_ADD_FAILED + ) return { recordUid, success: true, status: statusName, - message: result.message || 'Record created successfully', + message, revision: nsfToNumber(response.revision), } } catch (err) { diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts index 25ef429f..c7235ec9 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -1,4 +1,4 @@ -import type { Auth } from '@keeper-security/keeperapi' +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' @@ -35,16 +35,32 @@ function resolveRecordUids(storage: InMemoryStorage, identifiers: string[]): str throw new KeeperSdkError('At least one record UID or title is required.', ResultCodes.NSF_DETAILS_FAILED) } - const recordUids: string[] = [] - for (const identifier of identifiers) { + 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) - recordUids.push(recordUid) + return recordUid + }) +} + +async function mapRecordDetailsItem( + storage: InMemoryStorage, + auth: Auth, + item: Records.IRecordData +): Promise { + 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, } - return recordUids } export function formatNsfRecordDetailsTable(result: GetNsfRecordDetailsResult): string { @@ -95,16 +111,8 @@ export async function getNestedShareRecordDetails( const data: NsfRecordDetailsItem[] = [] for (const item of response.data ?? []) { - const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' - if (!recordUid) continue - const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) - data.push({ - recordUid, - title, - type, - revision: nsfToNumber(item.revision, 0) ?? 0, - version: item.version ?? 0, - }) + const mapped = await mapRecordDetailsItem(storage, auth, item) + if (mapped) data.push(mapped) } return { diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts index 08fb4d98..35066b18 100644 --- a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -2,25 +2,29 @@ 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' import { - NSF_FOLDER_COLORS, - type NsfFolderColor, -} from './nsfConstants' -import { + buildFolderOwnerInfo, cacheNewNsfFolder, findExistingChildFolder, isNestedShareFolder, - isRootFolderUid, + parseFolderModifyStatus, parseNsfPath, + requireAuthDataKey, + resolveKeeperDriveParentUid, } from './nsfHelpers' -const FOLDER_KEY_BYTE_LENGTH = 32 +type NsfFolderMetadata = { + name: string + color?: string +} export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` @@ -39,14 +43,13 @@ export type MkdirNsfResult = { function normalizeColor(color?: NsfFolderColorInput): NsfFolderColor | undefined { if (!color) return undefined - const value = color as NsfFolderColor - if (!(NSF_FOLDER_COLORS as readonly string[]).includes(value)) { + if (!(NSF_FOLDER_COLORS as readonly string[]).includes(color)) { throw new KeeperSdkError( `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(', ')}.`, ResultCodes.NSF_MKDIR_FAILED ) } - return value + return color } function resolveBaseFolderUid( @@ -54,8 +57,7 @@ function resolveBaseFolderUid( baseFolderUid: string | null | undefined ): string | null { if (!baseFolderUid) return null - if (isNestedShareFolder(storage, baseFolderUid)) return baseFolderUid - return null + return isNestedShareFolder(storage, baseFolderUid) ? baseFolderUid : null } async function resolveFolderKeyEncryptionKey( @@ -63,15 +65,11 @@ async function resolveFolderKeyEncryptionKey( auth: Auth, parentUid: string | null ): Promise { - const normalizedParent = parentUid && !isRootFolderUid(parentUid) ? parentUid : null - if (normalizedParent) { - const parentKey = await storage.getKeyBytes(normalizedParent) + if (parentUid) { + const parentKey = await storage.getKeyBytes(parentUid) if (parentKey) return parentKey } - if (!auth.dataKey) { - throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) - } - return auth.dataKey + return requireAuthDataKey(auth) } async function prepareFolderData( @@ -83,33 +81,33 @@ async function prepareFolderData( inheritPermissions: boolean ): Promise<{ folderUid: string; folderData: Folder.IFolderData }> { const folderUid = generateUid() - const folderKey = platform.getRandomBytes(FOLDER_KEY_BYTE_LENGTH) + const folderKey = generateEncryptionKey() await storage.saveKeyBytes(folderUid, folderKey) - const metadata: { name: string; color?: string } = { name: folderName } + 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 normalizedParent = parentUid && !isRootFolderUid(parentUid) ? parentUid : null - const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, normalizedParent) + const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, resolvedParentUid) const encryptedFolderKey = await platform.aesGcmEncrypt(folderKey, encryptionKey) return { folderUid, - folderData: { + folderData: Folder.FolderData.create({ folderUid: normal64Bytes(folderUid), - parentUid: normalizedParent ? normal64Bytes(normalizedParent) : undefined, + 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), + }), } } @@ -131,21 +129,10 @@ async function createFolderV3( ) const response = await auth.executeRest(folderAddMessage({ folderData: [folderData] })) - const result = response.folderAddResults?.[0] - if (!result) { - throw new KeeperSdkError('No results from folder creation.', ResultCodes.NSF_MKDIR_FAILED) - } - - const statusName = Folder.FolderModifyStatus[result.status ?? Folder.FolderModifyStatus.SUCCESS] ?? 'UNKNOWN' - if (result.status !== Folder.FolderModifyStatus.SUCCESS) { - throw new KeeperSdkError(result.message || `Folder creation failed (${statusName}).`, ResultCodes.NSF_MKDIR_FAILED) - } + const message = parseFolderModifyStatus(response.folderAddResults?.[0], ResultCodes.NSF_MKDIR_FAILED) await cacheNewNsfFolder(storage, auth, folderUid, folderName, parentUid, inheritPermissions) - return { - folderUid, - message: result.message || 'Folder created successfully', - } + return { folderUid, message } } export async function mkdirNestedShareFolder( @@ -160,9 +147,8 @@ export async function mkdirNestedShareFolder( const color = normalizeColor(input.color) const inheritPermissions = !input.noInheritPermissions - const baseFolderUid = resolveBaseFolderUid(storage, input.baseFolderUid) const segments = parseNsfPath(folderPath) - let parentUid = baseFolderUid + let parentUid: string | null = resolveBaseFolderUid(storage, input.baseFolderUid) const lastIdx = segments.length - 1 let createdUid: string | undefined @@ -184,9 +170,14 @@ export async function mkdirNestedShareFolder( continue } - const segColor = isLeaf ? color : undefined - const segInherit = isLeaf ? inheritPermissions : true - const result = await createFolderV3(storage, auth, segment, parentUid, segColor, segInherit) + const result = await createFolderV3( + storage, + auth, + segment, + parentUid, + isLeaf ? color : undefined, + isLeaf ? inheritPermissions : true + ) createdUid = result.folderUid parentUid = createdUid } diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 2ffbf9d7..d09656e5 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,4 +1,5 @@ import type { + Auth, DRecord, DUser, DKdFolder, @@ -6,7 +7,7 @@ import type { DKdFolderRecord, DKdRecordAccess, } from '@keeper-security/keeperapi' -import { Folder, webSafe64FromBytes } from '@keeper-security/keeperapi' +import { Folder, Records, webSafe64FromBytes } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { VaultObjectKind } from '../folders/folderHelpers' import { KeeperSdkError, ResultCodes } from '../utils' @@ -45,6 +46,68 @@ export function nsfToNumber( 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 +} + +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 +} + +export function buildFolderOwnerInfo(auth: Auth): Folder.IUserInfo | undefined { + if (!auth.accountUid?.length) return undefined + return { + accountUid: auth.accountUid, + username: auth.username, + } +} + +export function parseFolderModifyStatus( + 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' +} + +export function parseRecordModifyStatus( + 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 = 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) } @@ -238,6 +301,14 @@ function resolveKeeperDriveRootParentUid(storage: InMemoryStorage): string | und return undefined } +export function resolveKeeperDriveParentUid( + storage: InMemoryStorage, + parentUid: string | null | undefined +): string | null { + if (parentUid && !isRootFolderUid(parentUid)) return parentUid + return resolveKeeperDriveRootParentUid(storage) ?? null +} + export async function cacheNewNsfFolder( storage: InMemoryStorage, auth: { username?: string; accountUid?: Uint8Array }, @@ -270,27 +341,18 @@ export function checkFolderDeletePermission( storage: InMemoryStorage, folderUid: string, username: string, - accountUid?: Uint8Array + accountUid: Uint8Array ): void { if (isRootFolderUid(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 - const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' for (const entry of entries) { - const isCurrentUser = - (entry.accessType === Folder.AccessType.AT_USER && entry.accessTypeUid === accountUidStr) || - (entry.accessType === Folder.AccessType.AT_OWNER && entry.accessTypeUid === accountUidStr) || - (username && - storage.getAll('user').some( - (user) => - user.username === username && - webSafe64FromBytes(user.accountUid) === entry.accessTypeUid - )) - if (!isCurrentUser) continue + 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.', diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts index 19878b37..9515b673 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -7,6 +7,7 @@ import { checkFolderDeletePermission, ensureNestedShareFolder, getFolderDisplayName, + requireAuthAccountUid, resolveNsfFolderUidOrName, } from './nsfHelpers' @@ -90,10 +91,7 @@ function buildRemovals( ) } - const accountUid = auth.accountUid - if (!accountUid?.length) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } + const accountUid = requireAuthAccountUid(auth) const removals: RemovalSpec[] = [] for (const identifier of folderIdentifiers) { diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts index cdc0a5c0..680e513b 100644 --- a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -1,10 +1,5 @@ import type { Auth, DRecord } from '@keeper-security/keeperapi' -import { - Records, - keeperDriveRecordsUpdate, - normal64Bytes, - platform, -} 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' @@ -14,6 +9,8 @@ import { checkRecordEditPermission, ensureNestedShareRecord, nsfToNumber, + parseRecordModifyStatus, + requireAuthAccountUid, resolveNsfRecordIdentifier, } from './nsfHelpers' @@ -47,14 +44,6 @@ function loadExistingRecordData(storage: InMemoryStorage, recordUid: string): Re return { fields: [] } } -function requireAccountUid(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 -} - async function updateSingleRecord( storage: InMemoryStorage, auth: Auth, @@ -71,8 +60,6 @@ async function updateSingleRecord( } const merged = mergeNsfRecordData(loadExistingRecordData(storage, recordUid), input) - const encryptedData = await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey) - const response = await auth.executeRest( keeperDriveRecordsUpdate({ records: [ @@ -80,23 +67,19 @@ async function updateSingleRecord( recordUid: normal64Bytes(recordUid), clientModifiedTime: Date.now(), revision: record?.revision ?? 0, - data: encryptedData, + data: await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey), }, ], clientTime: Date.now(), }) ) - const result = response.records?.[0] - const success = result?.status === Records.RecordModifyResult.RS_SUCCESS || result?.status == null - const statusName = - result?.status != null ? Records.RecordModifyResult[result.status] ?? String(result.status) : 'RS_SUCCESS' - - if (!success) { - throw new KeeperSdkError(result?.message || `Record update failed (${statusName}).`, ResultCodes.NSF_UPDATE_FAILED) - } - + const { statusName, message } = parseRecordModifyStatus( + response.records?.[0], + ResultCodes.NSF_UPDATE_FAILED + ) const revision = nsfToNumber(response.revision) ?? record?.revision + if (record) { await storage.put({ ...record, @@ -110,7 +93,7 @@ async function updateSingleRecord( recordUid, success: true, status: statusName, - message: result?.message || 'Record updated successfully', + message, revision, } } @@ -120,16 +103,15 @@ export async function updateNestedShareRecords( auth: Auth, input: UpdateNsfRecordInput ): Promise { - const identifiers = input.records ?? [] - if (identifiers.length === 0) { + if (!input.records?.length) { throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) } - const accountUid = requireAccountUid(auth) + const accountUid = requireAuthAccountUid(auth) const updated: UpdateNsfRecordResultItem[] = [] try { - for (const identifier of identifiers) { + for (const identifier of input.records) { const recordUid = resolveNsfRecordIdentifier(storage, identifier) if (!recordUid) { throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 0445ec56..93a3e8f2 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -539,12 +539,7 @@ const RecordDetailsDataResponse = { export const recordDetailsDataMessage = ( data: IRecordDetailsDataRequest ): RestMessage => - createMessage( - data, - 'vault/records/v3/details/data', - RecordDetailsDataRequest, - RecordDetailsDataResponse - ) + createMessage(data, 'vault/records/v3/details/data', RecordDetailsDataRequest, RecordDetailsDataResponse) export const folderAddMessage = ( data: Folder.IFolderAddRequest From fb7732efdf279cef5974d311512eab7af7c310aa Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 23 Jun 2026 15:46:34 +0530 Subject: [PATCH 09/48] nsf commands initial commit --- KeeperSdk/src/index.ts | 55 ++ .../NestedShareFolderManager.ts | 106 +++ KeeperSdk/src/nestedShareFolders/index.ts | 77 +- .../src/nestedShareFolders/nsfConstants.ts | 121 +++ .../src/nestedShareFolders/nsfHelpers.ts | 209 ++++- .../nestedShareFolders/nsfRecordPermission.ts | 717 +++++++++++++++ KeeperSdk/src/nestedShareFolders/nsfShare.ts | 823 ++++++++++++++++++ .../src/nestedShareFolders/nsfShortcut.ts | 389 +++++++++ .../nestedShareFolders/nsfTransferRecord.ts | 132 +++ .../src/nestedShareFolders/updateNsfFolder.ts | 173 ++++ KeeperSdk/src/sharing/Sharing.ts | 126 ++- KeeperSdk/src/utils/constants.ts | 8 + KeeperSdk/src/vault/KeeperVault.ts | 110 +++ examples/sdk_example/package.json | 5 + .../nsf_record_permission.ts | 133 +++ .../src/nestedShareFolders/rndir_nsf.ts | 52 ++ .../src/nestedShareFolders/share_nsf.ts | 142 +++ .../src/nestedShareFolders/shortcut_nsf.ts | 126 +++ .../nestedShareFolders/transfer_nsf_record.ts | 50 ++ keeperapi/src/restMessageCodecs.ts | 44 + keeperapi/src/restMessages.ts | 119 ++- 21 files changed, 3685 insertions(+), 32 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfShare.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfShortcut.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/nsf_record_permission.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/rndir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/share_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/shortcut_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/transfer_nsf_record.ts create mode 100644 keeperapi/src/restMessageCodecs.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index f4dd449e..b8ef1968 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -476,6 +476,21 @@ export { removeNestedShareRecords, formatRemoveNsfPreview, mkdirNestedShareFolder, + updateNestedShareFolder, + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + getNsfRecordShortcuts, + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, + transferNestedShareRecords, + formatTransferNestedShareRecordResults, + checkFolderEditPermission, + checkFolderSharePermission, + checkRecordSharePermission, NSF_FOLDER_COLORS, NsfRemoveFolderOperation, removeNestedShareFolders, @@ -489,6 +504,18 @@ export { buildNsfRecordData, parseNsfFieldStrings, checkRecordEditPermission, + NsfRecordPermissionAction, + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionFailures, + NSF_RECORD_PERMISSION_ROLES, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + NsfFolderShareAction, + NsfRecordShareAction, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -511,6 +538,26 @@ export type { RemoveNsfRecordResult, MkdirNsfInput, MkdirNsfResult, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + NsfFolderShareActionInput, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, NsfFolderColorInput, NsfFolderColor, NsfRemoveFolderOperationInput, @@ -530,6 +577,14 @@ export type { NsfRecordFieldMap, NsfRecordCustomField, ParsedNsfFieldStrings, + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, + NsfRecordPermissionRole, + NsfRecordPermissionRoleInput, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index d98b5c54..40d33572 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -29,6 +29,33 @@ import { type RemoveNsfRecordResult, } from './removeNsfRecord' import { mkdirNestedShareFolder, type MkdirNsfInput, type MkdirNsfResult } from './mkdirNsf' +import { updateNestedShareFolder, type UpdateNsfFolderInput, type UpdateNsfFolderResult } from './updateNsfFolder' +import { + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + type ShareNestedShareFolderInput, + type ShareNestedShareFolderResult, + type ShareNestedShareRecordInput, + type ShareNestedShareRecordResult, +} from './nsfShare' +import { + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, + type ListNsfShortcutsOptions, + type NsfShortcutRow, + type KeepNsfShortcutInput, + type KeepNsfShortcutResult, +} from './nsfShortcut' +import { + transferNestedShareRecords, + formatTransferNestedShareRecordResults, + type TransferNestedShareRecordInput, + type TransferNestedShareRecordResult, +} from './nsfTransferRecord' import { formatRemoveNsfFolderPreview, removeNestedShareFolders, @@ -47,6 +74,13 @@ import { type UpdateNsfRecordResult, } from './updateNsfRecord' import { addNestedShareRecord, type AddNsfRecordInput, type AddNsfRecordResult } from './addNsfRecord' +import { + updateNestedShareRecordPermissions, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionFailures, + type UpdateNsfRecordPermissionInput, + type UpdateNsfRecordPermissionResult, +} from './nsfRecordPermission' export type AuthProvider = () => Auth @@ -118,6 +152,61 @@ export class NestedShareFolderManager { return mkdirNestedShareFolder(this.storage, this.requireAuth(), input) } + public async updateNestedShareFolder(input: UpdateNsfFolderInput): Promise { + return updateNestedShareFolder(this.storage, this.requireAuth(), input) + } + + 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) } @@ -150,4 +239,21 @@ export class NestedShareFolderManager { 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/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index 48c9d683..b899f17e 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -25,8 +25,14 @@ export { checkRecordDeletePermission, checkRecordEditPermission, checkFolderDeletePermission, + checkFolderEditPermission, + checkFolderSharePermission, + checkRecordSharePermission, parseNsfPath, findExistingChildFolder, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, } from './nsfHelpers' export { @@ -82,8 +88,58 @@ export type { export { mkdirNestedShareFolder } from './mkdirNsf' export type { MkdirNsfInput, MkdirNsfResult, NsfFolderColorInput } from './mkdirNsf' -export { NSF_FOLDER_COLORS } from './nsfConstants' -export type { NsfFolderColor } from './nsfConstants' + +export { updateNestedShareFolder } from './updateNsfFolder' +export type { UpdateNsfFolderInput, UpdateNsfFolderResult } from './updateNsfFolder' + +export { + NsfFolderShareAction, + NsfRecordShareAction, + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, +} from './nsfShare' +export type { + NsfFolderShareActionInput, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, +} from './nsfShare' + +export { + getNsfRecordShortcuts, + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, +} from './nsfShortcut' +export type { + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, +} from './nsfShortcut' + +export { + transferNestedShareRecords, + formatTransferNestedShareRecordResults, +} from './nsfTransferRecord' +export type { + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, +} from './nsfTransferRecord' + +export { NSF_FOLDER_COLORS, NSF_RECORD_PERMISSION_ROLES } from './nsfConstants' +export type { NsfFolderColor, NsfRecordPermissionRole, NsfRecordPermissionRoleInput } from './nsfConstants' export { NsfRemoveFolderOperation, @@ -121,6 +177,23 @@ export type { export { addNestedShareRecord } from './addNsfRecord' export type { AddNsfRecordInput, AddNsfRecordResult } from './addNsfRecord' +export { + NsfRecordPermissionAction, + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionFailures, +} from './nsfRecordPermission' +export type { + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, +} from './nsfRecordPermission' + export { buildNsfRecordData, parseNsfFieldStrings, diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index e687147a..a63e5f27 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -60,3 +60,124 @@ 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_RECORD_PERMISSION_ROLES = [ + 'viewer', + 'share-manager', + 'content-manager', + 'content-share-manager', + 'full-manager', +] 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]: 'contributor', + [Folder.AccessRoleType.REQUESTOR]: 'contributor', + [Folder.AccessRoleType.VIEWER]: 'viewer', + [Folder.AccessRoleType.SHARED_MANAGER]: 'share-manager', + [Folder.AccessRoleType.CONTENT_MANAGER]: 'content-manager', + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: 'content-share-manager', + [Folder.AccessRoleType.MANAGER]: 'full-manager', + [Folder.AccessRoleType.UNRESOLVED]: 'unresolved', +} + +export const NSF_RECORD_PERMISSION_ROLE_MAP: Record = { + contributor: Folder.AccessRoleType.REQUESTOR, + requestor: Folder.AccessRoleType.REQUESTOR, + viewer: Folder.AccessRoleType.VIEWER, + 'share-manager': Folder.AccessRoleType.SHARED_MANAGER, + share_manager: Folder.AccessRoleType.SHARED_MANAGER, + shared_manager: Folder.AccessRoleType.SHARED_MANAGER, + 'content-manager': Folder.AccessRoleType.CONTENT_MANAGER, + content_manager: Folder.AccessRoleType.CONTENT_MANAGER, + 'content-share-manager': Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + content_share_manager: Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + 'full-manager': Folder.AccessRoleType.MANAGER, + full_manager: Folder.AccessRoleType.MANAGER, +} + +export const NSF_SHARE_BATCH_SIZE = 200 + +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 Error(`Unknown folder access role type: ${roleType}`) + } + return Folder.FolderPermissions.create(flags) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index d09656e5..423ca06a 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -20,6 +20,9 @@ import { 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, ROOT_FOLDER_UID, } from './nsfConstants' @@ -337,6 +340,154 @@ export async function cacheNewNsfFolder( }) } +export function checkFolderEditPermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array +): void { + if (isRootFolderUid(folderUid)) { + throw new KeeperSdkError('The root folder cannot be edited.', 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?.canUpdateSetting) return + throw new KeeperSdkError( + 'You do not have permission to edit this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to edit this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export async function patchNsfFolderMetadata( + storage: InMemoryStorage, + folderUid: string, + metadata: { name: string; color?: string } +): Promise { + const folder = getKeeperDriveFolder(storage, folderUid) + if (!folder) return + + const data: { name: string; color?: string } = { name: metadata.name } + if (metadata.color) data.color = metadata.color + + await storage.put({ + ...folder, + data, + }) +} + +export function checkFolderSharePermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array +): void { + if (isRootFolderUid(folderUid)) { + throw new KeeperSdkError('The root folder cannot be shared.', 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?.canUpdateAccess) return + throw new KeeperSdkError( + 'You do not have permission to share this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + 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 { + 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.canUpdateAccess) return + throw new KeeperSdkError( + 'You do not have permission to share this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to share 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 + ) +} + +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 }> = [] + const seen = new Set() + + for (const entry of getFolderAccessEntries(storage, folderUid)) { + if (entry.accessType === Folder.AccessType.AT_OWNER) continue + + if (entry.accessType === Folder.AccessType.AT_USER) { + const username = resolveAccessUsername(storage, entry.accessTypeUid) + if (!username || username === currentUsername) continue + const key = `user:${username.toLowerCase()}` + if (seen.has(key)) continue + seen.add(key) + targets.push({ + recipient: username, + isTeam: false, + accountUid: entry.accessTypeUid, + }) + continue + } + + if (entry.accessType === Folder.AccessType.AT_TEAM) { + const key = `team:${entry.accessTypeUid}` + if (seen.has(key)) continue + seen.add(key) + targets.push({ + recipient: entry.accessTypeUid, + isTeam: true, + accountUid: entry.accessTypeUid, + }) + } + } + + return targets +} + export function checkFolderDeletePermission( storage: InMemoryStorage, folderUid: string, @@ -425,7 +576,7 @@ function isFolderOwnerAccount(storage: InMemoryStorage, folderUid: string, accou return folder?.ownerInfo?.accountUid === accountUidStr } -type FolderPermissionFlag = 'canRemove' | 'canDelete' +type FolderPermissionFlag = 'canRemove' | 'canDelete' | 'canUpdateSetting' function hasFolderPermission( storage: InMemoryStorage, @@ -676,3 +827,59 @@ export function isFolderUserPermission(entry: DKdFolderAccess): boolean { ) } +export type NsfRecordAccessFlags = { + canViewTitle?: boolean + canEdit?: boolean + canView?: boolean + canListAccess?: boolean + canUpdateAccess?: boolean + canDelete?: boolean + canChangeOwnership?: boolean + canRequestAccess?: boolean + canApproveAccess?: boolean + accessRoleType?: number | null +} + +function inferRecordPermissionRole(access: NsfRecordAccessFlags): string { + if (access.canChangeOwnership) return 'full-manager' + if (access.canDelete && access.canUpdateAccess) return 'content-share-manager' + if (access.canEdit && !access.canUpdateAccess) return 'content-manager' + if (access.canUpdateAccess) return 'share-manager' + if (access.canView && access.canListAccess) return 'viewer' + if (access.canView || access.canViewTitle || access.canRequestAccess) return 'contributor' + return 'contributor' +} + +export function resolveNsfRoleName(role: string): Folder.AccessRoleType { + const value = NSF_RECORD_PERMISSION_ROLE_MAP[role.trim().toLowerCase()] + if (value == null) { + throw new KeeperSdkError( + `Invalid role '${role}'. Accepted values: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } + return value +} + +export function getNsfRecordPermissionRoleLabel( + role: Folder.AccessRoleType | number | null | undefined +): string { + if (role == null) return '' + return NSF_RECORD_PERMISSION_ROLE_LABELS[role] ?? String(role) +} + +export function getNsfAccessRoleLabel(access: NsfRecordAccessFlags): string { + const roleInt = access.accessRoleType + if (roleInt != null && roleInt !== Folder.AccessRoleType.UNRESOLVED) { + return getNsfRecordPermissionRoleLabel(roleInt) + } + return inferRecordPermissionRole(access) +} + +export function normalizeNsfRecordPermissionRole(role?: string): string | undefined { + const trimmed = role?.trim() + if (!trimmed) return undefined + resolveNsfRoleName(trimmed) + return trimmed.toLowerCase() +} + diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts new file mode 100644 index 00000000..9fdc5665 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts @@ -0,0 +1,717 @@ +import type { Auth, DKdFolderRecord } from '@keeper-security/keeperapi' +import { + Folder, + normal64Bytes, + platform, + record, + recordAccessDetailsMessage, + recordsShareV3Message, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { buildNsfRecordRevokePermission, buildNsfRecordSharePermission, parseRecordSharingStatus } from '../sharing/Sharing' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + KeeperDriveKind, + ensureNestedShareFolder, + getKeeperDriveFolder, + getKeeperDriveFolders, + getKeeperDriveRecord, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + normalizeParentUid, + resolveNsfFolderIdentifier, + resolveNsfRoleName, +} from './nsfHelpers' +import { NSF_RECORD_PERMISSION_ROLES, NSF_SHARE_BATCH_SIZE } from './nsfConstants' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' + +export enum NsfRecordPermissionAction { + Grant = 'grant', + Revoke = 'revoke', +} + +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 +} + +type NsfRecordAccessEntry = { + 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 +} + +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 = normalizeParentUid(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 (normalizeParentUid(folder.parentUid) !== normalizeParentUid(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) +} + +function mapAccessEntry( + entry: { + data?: Folder.IRecordAccessData | null + accessorInfo?: { name?: string | null } | null + } +): NsfRecordAccessEntry | undefined { + const data = entry.data + if (!data?.recordUid?.length) return undefined + + return { + recordUid: webSafe64FromBytes(data.recordUid), + accessorName: entry.accessorInfo?.name || '', + accessTypeUid: data.accessTypeUid?.length ? webSafe64FromBytes(data.accessTypeUid) : '', + owner: !!data.owner, + inherited: !!data.inherited, + accessRoleType: data.accessRoleType ?? Folder.AccessRoleType.UNRESOLVED, + canViewTitle: !!data.canViewTitle, + canEdit: !!data.canEdit, + canView: !!data.canView, + canListAccess: !!data.canListAccess, + canUpdateAccess: !!data.canUpdateAccess, + canDelete: !!data.canDelete, + canChangeOwnership: !!data.canChangeOwnership, + canRequestAccess: !!data.canRequestAccess, + canApproveAccess: !!data.canApproveAccess, + } +} + +async function getRecordAccessesV3( + auth: Auth, + 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) + } + + const response = await auth.executeRest( + recordAccessDetailsMessage({ + recordUids: recordUids.map((uid) => normal64Bytes(uid)), + }) + ) + + const recordAccesses: NsfRecordAccessEntry[] = [] + for (const entry of response.recordAccesses ?? []) { + const mapped = mapAccessEntry(entry) + if (mapped) recordAccesses.push(mapped) + } + + return { + recordAccesses, + forbiddenRecords: (response.forbiddenRecords ?? []).map((uid) => webSafe64FromBytes(uid)), + } +} + +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 +): Promise { + const recordKey = await requireRecordKey(storage, auth, item.recordUid) + return buildNsfRecordSharePermission( + auth, + item.recordUid, + recordKey, + item.email, + item.accessRoleType ?? Folder.AccessRoleType.VIEWER, + undefined, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) +} + +async function buildRevokePermission( + auth: Auth, + item: NsfSharePermissionItem +): Promise { + return buildNsfRecordRevokePermission( + auth, + item.recordUid, + item.email, + 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] ?? [] + const byRecordUid = new Map() + for (const status of statuses) { + const parsed = parseRecordSharingStatus(status) + if (parsed.recordUid) { + byRecordUid.set(parsed.recordUid, parsed) + } + } + return byRecordUid +} + +async function runShareBatch( + auth: Auth, + items: T[], + buildPermission: (item: T) => Promise, + applyToRequest: (request: record.v3.sharing.IRequest, permission: record.v3.sharing.IPermissions) => void, + statusField: 'updatedSharingStatus' | 'createdSharingStatus' | 'revokedSharingStatus' +): 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 request: record.v3.sharing.IRequest = {} + const built: T[] = [] + + for (const item of chunk) { + try { + const permission = await buildPermission(item) + 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 statusByUid = await executeShareBatch(auth, request, statusField) + for (const item of built) { + const status = statusByUid.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) => buildSharePermission(storage, auth, item), + (request, permission) => { + request.updateSharingPermissions = [...(request.updateSharingPermissions ?? []), permission] + }, + 'updatedSharingStatus' + ) +} + +async function batchCreateRecordSharesV3( + storage: InMemoryStorage, + auth: Auth, + creates: NsfSharePermissionItem[] +): Promise { + return runShareBatch( + auth, + creates, + (item) => buildSharePermission(storage, auth, item), + (request, permission) => { + request.createSharingPermissions = [...(request.createSharingPermissions ?? []), permission] + }, + 'createdSharingStatus' + ) +} + +async function batchUnshareRecordsV3( + auth: Auth, + revokes: NsfSharePermissionItem[] +): Promise { + return runShareBatch( + auth, + revokes, + (item) => buildRevokePermission(auth, item), + (request, permission) => { + request.revokeSharingPermissions = [...(request.revokeSharingPermissions ?? []), permission] + }, + 'revokedSharingStatus' + ) +} + +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() + + for (const access of accesses) { + if (access.accessorName === currentUser) { + ownerFlags.set(access.recordUid, 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 === currentUser) 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, + } +} + +export function formatNsfRecordPermissionPlan(plan: NsfRecordPermissionPlan): string { + const lines: string[] = [] + + if (plan.skipped.length > 0) { + lines.push('SKIP — Record permission(s). Not permitted') + for (const item of plan.skipped) { + lines.push(` ${item.recordUid} ${item.title || '—'} ${item.email || '—'} ${item.curRole || '—'}`) + lines.push(` Reason: ${item.reason || 'Unknown'}`) + } + lines.push('') + } + + if (plan.grants.length > 0) { + lines.push('GRANT — Record permission(s)') + for (const item of plan.grants) { + lines.push( + ` ${item.recordUid} ${item.title || '—'} ${item.email} ${item.curRole || '—'} -> ${item.newRole || '—'}` + ) + } + lines.push('') + } + + if (plan.revokes.length > 0) { + lines.push('REVOKE — Record share(s)') + for (const item of plan.revokes) { + lines.push(` ${item.recordUid} ${item.title || '—'} ${item.email} ${item.curRole || '—'}`) + } + 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 ? '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, [...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/nsfShare.ts b/KeeperSdk/src/nestedShareFolders/nsfShare.ts new file mode 100644 index 00000000..befeb836 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShare.ts @@ -0,0 +1,823 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + getShareObjectsMessage, + normal64Bytes, + recordAccessDetailsMessage, + recordsShareV3Message, + folderAccessUpdateMessage, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { + buildNsfRecordRevokePermission, + buildNsfRecordSharePermission, + encryptKeyForRecipient, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, +} from '../sharing/Sharing' +import { KeeperSdkError, ResultCodes, extractErrorMessage, isValidEmail } from '../utils' +import { collectNsfRecordUidsInFolder } from './nsfRecordPermission' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import { transferNestedShareRecordOwnership } from './nsfTransferRecord' +import { getFolderPermissionsForRole } from './nsfConstants' +import { + checkFolderSharePermission, + checkRecordSharePermission, + collectExistingFolderShareTargets, + ensureNestedShareFolder, + ensureNestedShareRecord, + findFolderAccessEntry, + getKeeperDriveRecord, + getNsfRecordPermissionRoleLabel, + requireAuthAccountUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, + resolveNsfRoleName, +} from './nsfHelpers' + +// --- Folder share --- + +export enum NsfFolderShareAction { + Grant = 'grant', + Remove = 'remove', +} + +export type NsfFolderShareActionInput = NsfFolderShareAction | `${NsfFolderShareAction}` + +export type ShareNestedShareFolderInput = { + folders: string[] + recipients: string[] + action?: NsfFolderShareActionInput + role?: string + expirationTimestamp?: number +} + +export type NsfFolderShareResultItem = { + folderUid: string + recipient: string + isTeam: boolean + success: boolean + actionTaken: string + message?: string +} + +export type ShareNestedShareFolderResult = { + results: NsfFolderShareResultItem[] +} + +// --- Record share --- + +export enum NsfRecordShareAction { + Grant = 'grant', + Revoke = 'revoke', + Owner = 'owner', +} + +export type NsfRecordShareActionInput = NsfRecordShareAction | `${NsfRecordShareAction}` + +export type ShareNestedShareRecordInput = { + record: string + emails: string[] + action?: NsfRecordShareActionInput + role?: string + recursive?: boolean + dryRun?: boolean + expirationTimestamp?: number +} + +export type NsfRecordSharePlanItem = { + recordUid: string + title: string + email: string + action: string + role?: string +} + +export type NsfRecordShareResultItem = { + recordUid: string + email: string + success: boolean + actionTaken: string + message?: string +} + +export type ShareNestedShareRecordResult = { + dryRun: boolean + plan: NsfRecordSharePlanItem[] + results: NsfRecordShareResultItem[] +} + +type ShareRecipientTarget = { + recipient: string + isTeam: boolean + accountUid?: Uint8Array +} + +type DirectUserShare = { + recordUid: string + email: string + accessRoleType: number + expiration?: number +} + +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 +): { folderUid: string; recipient: string; success: boolean; message: string } { + 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, + } +} + +async function classifyShareRecipient(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 lower = trimmed.toLowerCase() + const matches = teams.filter((team) => { + const uid = team.teamUid?.length ? webSafe64FromBytes(team.teamUid) : '' + const name = team.teamname?.trim() ?? '' + return uid === trimmed || name.toLowerCase() === lower + }) + + 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 + ) +} + +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) + const { encryptedKey, useEccKey } = await encryptKeyForRecipient(folderKey, userKeys) + return Folder.EncryptedDataKey.create({ + encryptedKey, + encryptedKeyType: useEccKey + ? Folder.EncryptedKeyType.encrypted_by_public_key_ecc + : Folder.EncryptedKeyType.encrypted_by_public_key, + }) +} + +async function buildFolderAccessGrantData( + auth: Auth, + storage: InMemoryStorage, + folderUid: string, + target: ShareRecipientTarget, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Promise { + if (target.isTeam) { + throw new KeeperSdkError( + 'Team folder sharing is not yet supported in this SDK version.', + SHARE_ERROR + ) + } + + 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 userKeys = await loadUserShareKeysOrInvite(auth, target.recipient, SHARE_ERROR) + const encryptedFolderKey = await buildEncryptedFolderKeyForUser(auth, folderKey, target.recipient) + + const data = Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + accessRoleType, + permissions: getFolderPermissionsForRole(accessRoleType), + folderKey: encryptedFolderKey, + }) + + if (expirationTimestamp != null) { + data.tlaProperties = { expiration: expirationTimestamp } + } + + return data +} + +function buildFolderAccessUpdateData( + folderUid: string, + target: ShareRecipientTarget, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Folder.IFolderAccessData { + if (target.isTeam) { + throw new KeeperSdkError( + 'Team folder sharing is not yet supported in this SDK version.', + SHARE_ERROR + ) + } + + if (!target.accountUid?.length) { + throw new KeeperSdkError(`User ${target.recipient} not found`, SHARE_ERROR) + } + + const data = Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid: target.accountUid, + accessType: 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: ShareRecipientTarget[] = [] + 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 classifyShareRecipient(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: ShareRecipientTarget, + 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 = findFolderAccessEntry(storage, folderUid, accessTypeUid, accessType) + if (existing && existing.accessRoleType === accessRoleType && expirationTimestamp == null) { + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: true, + actionTaken: 'already_had_access', + 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 ? 'updated' : 'granted', + message: parsed.message, + } +} + +async function removeFolderAccess( + auth: Auth, + folderUid: string, + target: ShareRecipientTarget +): 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: '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() || 'viewer' + const accessRoleType = + action === NsfFolderShareAction.Grant ? resolveNsfRoleName(role) : undefined + + if (action === NsfFolderShareAction.Grant) { + resolveNsfRoleName(role) + } + + 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!, + input.expirationTimestamp + ) + ) + } + } + } + + return { results } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to share nested share folder: ${extractErrorMessage(err)}`, + SHARE_ERROR + ) + } +} + +// --- Record share --- + +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) +} + +async function findDirectUserShare( + auth: Auth, + recordUid: string, + email: string +): Promise { + const response = await auth.executeRest( + recordAccessDetailsMessage({ + recordUids: [normal64Bytes(recordUid)], + }) + ) + + for (const entry of response.recordAccesses ?? []) { + const data = entry.data + if (!data?.recordUid?.length || data.inherited || data.owner) continue + if (data.accessType !== Folder.AccessType.AT_USER) continue + if ((entry.accessorInfo?.name || '').toLowerCase() !== email.toLowerCase()) continue + + return { + recordUid, + email, + accessRoleType: data.accessRoleType ?? Folder.AccessRoleType.UNRESOLVED, + expiration: data.tlaProperties?.expiration ?? undefined, + } + } + return undefined +} + +function isShareUpdateNoop( + existing: DirectUserShare, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): boolean { + if (existing.accessRoleType !== accessRoleType) return false + if (expirationTimestamp == null) return true + return 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: 'owner', + message: result.message, + } +} + +async function revokeRecordShare( + auth: Auth, + recordUid: string, + email: string +): Promise { + const permission = await buildNsfRecordRevokePermission(auth, recordUid, email, SHARE_ERROR) + const response = await auth.executeRest( + recordsShareV3Message({ revokeSharingPermissions: [permission] }) + ) + const parsed = parseRecordSharingStatus(response.revokedSharingStatus?.[0]) + return { + recordUid, + email, + success: parsed.success, + actionTaken: 'revoke', + message: parsed.message, + } +} + +async function grantRecordShare( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Promise { + const existing = await findDirectUserShare(auth, recordUid, email) + if (existing && isShareUpdateNoop(existing, accessRoleType, expirationTimestamp)) { + return { + recordUid, + email, + success: true, + actionTaken: 'update', + message: 'Already has requested access', + } + } + + if (existing && expirationTimestamp != null && expirationTimestamp > 0) { + const revokeResult = await revokeRecordShare(auth, recordUid, email) + if (!revokeResult.success) { + return { ...revokeResult, actionTaken: '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: '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 ? 'update' : '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[]): string { + if (plan.length === 0) return 'No record share changes planned.' + const lines = ['Record share plan'] + for (const item of plan) { + lines.push( + ` ${item.recordUid} ${item.title || '—'} ${item.email} ${item.action}${item.role ? ` ${item.role}` : ''}` + ) + } + 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 ? 'success' : '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() || 'viewer' + const accessRoleType = + action === NsfRecordShareAction.Grant ? resolveNsfRoleName(role) : 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) + } + + 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, + }) + } + } + + 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(auth, recordUid, email)) + } else { + results.push( + await grantRecordShare( + storage, + auth, + recordUid, + email, + accessRoleType!, + input.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/nsfShortcut.ts b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts new file mode 100644 index 00000000..b2ac1108 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts @@ -0,0 +1,389 @@ +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 './listNsf' +import { + KeeperDriveKind, + checkFolderRemovePermission, + ensureNestedShareRecord, + getKeeperDriveRecord, + getFolderDisplayName, + isNestedShareFolder, + isRootFolderUid, + normalizeParentUid, + requireAuthAccountUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import type { DKdFolderRecord } from '@keeper-security/keeperapi' + +const SHORTCUT_ERROR = ResultCodes.NSF_SHORTCUT_FAILED +const ROOT_FOLDER_LABEL = 'root' + +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 +} + +function isShortcutFolder(storage: InMemoryStorage, folderUid: string): boolean { + const normalized = normalizeParentUid(folderUid) + return isRootFolderUid(normalized) || isNestedShareFolder(storage, normalized) +} + +function formatFolderLabel(storage: InMemoryStorage, folderUid: string): string { + const normalized = normalizeParentUid(folderUid) + if (isRootFolderUid(normalized)) { + return `root (${ROOT_FOLDER_LABEL})` + } + const name = getFolderDisplayName(storage, normalized) + return `${name} (${normalized})` +} + +export function getNsfRecordShortcuts(storage: InMemoryStorage): Map> { + const records = new Map>() + + for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { + const folderUid = normalizeParentUid(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 normalized = normalizeParentUid(folderUid) + const matches = [...shortcuts.entries()] + .filter(([, folders]) => folders.has(normalized)) + .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 normalizeParentUid(folderUid) + } + + if (defaultFolderUid && isNestedShareFolder(storage, defaultFolderUid)) { + return normalizeParentUid(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) => { + if (!entry.recordUid?.length) return true + return webSafe64FromBytes(entry.recordUid) === recordUid + }) ?? response.folderRecordUpdateResult?.[0] + + if (!result) { + return { + folderUid, + success: true, + message: 'Record unlinked from folder', + } + } + + 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/nsfTransferRecord.ts b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts new file mode 100644 index 00000000..0c076e35 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts @@ -0,0 +1,132 @@ +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 '../sharing/Sharing' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { ensureNestedShareRecord, resolveNsfRecordIdentifier } from './nsfHelpers' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' + +const TRANSFER_ERROR = ResultCodes.NSF_TRANSFER_FAILED + +export type TransferNestedShareRecordInput = { + records: string[] + newOwnerEmail: string +} + +export type TransferNestedShareRecordResultItem = { + recordUid: string + success: boolean + message: string +} + +export type TransferNestedShareRecordResult = { + results: TransferNestedShareRecordResultItem[] + success: boolean +} + +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 success = status?.status?.toLowerCase().includes('success') ?? false + 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[] = [] + + 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) + 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 ? 'success' : 'failed'} ${item.message}` + ) + } + return lines.join('\n') +} diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts new file mode 100644 index 00000000..2514cdcf --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts @@ -0,0 +1,173 @@ +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, type NsfFolderColor } from './nsfConstants' +import { + checkFolderEditPermission, + ensureNestedShareFolder, + getFolderDisplayName, + getKeeperDriveFolder, + parseFolderModifyStatus, + patchNsfFolderMetadata, + requireAuthAccountUid, + resolveNsfFolderIdentifier, +} from './nsfHelpers' +import type { NsfFolderColorInput } from './mkdirNsf' + +type NsfFolderMetadata = { + name: string + color?: string +} + +export type UpdateNsfFolderInput = { + folder: string + name?: string + color?: NsfFolderColorInput + quiet?: boolean +} + +export type UpdateNsfFolderResult = { + folderUid: string + updated: boolean + message?: string +} + +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 +} + +async function buildUpdateFolderData( + storage: InMemoryStorage, + folderUid: string, + metadata: NsfFolderMetadata +): 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 + ) + + return Folder.FolderData.create({ + folderUid: normal64Bytes(folderUid), + data: encryptedData, + }) +} + +function buildSuccessMessage( + folderDisplayName: string, + newName: string | undefined, + color: NsfFolderColor | 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.` + } + return `Folder "${folderDisplayName}" has been updated.` +} + +export async function updateNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfFolderInput +): Promise { + const folderArg = input.folder?.trim() + if (!folderArg) { + throw new KeeperSdkError('Enter the path or UID of existing folder.', ResultCodes.NSF_UPDATE_FAILED) + } + + let newName = input.name + if (newName !== undefined) { + newName = newName.trim() + if (!newName) { + throw new KeeperSdkError('Folder name cannot be empty', ResultCodes.NSF_UPDATE_FAILED) + } + } + + const color = input.color !== undefined ? normalizeColor(input.color) : undefined + if (newName === undefined && color === undefined) { + throw new KeeperSdkError( + 'New folder name and/or color parameters are required.', + ResultCodes.NSF_UPDATE_FAILED + ) + } + + 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) + checkFolderEditPermission(storage, folderUid, auth.username, accountUid) + + const folderDisplayName = getFolderDisplayName(storage, folderUid) + + try { + const existing = readExistingMetadata(folderUid, storage) + const metadata = mergeFolderMetadata(existing, newName, color) + const folderData = await buildUpdateFolderData(storage, folderUid, metadata) + + const response = await auth.executeRest(folderUpdateMessage({ folderData: [folderData] })) + parseFolderModifyStatus(response.folderUpdateResults?.[0], ResultCodes.NSF_UPDATE_FAILED) + + await patchNsfFolderMetadata(storage, folderUid, metadata) + + return { + folderUid, + updated: true, + message: buildSuccessMessage(folderDisplayName, newName, color, 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 + ) + } +} 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/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index f67696b5..ba4adb6b 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -70,6 +70,10 @@ export enum NsfErrorCode { 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 { @@ -154,6 +158,10 @@ export const ResultCodes = { 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, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 363236ea..478fa268 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -81,10 +81,31 @@ import type { GetNsfOptions, GetNsfResult } from '../nestedShareFolders/getNsf' import type { LinkNsfRecordResult } from '../nestedShareFolders/linkNsfRecord' import type { RemoveNsfRecordInput, RemoveNsfRecordResult } from '../nestedShareFolders/removeNsfRecord' import type { MkdirNsfInput, MkdirNsfResult } from '../nestedShareFolders/mkdirNsf' +import type { UpdateNsfFolderInput, UpdateNsfFolderResult } from '../nestedShareFolders/updateNsfFolder' +import type { + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, +} from '../nestedShareFolders/nsfShare' +import type { + ListNsfShortcutsOptions, + NsfShortcutRow, + KeepNsfShortcutInput, + KeepNsfShortcutResult, +} from '../nestedShareFolders/nsfShortcut' +import type { + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, +} from '../nestedShareFolders/nsfTransferRecord' import type { RemoveNsfFolderInput, RemoveNsfFolderResult } from '../nestedShareFolders/removeNsfFolder' import type { GetNsfRecordDetailsInput, GetNsfRecordDetailsResult } from '../nestedShareFolders/getNsfRecordDetails' import type { UpdateNsfRecordInput, UpdateNsfRecordResult } from '../nestedShareFolders/updateNsfRecord' import type { AddNsfRecordInput, AddNsfRecordResult } from '../nestedShareFolders/addNsfRecord' +import type { + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, +} from '../nestedShareFolders/nsfRecordPermission' import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' import type { ListUserRow, @@ -769,6 +790,76 @@ export class KeeperVault { 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 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() @@ -808,6 +899,25 @@ export class KeeperVault { 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() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index a84d3b35..fbfcaea0 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -35,10 +35,15 @@ "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", "nsf:folder": "ts-node src/nestedShareFolders/nsf_folder.ts", "nsf:mkdir": "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:rmdir": "ts-node src/nestedShareFolders/nsf_folder.ts", "nsf:record-details": "ts-node src/nestedShareFolders/get_nsf_record_details.ts", "nsf:record-update": "ts-node src/nestedShareFolders/update_nsf_record.ts", "nsf:record-add": "ts-node src/nestedShareFolders/add_nsf_record.ts", + "nsf:record-permission": "ts-node src/nestedShareFolders/nsf_record_permission.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", 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/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 93a3e8f2..a3049652 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -20,6 +20,7 @@ import { record, folder, } 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 }> @@ -482,8 +483,7 @@ export const removeFolderMessage = ( folder.v3.remove.RemoveResponse ) -export interface IRecordDetailsDataRequest { - recordUids: Uint8Array[] +export interface IRecordDetailsDataRequest extends RecordUidsRequest { clientTime?: number } @@ -504,35 +504,16 @@ const RecordDetailsDataRequest = { if (message.clientTime != null) { writer.uint32(8).int64(message.clientTime) } - for (const uid of message.recordUids) { - writer.uint32(26).bytes(uid) - } - return writer + return recordUidsRequestCodec.encode(message, writer) }, } const RecordDetailsDataResponse = { decode(data: Uint8Array): IRecordDetailsDataResponse { - const reader = Reader.create(data) - const response: IRecordDetailsDataResponse = { - data: [], - forbiddenRecords: [], - } - while (reader.pos < reader.len) { - const tag = reader.uint32() - switch (tag >>> 3) { - case 1: - response.data.push(Records.RecordData.decode(reader, reader.uint32())) - break - case 2: - response.forbiddenRecords.push(reader.bytes() as Uint8Array) - break - default: - reader.skipType(tag & 7) - break - } - } - return response + const { items, forbiddenRecords } = decodeRepeatedWithForbidden(data, (reader, length) => + Records.RecordData.decode(reader, length) + ) + return { data: items, forbiddenRecords } }, } @@ -546,6 +527,11 @@ export const folderAddMessage = ( ): 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 => @@ -1074,6 +1060,87 @@ export const keeperDriveRecordsUpdate = ( ): RestMessage => createMessage(data, 'vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) +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 => + createMessage( + data, + 'vault/records/v3/transfer', + Records.RecordsOnwershipTransferRequest, + Records.RecordsOnwershipTransferResponse + ) + export const getSharingAdminsMessage = ( data: Enterprise.IGetSharingAdminsRequest ): RestMessage => From 384b5301332b2d784f06f4588b04d1cb674116d6 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 24 Jun 2026 10:37:20 +0530 Subject: [PATCH 10/48] removed cli package and migrated to different repo --- KeeperSdk/src/api.ts | 44 +--- KeeperSdk/src/cli/README.md | 124 ---------- KeeperSdk/src/cli/access.ts | 29 --- KeeperSdk/src/cli/account/whoamiFormat.ts | 73 ------ KeeperSdk/src/cli/builtinCommands.ts | 35 --- KeeperSdk/src/cli/commandHelpers.ts | 24 -- KeeperSdk/src/cli/commander/get.ts | 36 --- KeeperSdk/src/cli/commander/getCore.ts | 124 ---------- KeeperSdk/src/cli/commander/index.ts | 4 - KeeperSdk/src/cli/commander/listCore.ts | 116 --------- KeeperSdk/src/cli/commander/listSfCore.ts | 66 ------ KeeperSdk/src/cli/commander/lsCore.ts | 233 ------------------- KeeperSdk/src/cli/commander/misc.ts | 196 ---------------- KeeperSdk/src/cli/commander/nav.ts | 183 --------------- KeeperSdk/src/cli/commander/reportOutput.ts | 58 ----- KeeperSdk/src/cli/commands/help.ts | 70 ------ KeeperSdk/src/cli/commands/login.ts | 173 -------------- KeeperSdk/src/cli/commands/logout.ts | 36 --- KeeperSdk/src/cli/commands/restoreSession.ts | 220 ----------------- KeeperSdk/src/cli/commands/sync.ts | 55 ----- KeeperSdk/src/cli/commands/vault.ts | 53 ----- KeeperSdk/src/cli/dispatch.ts | 62 ----- KeeperSdk/src/cli/help.ts | 183 --------------- KeeperSdk/src/cli/index.ts | 103 -------- KeeperSdk/src/cli/jsonArg.ts | 25 -- KeeperSdk/src/cli/parse.ts | 217 ----------------- KeeperSdk/src/cli/parser.ts | 179 -------------- KeeperSdk/src/cli/prompt.ts | 12 - KeeperSdk/src/cli/registry.ts | 55 ----- KeeperSdk/src/cli/table.ts | 7 - KeeperSdk/src/cli/types.ts | 102 -------- KeeperSdk/src/cli/utils.ts | 18 -- KeeperSdk/src/cli/vaultSurface.ts | 18 -- 33 files changed, 3 insertions(+), 2930 deletions(-) delete mode 100644 KeeperSdk/src/cli/README.md delete mode 100644 KeeperSdk/src/cli/access.ts delete mode 100644 KeeperSdk/src/cli/account/whoamiFormat.ts delete mode 100644 KeeperSdk/src/cli/builtinCommands.ts delete mode 100644 KeeperSdk/src/cli/commandHelpers.ts delete mode 100644 KeeperSdk/src/cli/commander/get.ts delete mode 100644 KeeperSdk/src/cli/commander/getCore.ts delete mode 100644 KeeperSdk/src/cli/commander/index.ts delete mode 100644 KeeperSdk/src/cli/commander/listCore.ts delete mode 100644 KeeperSdk/src/cli/commander/listSfCore.ts delete mode 100644 KeeperSdk/src/cli/commander/lsCore.ts delete mode 100644 KeeperSdk/src/cli/commander/misc.ts delete mode 100644 KeeperSdk/src/cli/commander/nav.ts delete mode 100644 KeeperSdk/src/cli/commander/reportOutput.ts delete mode 100644 KeeperSdk/src/cli/commands/help.ts delete mode 100644 KeeperSdk/src/cli/commands/login.ts delete mode 100644 KeeperSdk/src/cli/commands/logout.ts delete mode 100644 KeeperSdk/src/cli/commands/restoreSession.ts delete mode 100644 KeeperSdk/src/cli/commands/sync.ts delete mode 100644 KeeperSdk/src/cli/commands/vault.ts delete mode 100644 KeeperSdk/src/cli/dispatch.ts delete mode 100644 KeeperSdk/src/cli/help.ts delete mode 100644 KeeperSdk/src/cli/index.ts delete mode 100644 KeeperSdk/src/cli/jsonArg.ts delete mode 100644 KeeperSdk/src/cli/parse.ts delete mode 100644 KeeperSdk/src/cli/parser.ts delete mode 100644 KeeperSdk/src/cli/prompt.ts delete mode 100644 KeeperSdk/src/cli/registry.ts delete mode 100644 KeeperSdk/src/cli/table.ts delete mode 100644 KeeperSdk/src/cli/types.ts delete mode 100644 KeeperSdk/src/cli/utils.ts delete mode 100644 KeeperSdk/src/cli/vaultSurface.ts diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts index 0e6ef8b9..225a3cd6 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -42,10 +42,13 @@ export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './util export { searchRecords, formatRecord, + formatRecordFields, getRecordTitle, getRecordType, getRecordFields, getRecordSummary, + getRecordDescription, + getRecordCategory, getRecordPassword, getRecordLogin, getRecordUrl, @@ -86,7 +89,6 @@ export type { KeeperVaultConfig, VaultSummary } from './vault/KeeperVault' export { buildWhoamiInfo, normalizeServerHost, resolveDataCenter } from './account/whoamiInfo' export type { WhoamiInfo, BuildWhoamiInfoInput } from './account/whoamiInfo' -export { formatWhoamiJson, formatWhoamiOutput } from './cli/account/whoamiFormat' export type { SessionRestoreInput } from './auth/sessionRestore' export { @@ -185,46 +187,6 @@ export type { AuthProvider, SharedFolderPermissionsInput } from './folders/Folde export { SharedFolderManager } from './sharedFolders/SharedFolderManager' -export { - dispatchCliLine, - dispatchKeeperCli, - ensureKeeperCliRegistry, - registerCliCommand, - registerCliAlias, - getCliCommand, - listCliCommands, - listCliCommandNames, - listCliCommandNamesForLoginState, - listCliCommandsForLoginState, - isAuthCliCommand, - listDocumentedCommands, - getDetailedHelpPage, - formatDetailedHelpForCommand, - tokenizeArguments, - parseCliArgs, - wantsCliHelp, - rejectUnknownOptions, - loginWithCredentials, - loginWithSessionToken, - runLoginCommand, - runLogoutCommand, - KeeperCliParser, - createKeeperCliParser, - getKeeperCliPromptPrefix, - BUILTIN_CLI_COMMANDS, - registerBuiltinCliCommands, - listCommand, -} from './cli' -export type { KeeperCliParserOptions } from './cli' -export type { - CliResult, - ParsedCli, - CliCommandDefinition, - CliHelpDoc, - KeeperCliHost, - KeeperCliVault, -} from './cli' - export { Auth, KeeperEnvironment, syncDown, Authentication } from '@keeper-security/keeperapi' export type { diff --git a/KeeperSdk/src/cli/README.md b/KeeperSdk/src/cli/README.md deleted file mode 100644 index 6a282593..00000000 --- a/KeeperSdk/src/cli/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# Keeper SDK — CLI layer - -Commander-aligned shell commands for browser shell. -Entry point: `dispatchCliLine(line, host)` or `createKeeperCliParser()`. - -## Commands - -### Before login - -| Command | Aliases | Notes | -|---------|---------|--------| -| `help` | `?` | All commands or `help ` | -| `login` | | Interactive / flags | -| `restore-session` | | Extension JSON / env | -| `register-device` | | Device token for session login | - -### After login - -| Area | Commands | -|------|----------| -| Session | `logout`, `sync` (`syncdown`, `sync-down`, `d`), `whoami` | -| Records | `list` (`l`), `search` (`s`), `get` (`g`) | -| Folders | `ls`, `cd`, `tree`, `mkdir` | -| Shared folders | `list-sf` (`lsf`) | -| Vault counts | `vault summary` | - -Every command supports `--help` / `-h`. - -Record/folder **writes** (`add`, `update`, `delete`, `share`, …) are SDK API only — not CLI commands yet. - -## Embedding (`KeeperCliHost`) - -Implement `KeeperCliHost` and expose a `KeeperCliVault` adapter (usually wrapping `KeeperVault`). - -**Required:** `isLoggedIn`, `login`, `loginWithSessionToken`, `logout`, `sync`, `getRecords`, `getSharedFolders`, `restoreSession`. - -**Optional** (command fails with a clear message if missing): - -| Method | Commands | -|--------|----------| -| `findRecord`, `findRecords` | `get`, `search` | -| `getRecordShareInfo` | `get` (share sections in detail output — planned) | -| `getWhoamiInfo` | `whoami` | -| `getSummary` | `vault summary` | -| `listFolder`, `changeDirectory`, `tree`, `mkdir`, … | folder navigation | -| `listSharedFolders` | `list-sf` | - -Types: `types.ts`. - -```typescript -import { dispatchCliLine, type KeeperCliHost } from '@keeper-security/keeper-sdk-javascript' - -const result = await dispatchCliLine('whoami', host) -process.stdout.write(result.out) -if (result.err) process.stderr.write(result.err) -``` - -## Commander parity - -### `whoami` - -Uses `vault.getWhoamiInfo()` → account summary API (user, server, data center, admin, license, storage, BreachWatch). - -- `--verbose` / `-v` — syncs vault, adds record/shared-folder/team counts and reporting status -- `--json` — Commander-compatible field names (`data_center`, `breachwatch`, …) - -Formatting: `account/whoamiFormat.ts`. - -### `get` - -| `--format` | Behavior | -|------------|----------| -| `detail` (default) | Record fields via `formatRecord` | -| `json` | Full `DRecord` JSON | -| `password` | Password field only | -| `fields` | Field name/value JSON | - -Target resolution: shared folder UID → folder path/UID → record UID/title. - -**Target Commander detail output** (UID/Type/Title + User Permissions + Shared Folder Permissions + Share Admins) requires wiring `getRecordShareInfo` in the host and a dedicated formatter — tracked below. - -### `search` vs `get` - -- **`get `** — exact UID (record, shared folder, or folder) -- **`search `** — text match across record title/fields (not raw UID lookup) - -## Extending - -```typescript -import { registerCliCommand, type CliCommandDefinition } from '@keeper-security/keeper-sdk-javascript' - -registerCliCommand({ - name: 'my-cmd', - description: '…', - usage: 'my-cmd', - help: { description: '…' }, - async run(host, parsed) { - return { code: 0, out: 'ok\n', err: '' } - }, -}) -``` - -Call `ensureKeeperCliRegistry()` before dispatch if you register commands after import. - -## Changelog (CLI) - -Track CLI-only changes here — not in the package root `KeeperSdk/README.md`. - -### Unreleased - -- **`whoami`** — Commander-style sections (User Info / Account); data in `account/whoamiInfo.ts`, format in `cli/account/whoamiFormat.ts`; `--verbose`, `--json` -- **`getDetailedHelpPage`** — exported from `help.ts` / `cli/index.ts` for public API (`getDetailedHelpPageForRegistry` for filtered registries) -- **Help system** — argparse-style `--help` via `formatArgparseHelp` (`help.ts`) - -### Planned / in progress - -- **`get --format detail`** — Commander layout with permissions and share admins (`getRecordShareInfo`) -- **Report commands** — `reportOutput.ts` helpers for table/csv/json; PDF not supported in JS CLI host - -### Conventions - -- Commander parity formatters live under `cli/commander/` or `cli/account/` -- Domain data builders stay outside `cli/` when used by `KeeperVault` -- Update this changelog when adding or changing commands, flags, or output format diff --git a/KeeperSdk/src/cli/access.ts b/KeeperSdk/src/cli/access.ts deleted file mode 100644 index 136fb0d7..00000000 --- a/KeeperSdk/src/cli/access.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CliCommandDefinition } from './types' -import { listCliCommands, resolveCliCommandName } from './registry' - -export const AUTH_CLI_COMMAND_NAMES = new Set([ - 'help', - 'login', - 'restore-session', -]) - -export function isAuthCliCommand(name: string): boolean { - const resolved = resolveCliCommandName(name) - return resolved != null && AUTH_CLI_COMMAND_NAMES.has(resolved) -} - -export function filterCliCommandsForLoginState( - commands: readonly CliCommandDefinition[], - loggedIn: boolean -): CliCommandDefinition[] { - if (loggedIn) return [...commands] - return commands.filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) -} - -export function listCliCommandsForLoginState(loggedIn: boolean): CliCommandDefinition[] { - return filterCliCommandsForLoginState(listCliCommands(), loggedIn) -} - -export function listCliCommandNamesForLoginState(loggedIn: boolean): readonly string[] { - return listCliCommandsForLoginState(loggedIn).map((c) => c.name) -} diff --git a/KeeperSdk/src/cli/account/whoamiFormat.ts b/KeeperSdk/src/cli/account/whoamiFormat.ts deleted file mode 100644 index bbd5c284..00000000 --- a/KeeperSdk/src/cli/account/whoamiFormat.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { WhoamiInfo } from '../../account/whoamiInfo' - -const SECTION_RULE = '─'.repeat(50) -const LABEL_WIDTH = 24 - -export function formatWhoamiJson(info: WhoamiInfo, options: { verbose?: boolean } = {}): string { - const payload: Record = { - logged_in: true, - user: info.user, - server: info.server, - data_center: info.dataCenter, - admin: info.admin, - account_type: info.accountType, - renewal_date: info.renewalDate, - storage_capacity: info.storageCapacity, - storage_usage: info.storageUsage, - storage_renewal_date: info.storageRenewalDate, - breachwatch: info.breachWatch, - } - - if (options.verbose) { - payload.reporting_and_alerts = info.reportingAndAlerts - payload.records_count = info.recordsCount ?? 0 - payload.shared_folders_count = info.sharedFoldersCount ?? 0 - payload.teams_count = info.teamsCount ?? 0 - } - - return JSON.stringify(payload, null, 2) + '\n' -} - -export function formatWhoamiOutput(info: WhoamiInfo, options: { verbose?: boolean } = {}): string { - const userRows = [ - { label: 'User', value: info.user }, - { label: 'Server', value: info.server }, - { label: 'Data Center', value: info.dataCenter }, - { label: 'Admin', value: info.admin ? 'Yes' : 'No' }, - ] - - if (options.verbose) { - userRows.push( - { label: 'Records', value: String(info.recordsCount ?? 0) }, - { label: 'Shared Folders', value: String(info.sharedFoldersCount ?? 0) }, - { label: 'Teams', value: String(info.teamsCount ?? 0) } - ) - } - - const accountRows = [ - { label: 'Account Type', value: info.accountType }, - { label: 'Renewal Date', value: info.renewalDate }, - { label: 'Storage Capacity', value: info.storageCapacity }, - { label: 'Usage', value: info.storageUsage }, - { label: 'Storage Renewal Date', value: info.storageRenewalDate }, - { label: 'BreachWatch', value: info.breachWatch ? 'Yes' : 'No' }, - ] - - if (options.verbose) { - accountRows.push({ - label: 'Reporting & Alerts', - value: info.reportingAndAlerts ? 'Yes' : 'No', - }) - } - - const sections = [renderWhoamiSection('User Info', userRows), renderWhoamiSection('Account', accountRows)] - return `\n${sections.join('\n\n')}\n` -} - -function renderWhoamiSection(title: string, rows: { label: string; value: string }[]): string { - const lines = [` ${title}`, ` ${SECTION_RULE}`] - for (const row of rows) { - lines.push(` ${row.label.padStart(LABEL_WIDTH)}: ${row.value}`) - } - return lines.join('\n') -} diff --git a/KeeperSdk/src/cli/builtinCommands.ts b/KeeperSdk/src/cli/builtinCommands.ts deleted file mode 100644 index 1ded245c..00000000 --- a/KeeperSdk/src/cli/builtinCommands.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { CliCommandDefinition } from './types' -import { registerCliCommand } from './registry' -import { helpCommand } from './commands/help' -import { loginCommand } from './commands/login' -import { logoutCommand } from './commands/logout' -import { restoreSessionCommand } from './commands/restoreSession' -import { syncCommand } from './commands/sync' -import { vaultCommand } from './commands/vault' -import { getCommand } from './commander/get' -import { cdCommand, lsCommand, mkdirCommand, treeCommand } from './commander/nav' -import { listCommand, listSfCommand, searchCommand, whoamiCommand } from './commander/misc' - -export const BUILTIN_CLI_COMMANDS: readonly CliCommandDefinition[] = [ - helpCommand, - loginCommand, - restoreSessionCommand, - syncCommand, - vaultCommand, - getCommand, - listCommand, - lsCommand, - cdCommand, - treeCommand, - mkdirCommand, - searchCommand, - listSfCommand, - whoamiCommand, - logoutCommand, -] - -export function registerBuiltinCliCommands(): void { - for (const def of BUILTIN_CLI_COMMANDS) { - registerCliCommand(def) - } -} diff --git a/KeeperSdk/src/cli/commandHelpers.ts b/KeeperSdk/src/cli/commandHelpers.ts deleted file mode 100644 index e4fa6652..00000000 --- a/KeeperSdk/src/cli/commandHelpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { CliResult, KeeperCliHost, KeeperCliVault } from './types' -import { ensureLoggedIn } from './commands/login' - -export async function ensureSession(host: KeeperCliHost): Promise { - const v = host.getVault() - if (v.isLoggedIn) return null - const r = await ensureLoggedIn(host) - return r.code === 0 ? null : r -} - -export function ensureCapability( - v: KeeperCliVault, - name: K, - context: string -): CliResult | null { - if (typeof v[name] !== 'function') { - return { - code: 1, - out: '', - err: `${context}: this host does not expose KeeperCliVault.${String(name)}.\n`, - } - } - return null -} diff --git a/KeeperSdk/src/cli/commander/get.ts b/KeeperSdk/src/cli/commander/get.ts deleted file mode 100644 index 13b6e3bc..00000000 --- a/KeeperSdk/src/cli/commander/get.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { CliCommandDefinition } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { executeGet } from './getCore' - -export const getCommand: CliCommandDefinition = { - name: 'get', - order: 10, - aliases: ['g'], - description: 'Get details of a record or folder by UID or title.', - usage: 'get [--format {detail,json,password,fields}] [--unmask]', - flagOptions: ['--format', '--unmask', '--detail', '--json'], - help: { - description: 'Get the details of a record/folder/team by UID or title', - usage: '[-h] [--unmask] [--format {detail,json,password,fields}] uid', - positionals: [{ name: 'uid', help: 'UID or title to search for' }], - options: [ - { flags: '--unmask', help: 'display hidden field values (records)' }, - { - flags: '--format', - choices: 'detail,json,password,fields', - help: 'output format (default: detail)', - }, - ], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(getCommand), err: '' } - } - try { - return await executeGet(host, parsed, 'get') - } catch (e) { - return { code: 1, out: '', err: host.formatError('get', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commander/getCore.ts b/KeeperSdk/src/cli/commander/getCore.ts deleted file mode 100644 index bdae4fd1..00000000 --- a/KeeperSdk/src/cli/commander/getCore.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { DRecord } from '@keeper-security/keeperapi' -import { - formatRecord, - formatRecordFields, - getRecordPassword, -} from '../../records/RecordUtils' -import type { CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt } from '../parse' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { GetFolderFormat } from '../../folders/getFolder' - -export type GetOutputFormat = 'detail' | 'json' | 'password' | 'fields' - -export function resolveGetFormat(parsed: ParsedCli): GetOutputFormat { - const raw = getOpt(parsed.opts, 'format')?.toLowerCase() - if (raw === 'json' || hasOpt(parsed.opts, 'json')) return 'json' - if (raw === 'password') return 'password' - if (raw === 'fields') return 'fields' - if (raw === 'detail') return 'detail' - return hasOpt(parsed.opts, 'detail') ? 'detail' : 'detail' -} - -export function resolveGetUnmask(parsed: ParsedCli): boolean { - return hasOpt(parsed.opts, 'unmask') -} - -export function getGetTarget(parsed: ParsedCli): string | undefined { - return parsed.positional[0]?.trim() || undefined -} - -async function outputRecord( - host: KeeperCliHost, - record: DRecord, - fmt: GetOutputFormat, - unmask: boolean, - cmd: string -): Promise { - if (fmt === 'password') { - const pw = getRecordPassword(record) - return { code: 0, out: pw ? `${pw}\n` : '', err: pw ? '' : `${cmd}: record has no password field\n` } - } - if (fmt === 'fields') { - return { code: 0, out: JSON.stringify(formatRecordFields(record, unmask), null, 2) + '\n', err: '' } - } - if (fmt === 'json') { - return { code: 0, out: JSON.stringify(record, null, 2) + '\n', err: '' } - } - return { code: 0, out: formatRecord(record, { showDetails: true, unmask }) + '\n', err: '' } -} - -async function tryGetFolder( - host: KeeperCliHost, - target: string, - fmt: GetOutputFormat, - cmd: string -): Promise { - const v = host.getVault() - if (!v.getFolder) return null - try { - const res = await v.getFolder(target, { - format: fmt === 'json' ? GetFolderFormat.JSON : GetFolderFormat.Detail, - }) - if (fmt === 'json') { - const json = (res as { json?: Record }).json ?? res - return { code: 0, out: JSON.stringify(json, null, 2) + '\n', err: '' } - } - const name = 'name' in res ? res.name : target - const uid = - 'folder_uid' in res - ? res.folder_uid - : 'shared_folder_uid' in res - ? res.shared_folder_uid - : target - return { code: 0, out: `${name}\t${uid}\n`, err: '' } - } catch { - return null - } -} - -async function tryGetSharedFolderByUid( - host: KeeperCliHost, - target: string, - fmt: GetOutputFormat, - cmd: string -): Promise { - const v = host.getVault() - const hit = v.getSharedFolders().find((sf) => sf.uid === target) - if (!hit) return null - if (fmt === 'json') { - return { code: 0, out: JSON.stringify(hit, null, 2) + '\n', err: '' } - } - if (fmt === 'password' || fmt === 'fields') { - return { code: 1, out: '', err: `${cmd}: --format ${fmt} applies to records only\n` } - } - return { code: 0, out: `${hit.name ?? '(unnamed)'}\t${hit.uid}\n`, err: '' } -} - -export async function executeGet(host: KeeperCliHost, parsed: ParsedCli, cmd = 'get'): Promise { - const target = getGetTarget(parsed) - if (!target) { - return { code: 1, out: '', err: `${cmd}: UID parameter is required\n` } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const fmt = resolveGetFormat(parsed) - const unmask = resolveGetUnmask(parsed) - - await v.sync() - - const sf = await tryGetSharedFolderByUid(host, target, fmt, cmd) - if (sf) return sf - - const folder = await tryGetFolder(host, target, fmt, cmd) - if (folder) return folder - - const cap = ensureCapability(v, 'findRecord', cmd) - if (cap) return cap - const record = v.findRecord!(target) - if (!record) { - return { code: 1, out: '', err: `${cmd}: cannot find any object matching "${target}"\n` } - } - return outputRecord(host, record, fmt, unmask, cmd) -} diff --git a/KeeperSdk/src/cli/commander/index.ts b/KeeperSdk/src/cli/commander/index.ts deleted file mode 100644 index 6795faac..00000000 --- a/KeeperSdk/src/cli/commander/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { getCommand } from './get' -export { executeGet } from './getCore' -export { lsCommand, cdCommand, treeCommand, mkdirCommand } from './nav' -export { listCommand, searchCommand, listSfCommand, whoamiCommand } from './misc' diff --git a/KeeperSdk/src/cli/commander/listCore.ts b/KeeperSdk/src/cli/commander/listCore.ts deleted file mode 100644 index 1d76e975..00000000 --- a/KeeperSdk/src/cli/commander/listCore.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { DRecord } from '@keeper-security/keeperapi' -import type { CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getAllOpt, getOpt, hasOpt } from '../parse' -import { ensureSession } from '../commandHelpers' -import { - formatRecordsListTable, - renderRecordsListAsciiTable, -} from '../../records/listRecordsTable' -import { getRecordFields, getRecordType, searchRecords } from '../../records/RecordUtils' -import { - emptyReportResult, - resolveReportFormat, - tableToCsv, - validateReportFormat, - validateReportOutput, -} from './reportOutput' - -function filterByRecordTypes(records: DRecord[], recordTypes: string[]): DRecord[] { - if (recordTypes.length === 0) return records - - const matchers: Array<(record: DRecord) => boolean> = [] - - for (const rt of recordTypes) { - const key = rt.toLowerCase() - if (key === 'app') { - matchers.push((record) => record.version === 5) - } else if (key === 'file') { - matchers.push( - (record) => - (record.version === 3 || record.version === 4) && - getRecordType(record).toLowerCase() === 'file' - ) - } else if (key === 'general' || key === 'legacy') { - matchers.push((record) => record.version === 1 || record.version === 2) - } else if (key === 'pam') { - matchers.push((record) => record.version === 6) - } else { - const typeName = key - matchers.push((record) => getRecordType(record).toLowerCase() === typeName) - } - } - - return records.filter((record) => matchers.some((match) => match(record))) -} - -function filterByFields(records: DRecord[], fieldNames: string[]): DRecord[] { - if (fieldNames.length === 0) return records - return records.filter((record) => { - const fields = getRecordFields(record) - return fieldNames.every((name) => { - const needle = name.toLowerCase() - return fields.some((field) => { - const type = (field.type || '').toLowerCase() - const label = (field.label || '').toLowerCase() - return type === needle || label === needle || type.includes(needle) || label.includes(needle) - }) - }) - }) -} - -function filterListRecords(records: DRecord[], parsed: ParsedCli): DRecord[] { - let result = records - const pattern = parsed.positional[0]?.trim() - if (pattern) { - result = searchRecords(result, pattern) - } - result = filterByRecordTypes(result, getAllOpt(parsed, 'type', 't')) - result = filterByFields(result, getAllOpt(parsed, 'field')) - return result -} - -function recordsToJsonRows(records: DRecord[]): Record[] { - const table = formatRecordsListTable(records, { verbose: true }) - return table.rows.map((row) => ({ - record_uid: row[1], - type: row[2], - title: row[3], - description: row[4], - shared: row[5] === 'True', - record_category: row[6], - })) -} - -export async function executeList(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - - const fmt = resolveReportFormat(parsed) - const badFmt = validateReportFormat('list', fmt) - if (badFmt) return badFmt - - const outputPath = getOpt(parsed.opts, 'output') - const badOut = validateReportOutput('list', fmt, outputPath) - if (badOut) return badOut - - const v = host.getVault() - await v.sync() - const records = filterListRecords(v.getRecords(), parsed) - const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') - - if (records.length === 0) { - return emptyReportResult('list', fmt, 'No records are found\n', outputPath) - } - - const table = formatRecordsListTable(records, { verbose }) - - if (fmt === 'json') { - return { code: 0, out: `${JSON.stringify(recordsToJsonRows(records), null, 2)}\n`, err: '' } - } - - if (fmt === 'csv') { - return { code: 0, out: tableToCsv(table.headers, table.rows), err: '' } - } - - return { code: 0, out: `${renderRecordsListAsciiTable(table)}\n`, err: '' } -} diff --git a/KeeperSdk/src/cli/commander/listSfCore.ts b/KeeperSdk/src/cli/commander/listSfCore.ts deleted file mode 100644 index 18832386..00000000 --- a/KeeperSdk/src/cli/commander/listSfCore.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt } from '../parse' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { - formatSharedFoldersTable, - renderSharedFoldersAsciiTable, - type ListSharedFolderRow, -} from '../../sharedFolders/listSharedFolders' -import { - emptyReportResult, - resolveReportFormat, - tableToCsv, - validateReportFormat, - validateReportOutput, -} from './reportOutput' - -function sharedFoldersToJson(rows: ListSharedFolderRow[]): Record[] { - return rows.map((row) => ({ - shared_folder_uid: row.shared_folder_uid, - name: row.name, - })) -} - -export async function executeListSf(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - - const v = host.getVault() - const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') - if (cap) return cap - - const fmt = resolveReportFormat(parsed) - const badFmt = validateReportFormat('list-sf', fmt) - if (badFmt) return badFmt - - const outputPath = getOpt(parsed.opts, 'output') - const badOut = validateReportOutput('list-sf', fmt, outputPath) - if (badOut) return badOut - - await v.sync() - - const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null - const roeEligible = hasOpt(parsed.opts, 'roe-eligible', 'roe') - const rows = v.listSharedFolders!({ pattern, roeEligible }) - - if (rows.length === 0) { - const message = pattern - ? `(no shared folders matched "${pattern}")\n` - : '(no shared folders)\n' - return emptyReportResult('list-sf', fmt, message, outputPath) - } - - const table = formatSharedFoldersTable(rows, { verbose: true }) - - if (fmt === 'json') { - return { code: 0, out: `${JSON.stringify(sharedFoldersToJson(rows), null, 2)}\n`, err: '' } - } - - if (fmt === 'csv') { - const csvHeaders = ['Shared Folder UID', 'Name'] - const csvRows = rows.map((row) => [row.shared_folder_uid, row.name]) - return { code: 0, out: tableToCsv(csvHeaders, csvRows), err: '' } - } - - return { code: 0, out: `${renderSharedFoldersAsciiTable(table)}\n`, err: '' } -} diff --git a/KeeperSdk/src/cli/commander/lsCore.ts b/KeeperSdk/src/cli/commander/lsCore.ts deleted file mode 100644 index 8b15fe0a..00000000 --- a/KeeperSdk/src/cli/commander/lsCore.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { ListFolderResult } from '../../folders/listFolder' -import type { CliResult, KeeperCliHost, KeeperCliVault, ParsedCli } from '../types' -import { getOpt, hasOpt } from '../parse' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { formatTable } from '../table' -import { - emptyReportResult, - resolveReportFormat, - tableToCsv, - validateReportFormat, - validateReportOutput, -} from './reportOutput' - -function truncateName(name: string, verbose: boolean): string { - if (verbose || name.length <= 40) return name - return `${name.slice(0, 25)}...${name.slice(-12)}` -} - -async function resolveLsContext( - v: KeeperCliVault, - rawPattern?: string -): Promise<{ folderUid: string | null; pattern: string | null }> { - const trimmed = rawPattern?.trim() - if (!trimmed) { - return { folderUid: v.getCurrentFolderUid?.() ?? null, pattern: null } - } - - if (v.tryResolvePath) { - const resolved = await v.tryResolvePath(trimmed) - return { - folderUid: resolved.folderUid, - pattern: resolved.remaining.trim() || null, - } - } - - if (v.changeDirectory) { - try { - const cd = await v.changeDirectory(trimmed) - return { folderUid: cd.folderUid, pattern: null } - } catch { - return { folderUid: v.getCurrentFolderUid?.() ?? null, pattern: trimmed } - } - } - - return { folderUid: v.getCurrentFolderUid?.() ?? null, pattern: trimmed } -} - -function formatLsSimple(result: ListFolderResult, verbose: boolean): string { - if (result.folders.length + result.records.length === 0) return '(empty)\n' - - const names: string[] = [] - for (const folder of result.folders) { - names.push(`${truncateName(folder.name || folder.uid, verbose)}/`) - } - for (const record of result.records) { - names.push(truncateName(record.name || record.uid, verbose)) - } - names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) - - const maxName = names.reduce((max, name) => Math.max(max, name.length), 0) - const width = 80 - const cols = Math.max(1, Math.floor(width / (maxName + 2))) - const lines: string[] = [] - for (let i = 0; i < names.length; i += cols) { - const row = names.slice(i, i + cols).map((name) => name.padEnd(maxName)) - lines.push(row.join(' ')) - } - return `${lines.join('\n')}\n` -} - -function formatLsDetailTable(result: ListFolderResult): string { - const chunks: string[] = [] - if (result.folders.length > 0) { - if (result.detail) { - const headers = ['folder_uid', 'name', 'flags', 'type'] - const rows = result.folders.map((folder) => [ - folder.uid, - folder.name, - folder.flags, - folder.folderKind, - ]) - chunks.push(formatTable(headers, rows).trimEnd()) - } else { - const headers = ['folder_uid', 'name', 'type'] - const rows = result.folders.map((folder) => [folder.uid, folder.name, folder.folderKind]) - chunks.push(formatTable(headers, rows).trimEnd()) - } - } - if (result.records.length > 0) { - if (result.detail) { - const headers = ['record_uid', 'type', 'title', 'flags'] - const rows = result.records.map((record) => [record.uid, record.type, record.name, record.flags]) - chunks.push(formatTable(headers, rows).trimEnd()) - } else { - const headers = ['record_uid', 'type', 'title'] - const rows = result.records.map((record) => [record.uid, record.type, record.name]) - chunks.push(formatTable(headers, rows).trimEnd()) - } - } - return `${chunks.join('\n')}\n` -} - -function formatLsJson(result: ListFolderResult): string { - const rows: Record[] = [] - if (result.detail) { - for (const folder of result.folders) { - rows.push({ - type: 'folder', - uid: folder.uid, - name: folder.name, - details: `Flags: ${folder.flags}, Subfolders: ${folder.subfolderCount}, Records: ${folder.recordCount}`, - }) - } - for (const record of result.records) { - rows.push({ - type: 'record', - uid: record.uid, - name: record.name, - details: `Type: ${record.type}, Flags: ${record.flags}`, - }) - } - } else { - for (const folder of result.folders) { - rows.push({ type: 'folder', uid: folder.uid, name: folder.name, details: '' }) - } - for (const record of result.records) { - rows.push({ - type: 'record', - uid: record.uid, - name: record.name, - details: `Type: ${record.type}`, - }) - } - } - rows.sort((a, b) => { - const typeCmp = a.type.localeCompare(b.type) - if (typeCmp !== 0) return typeCmp - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) - }) - return `${JSON.stringify(rows, null, 2)}\n` -} - -function formatLsCsv(result: ListFolderResult): string { - const headers = ['type', 'uid', 'name', 'details'] - const rows: string[][] = [] - if (result.detail) { - for (const folder of result.folders) { - rows.push([ - 'folder', - folder.uid, - folder.name, - `Flags: ${folder.flags}, Subfolders: ${folder.subfolderCount}, Records: ${folder.recordCount}`, - ]) - } - for (const record of result.records) { - rows.push(['record', record.uid, record.name, `Type: ${record.type}, Flags: ${record.flags}`]) - } - } else { - for (const folder of result.folders) { - rows.push(['folder', folder.uid, folder.name, '']) - } - for (const record of result.records) { - rows.push(['record', record.uid, record.name, `Type: ${record.type}`]) - } - } - rows.sort((a, b) => { - const typeCmp = a[0].localeCompare(b[0]) - if (typeCmp !== 0) return typeCmp - return a[2].localeCompare(b[2], undefined, { sensitivity: 'base' }) - }) - return tableToCsv(headers, rows) -} - -export async function executeLs(host: KeeperCliHost, parsed: ParsedCli, cmd = 'ls'): Promise { - const r = await ensureSession(host) - if (r) return r - - const v = host.getVault() - const cap = ensureCapability(v, 'listFolder', cmd) - if (cap) return cap - await v.sync() - - let fmt = resolveReportFormat(parsed) - const badFmt = validateReportFormat(cmd, fmt) - if (badFmt) return badFmt - - const outputPath = getOpt(parsed.opts, 'output') - const badOut = validateReportOutput(cmd, fmt, outputPath) - if (badOut) return badOut - - const foldersOnly = hasOpt(parsed.opts, 'folders', 'f') - const recordsOnly = hasOpt(parsed.opts, 'records', 'r') - let detail = hasOpt(parsed.opts, 'detail', 'list', 'l') - const verbose = hasOpt(parsed.opts, 'verbose', 'v') - const recursive = hasOpt(parsed.opts, 'recursive', 'R') - hasOpt(parsed.opts, 'short', 's') - - if (fmt === 'json' || fmt === 'csv') { - detail = true - } - - const showFolders = foldersOnly ? true : !recordsOnly - const showRecords = recordsOnly ? true : !foldersOnly - - const rawPattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') - const { folderUid, pattern } = await resolveLsContext(v, rawPattern) - - const result = await v.listFolder!({ - folderUid, - pattern, - detail, - showFolders, - showRecords, - recursive, - }) - - if (result.folders.length === 0 && result.records.length === 0) { - if (pattern) { - return { code: 1, out: '', err: `${cmd}: ${pattern}: No such folder or record\n` } - } - return emptyReportResult(cmd, fmt, '(empty)\n', outputPath) - } - - if (fmt === 'json') { - return { code: 0, out: formatLsJson(result), err: '' } - } - if (fmt === 'csv') { - return { code: 0, out: formatLsCsv(result), err: '' } - } - - const out = detail ? formatLsDetailTable(result) : formatLsSimple(result, verbose) - return { code: 0, out, err: '' } -} diff --git a/KeeperSdk/src/cli/commander/misc.ts b/KeeperSdk/src/cli/commander/misc.ts deleted file mode 100644 index be05a83a..00000000 --- a/KeeperSdk/src/cli/commander/misc.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { formatTable } from '../table' -import { getRecordTitle } from '../../records/RecordUtils' -import { recordUid } from '../utils' -import { formatWhoamiJson, formatWhoamiOutput } from '../account/whoamiFormat' -import { executeList } from './listCore' -import { executeListSf } from './listSfCore' - -async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { - const pattern = parsed.positional.join(' ') || getOpt(parsed.opts, 'pattern') - if (!pattern?.trim()) { - return { code: 1, out: '', err: 'search: missing search terms. Usage: search \n' } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'findRecords', 'search') - if (cap) return cap - await v.sync() - const matches = v.findRecords!(pattern) - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(matches, null, 2) + '\n', err: '' } - } - if (matches.length === 0) { - return { code: 0, out: `(no records matched "${pattern}")\n`, err: '' } - } - const rows = matches.map((rec) => [recordUid(rec), getRecordTitle(rec)]) - return { code: 0, out: formatTable(['record_uid', 'title'], rows), err: '' } -} - -async function runWhoami(host: KeeperCliHost, parsed: ParsedCli): Promise { - const v = host.getVault() - if (!v.isLoggedIn) { - return { code: 1, out: '', err: 'whoami: not logged in\n' } - } - - const cap = ensureCapability(v, 'getWhoamiInfo', 'whoami') - if (cap) return cap - - const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') - if (verbose) { - const syncCap = ensureCapability(v, 'sync', 'whoami') - if (syncCap) return syncCap - await v.sync!() - } - - const info = await v.getWhoamiInfo!({ includeVaultCounts: verbose }) - - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: formatWhoamiJson(info, { verbose }), err: '' } - } - - return { code: 0, out: formatWhoamiOutput(info, { verbose }), err: '' } -} - -export const listCommand: CliCommandDefinition = { - name: 'list', - order: 14, - aliases: ['l'], - description: 'List all records', - usage: 'list [--format {table,csv,json,pdf}] [--output OUTPUT] [-v] [-t RECORD_TYPE] [--field FIELD] [pattern]', - flagOptions: ['--format', '--output', '-v', '--verbose', '-t', '--type', '--field', '--json'], - valueShortFlags: ['t'], - help: { - description: 'List all records', - usage: '[-h] [--format {table,csv,json,pdf}] [--output OUTPUT] [-v] [-t RECORD_TYPE] [--field FIELD] [pattern]', - positionals: [{ name: 'pattern', nargs: '?', help: 'search pattern' }], - options: [ - { - flags: '--format', - choices: 'table,csv,json,pdf', - help: 'format of output', - }, - { - flags: '--output', - metavar: 'OUTPUT', - help: 'path to resulting output file (ignored for "table" format)', - }, - { flags: '-v, --verbose', help: 'verbose output' }, - { - flags: '-t, --type', - metavar: 'RECORD_TYPE', - help: 'List records of certain types. Can be repeated', - }, - { - flags: '--field', - metavar: 'FIELD', - help: 'Filter records by specific field(s). Can be specified multiple times.', - }, - ], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listCommand), err: '' } - try { - return await executeList(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('list', e) } - } - }, -} - -export const searchCommand: CliCommandDefinition = { - name: 'search', - order: 15, - aliases: ['s'], - description: 'Search vault records by text.', - usage: 'search [--json]', - flagOptions: ['--json', '--pattern'], - help: { - description: 'Search the vault. Words can be in any order.', - usage: '[-h] [--json] [pattern ...]', - positionals: [ - { - name: 'pattern', - nargs: '*', - help: 'search terms (space-separated, order independent)', - }, - ], - options: [{ flags: '--json', help: 'emit JSON' }], - epilog: 'For exact lookup by UID, use get instead of search.', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(searchCommand), err: '' } - try { - return await runSearch(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('search', e) } - } - }, -} - -export const listSfCommand: CliCommandDefinition = { - name: 'list-sf', - order: 16, - aliases: ['lsf'], - description: 'List all shared folders', - usage: 'list-sf [--format {table,csv,json,pdf}] [--output OUTPUT] [--roe-eligible] [pattern]', - flagOptions: ['--format', '--output', '--roe-eligible', '--roe', '--json', '--pattern'], - help: { - description: 'List all shared folders', - usage: '[-h] [--format {table,csv,json,pdf}] [--output OUTPUT] [--roe-eligible] [pattern]', - positionals: [{ name: 'pattern', nargs: '?', help: 'search pattern' }], - options: [ - { - flags: '--format', - choices: 'table,csv,json,pdf', - help: 'format of output', - }, - { - flags: '--output', - metavar: 'OUTPUT', - help: 'path to resulting output file (ignored for "table" format)', - }, - { - flags: '--roe-eligible', - help: - 'only list shared folders eligible for --rotate-on-expiration (contain at least one pamUser record with rotation configured)', - }, - ], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listSfCommand), err: '' } - try { - return await executeListSf(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('list-sf', e) } - } - }, -} - -export const whoamiCommand: CliCommandDefinition = { - name: 'whoami', - order: 18, - description: 'Display current user and account information.', - usage: 'whoami [--verbose|-v] [--json]', - flagOptions: ['--verbose', '-v', '--json'], - help: { - description: 'Display current user and account information.', - usage: '[-h] [-v] [--json]', - options: [ - { flags: '-v, --verbose', help: 'include vault counts and reporting status' }, - { flags: '--json', help: 'emit JSON' }, - ], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(whoamiCommand), err: '' } - try { - return await runWhoami(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('whoami', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commander/nav.ts b/KeeperSdk/src/cli/commander/nav.ts deleted file mode 100644 index 7d8e85e1..00000000 --- a/KeeperSdk/src/cli/commander/nav.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { executeLs } from './lsCore' - -async function runCd(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const target = parsed.positional[0] - if (!target) return { code: 1, out: '', err: `${cmd}: missing folder path. Usage: ${cmd} \n` } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'changeDirectory', cmd) - if (cap) return cap - try { - const res = await v.changeDirectory!(target) - return { code: 0, out: `${res.name}\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } - } -} - -async function runTree(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'tree', cmd) - if (cap) return cap - await v.sync() - const folderPath = parsed.positional[0] - const showRecords = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') - const out = await v.tree!(folderPath ? { folderPath, showRecords } : { showRecords }) - return { code: 0, out: out.endsWith('\n') ? out : out + '\n', err: '' } -} - -async function runMkdir(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const target = parsed.positional[0] - if (!target) { - return { code: 1, out: '', err: `${cmd}: missing path. Usage: ${cmd} [-sf]\n` } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'mkdir', cmd) - if (cap) return cap - const cwd = v.getWorkingFolderDisplayName?.() ?? 'My Vault' - const shared = - hasOpt(parsed.opts, 'shared-folder') || - hasOpt(parsed.opts, 'sf') || - hasOpt(parsed.opts, 'shared') - try { - const res = await v.mkdir!(target, { sharedFolder: shared }) - if (!res.success) { - return { code: 1, out: '', err: `${cmd} [in ${cwd}]: ${res.message ?? 'failed'}\n` } - } - return { code: 0, out: `${res.folderUid}\t${target} (in ${cwd})\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target} [in ${cwd}]`, e) } - } -} - -const lsHelp: CliCommandDefinition['help'] = { - description: 'List folder contents.', - usage: '[-h] [--format {table,csv,json,pdf}] [--output OUTPUT] [-l] [-f] [-r] [-s] [-v] [-R] [pattern]', - positionals: [{ name: 'pattern', nargs: '?', help: 'search pattern' }], - options: [ - { - flags: '--format', - choices: 'table,csv,json,pdf', - help: 'format of output', - }, - { - flags: '--output', - metavar: 'OUTPUT', - help: 'path to resulting output file (ignored for "table" format)', - }, - { flags: '-l, --list', help: 'show detailed list' }, - { flags: '-f, --folders', help: 'display folders only' }, - { flags: '-r, --records', help: 'display records only' }, - { flags: '-s, --short', help: 'Do not display record details. (Not used)' }, - { flags: '-v, --verbose', help: 'verbose output' }, - { flags: '-R, --recursive', help: 'list all folders/records in subfolders' }, - ], -} - -export const lsCommand: CliCommandDefinition = { - name: 'ls', - order: 11, - description: 'List folder contents.', - usage: 'ls [--format {table,csv,json,pdf}] [--output OUTPUT] [-l] [-f] [-r] [-s] [-v] [-R] [pattern]', - flagOptions: [ - '--format', - '--output', - '-l', - '--list', - '-f', - '--folders', - '-r', - '--records', - '-s', - '--short', - '-v', - '--verbose', - '-R', - '--recursive', - '--detail', - '--json', - '--pattern', - ], - help: lsHelp, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(lsCommand), err: '' } - try { - return await executeLs(host, parsed, 'ls') - } catch (e) { - return { code: 1, out: '', err: host.formatError('ls', e) } - } - }, -} - -export const cdCommand: CliCommandDefinition = { - name: 'cd', - order: 12, - description: 'Change current folder.', - usage: 'cd ', - help: { - description: 'Change current folder', - usage: '[-h] folder', - positionals: [{ name: 'folder', help: 'folder path or UID' }], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(cdCommand), err: '' } - try { - return await runCd(host, parsed, 'cd') - } catch (e) { - return { code: 1, out: '', err: host.formatError('cd', e) } - } - }, -} - -export const treeCommand: CliCommandDefinition = { - name: 'tree', - order: 13, - description: 'Display the folder structure.', - usage: 'tree [PATH] [-r|--records]', - flagOptions: ['-r', '--records'], - help: { - description: 'Display the folder structure.', - usage: '[-h] [-r] [folder]', - positionals: [{ name: 'folder', nargs: '?', help: 'folder path or UID' }], - options: [{ flags: '-r, --records', help: 'show records within each folder' }], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(treeCommand), err: '' } - try { - return await runTree(host, parsed, 'tree') - } catch (e) { - return { code: 1, out: '', err: host.formatError('tree', e) } - } - }, -} - -export const mkdirCommand: CliCommandDefinition = { - name: 'mkdir', - order: 14, - description: 'Create a folder.', - usage: 'mkdir [-sf|--shared-folder]', - flagOptions: ['-sf', '--shared-folder', '--shared'], - help: { - description: 'Create a folder', - usage: '[-h] [-sf] path', - positionals: [{ name: 'path', help: 'folder path' }], - options: [{ flags: '-sf, --shared-folder', help: 'create a shared folder' }], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(mkdirCommand), err: '' } - try { - return await runMkdir(host, parsed, 'mkdir') - } catch (e) { - return { code: 1, out: '', err: host.formatError('mkdir', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commander/reportOutput.ts b/KeeperSdk/src/cli/commander/reportOutput.ts deleted file mode 100644 index 54b96a5f..00000000 --- a/KeeperSdk/src/cli/commander/reportOutput.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { CliResult, ParsedCli } from '../types' -import { getOpt, hasOpt } from '../parse' - -export const REPORT_FORMATS = new Set(['table', 'csv', 'json', 'pdf']) - -export function resolveReportFormat(parsed: ParsedCli): string { - const fmt = getOpt(parsed.opts, 'format')?.toLowerCase() - if (fmt) return fmt - if (hasOpt(parsed.opts, 'json')) return 'json' - return 'table' -} - -export function escapeCsvCell(value: string): string { - if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"` - return value -} - -export function tableToCsv(headers: string[], rows: string[][]): string { - const lines = [ - headers.map(escapeCsvCell).join(','), - ...rows.map((row) => row.map((cell) => escapeCsvCell(cell ?? '')).join(',')), - ] - return `${lines.join('\n')}\n` -} - -export function validateReportFormat(command: string, fmt: string): CliResult | null { - if (!REPORT_FORMATS.has(fmt)) { - return { code: 1, out: '', err: `${command}: invalid --format ${fmt} (choose table, csv, json, pdf)\n` } - } - if (fmt === 'pdf') { - return { code: 1, out: '', err: `${command}: pdf output is not supported in this CLI\n` } - } - return null -} - -export function validateReportOutput(command: string, fmt: string, outputPath?: string): CliResult | null { - if (outputPath && fmt !== 'table') { - return { - code: 1, - out: '', - err: `${command}: --output file write is not supported in this host; omit --output to print to stdout\n`, - } - } - return null -} - -export function emptyReportResult(command: string, fmt: string, message: string, outputPath?: string): CliResult { - if (fmt === 'json') { - const bad = validateReportOutput(command, fmt, outputPath) - if (bad) return bad - return { code: 0, out: `${JSON.stringify([], null, 2)}\n`, err: '' } - } - if (fmt === 'csv') { - const bad = validateReportOutput(command, fmt, outputPath) - if (bad) return bad - } - return { code: 0, out: message, err: '' } -} diff --git a/KeeperSdk/src/cli/commands/help.ts b/KeeperSdk/src/cli/commands/help.ts deleted file mode 100644 index 34e01a62..00000000 --- a/KeeperSdk/src/cli/commands/help.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { CliCommandDefinition, KeeperCliHost } from '../types' -import { wantsCliHelp } from '../parse' -import { - formatAllCommandsSummary, - formatDetailedHelpForCommand, - formatShortCommandSummary, - getDetailedHelpPageForRegistry, -} from '../help' -import { isAuthCliCommand, listCliCommandsForLoginState } from '../access' -import { getCliCommand } from '../registry' - -export const helpCommand: CliCommandDefinition = { - name: 'help', - order: 0, - description: 'Show all commands, or full docs for one command (same as COMMAND --help).', - usage: 'help [command] (see also: help --help)', - help: { - description: 'Show all commands, or full docs for one command (same as COMMAND --help).', - usage: '[-h] [command]', - positionals: [{ name: 'command', nargs: '?', help: 'command name' }], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(helpCommand), err: '' } - } - if (parsed.opts.size > 0) { - return { code: 1, out: '', err: 'help: unknown option (try `help --help`)\n' } - } - const loggedIn = host.getVault().isLoggedIn - const visible = listCliCommandsForLoginState(loggedIn) - const args = parsed.positional - if (args.length === 0) { - if (loggedIn) { - return { code: 0, out: formatAllCommandsSummary(visible), err: '' } - } - return { - code: 0, - out: formatAllCommandsSummary(visible, { - header: 'Not logged in — sign-in commands:\n\n', - footer: - '\nRun `login` or `restore-session` to open the vault.\n' + - 'After login, run `help` again for vault commands (get, ls, cd, …).\n', - }), - err: '', - } - } - if (args.length > 1) { - return { code: 1, out: '', err: 'Usage: help [command]\n' } - } - const name = args[0] - if (!loggedIn && !isAuthCliCommand(name)) { - return { - code: 1, - out: '', - err: - `help: "${name}" requires a logged-in session. ` + - 'Run `help` for sign-in commands (login, restore-session).\n', - } - } - const long = getDetailedHelpPageForRegistry(visible, name) - if (long) { - return { code: 0, out: long, err: '' } - } - const def = getCliCommand(name) - if (!def) { - return { code: 1, out: '', err: `help: unknown command: ${name}\n` } - } - return { code: 0, out: formatShortCommandSummary(def), err: '' } - }, -} diff --git a/KeeperSdk/src/cli/commands/login.ts b/KeeperSdk/src/cli/commands/login.ts deleted file mode 100644 index 5881462a..00000000 --- a/KeeperSdk/src/cli/commands/login.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { utf8ToBase64Url } from '../utils' - -const LOGIN_ALLOWED = new Set([ - 'username', - 'user', - 'session-token', - 'token', - 'st', - 'session-token-plain', -]) - -export async function runLoginCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { - const opts = parsed?.opts ?? new Map() - if (parsed && wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(loginCommand), err: '' } - } - if (parsed) { - for (const secretFlag of ['password', 'pass', 'pwd'] as const) { - if (opts.has(secretFlag)) { - return { - code: 1, - out: '', - err: - 'login: do not pass --password on the command line (it is logged and visible). ' + - 'Use KEEPER_PASSWORD for automation, or run `login --username …` in the shell and enter the password when prompted (masked).\n', - } - } - } - const bad = rejectUnknownOptions(parsed, LOGIN_ALLOWED, 'login') - if (bad) return bad - } - - const username = getOpt(opts, 'username', 'user') ?? host.envString('KEEPER_USERNAME') - const passwordEnv = host.envString('KEEPER_PASSWORD') - const sessionRaw = getOpt(opts, 'session-token', 'token', 'st') ?? host.envString('KEEPER_SESSION_TOKEN') - const sessionPlain = parsed && hasOpt(opts, 'session-token-plain') - - if (parsed) { - const stPlainVal = opts.get('session-token-plain') - if (stPlainVal !== undefined && stPlainVal !== true) { - return { - code: 1, - out: '', - err: 'login: --session-token-plain is a boolean flag (no value)\n', - } - } - } - - if (!username) { - return { - code: 1, - out: '', - err: 'login: provide --username or KEEPER_USERNAME.\n', - } - } - - const sessionTrimmed = typeof sessionRaw === 'string' ? sessionRaw.trim() : '' - if (sessionTrimmed.length > 0) { - return loginWithSessionToken(host, username, sessionTrimmed, { plainToken: !!sessionPlain }) - } - - if (!passwordEnv) { - return { - code: 1, - needPassword: true, - loginUsername: username, - out: '', - err: '', - } - } - - return loginWithCredentials(host, username, passwordEnv) -} - -export async function loginWithCredentials( - host: KeeperCliHost, - username: string, - password: string -): Promise { - try { - const v = host.getVault() - if (v.isLoggedIn) { - await v.logout() - } - await v.login(username, password) - await v.sync() - return { code: 0, out: `keeper: logged in as ${username}.\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -export async function loginWithSessionToken( - host: KeeperCliHost, - username: string, - sessionToken: string, - options?: { plainToken?: boolean } -): Promise { - let token = sessionToken.trim() - if (options?.plainToken && token.length > 0) { - token = utf8ToBase64Url(token) - } - try { - const v = host.getVault() - if (v.isLoggedIn) { - await v.logout() - } - await v.loginWithSessionToken(username, token) - await v.sync() - return { code: 0, out: `keeper: logged in as ${username} (session token).\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -/** Pass-through if logged in; auto-login when `KEEPER_USERNAME` is set; otherwise "not logged in". */ -export async function ensureLoggedIn(host: KeeperCliHost): Promise { - if (host.getVault().isLoggedIn) { - return { code: 0, out: '', err: '' } - } - if (host.envString('KEEPER_USERNAME')) { - return runLoginCommand(host, { positional: [], opts: new Map(), repeatedOpts: new Map() }) - } - return { code: 1, out: '', err: 'not logged in\n' } -} - -export const loginCommand: CliCommandDefinition = { - name: 'login', - order: 10, - description: - 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN). Password never on CLI line.', - usage: - 'login [--username|--user ] [--session-token|--token|--st ] [--session-token-plain] [--help|-h]', - flagOptions: [ - '--user', - '--username', - '--session-token', - '--token', - '--st', - '--session-token-plain', - ], - allowedOptions: LOGIN_ALLOWED, - help: { - description: - 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN).', - usage: - '[-h] [--username USER] [--session-token TOKEN] [--session-token-plain]', - options: [ - { flags: '--username, --user', metavar: 'USER', help: 'account identifier (often email)' }, - { - flags: '--session-token, --token, --st', - metavar: 'TOKEN', - help: 'session token string (or use KEEPER_SESSION_TOKEN)', - }, - { - flags: '--session-token-plain', - help: 'treat --session-token value as plain UTF-8 and encode base64url', - }, - ], - epilog: `Password MUST NOT appear on the CLI line. Use KEEPER_PASSWORD for automation, or run -login with only a username and enter the password when prompted (masked). - -environment variables: - KEEPER_USERNAME default username if not passed on the command line - KEEPER_PASSWORD password for non-interactive login (no session token) - KEEPER_SESSION_TOKEN session token when not passed as a flag - KEEPER_HOST optional vault host / region`, - }, - run: (host, parsed) => runLoginCommand(host, parsed), -} diff --git a/KeeperSdk/src/cli/commands/logout.ts b/KeeperSdk/src/cli/commands/logout.ts deleted file mode 100644 index b6374451..00000000 --- a/KeeperSdk/src/cli/commands/logout.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' - -export async function runLogoutCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { - if (parsed && wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(logoutCommand), err: '' } - } - if (parsed && parsed.opts.size > 0) { - return { code: 1, out: '', err: 'logout: no options (try: logout --help)\n' } - } - if (parsed && parsed.positional.length > 0) { - return { code: 1, out: '', err: 'Usage: logout\n' } - } - try { - const v = host.getVault() - if (!v.isLoggedIn) { - return { code: 0, out: 'keeper: already logged out.\n', err: '' } - } - await v.logout() - return { code: 0, out: 'keeper: logged out.\n', err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -export const logoutCommand: CliCommandDefinition = { - name: 'logout', - order: 200, - description: 'Log out of the current Keeper session.', - usage: 'logout [--help|-h]', - help: { - description: 'Log out of the current Keeper session.', - }, - run: (host, parsed) => runLogoutCommand(host, parsed), -} diff --git a/KeeperSdk/src/cli/commands/restoreSession.ts b/KeeperSdk/src/cli/commands/restoreSession.ts deleted file mode 100644 index 5054abbe..00000000 --- a/KeeperSdk/src/cli/commands/restoreSession.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { CliCommandDefinition, KeeperCliHost, ParsedCli } from '../types' -import { - resolveSessionRestorePayload, - validateSessionRestoreInput, - type SessionRestoreInput, -} from '../../auth/sessionRestore' -import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { runVaultSync } from './sync' - -export const RESTORE_SESSION_TRAILING_OPTS = [ - 'sync', - 'account-uid', - 'client-key', - 'data-key', - 'ecc-private-key', - 'ecc-public-key', - 'message-session-uid', - 'private-key', - 'session-token', - 'st', - 'session-token-type', - 'username', - 'user', - 'user-type', - 'sso-logout-url', - 'sso-session-id', - 'enterprise-public-key', - 'enterprise-ecc-public-key', -] as const - -const RESTORE_ALLOWED = new Set([ - 'sync', - 'from-json', - 'account-uid', - 'client-key', - 'data-key', - 'ecc-private-key', - 'ecc-public-key', - 'message-session-uid', - 'private-key', - 'session-token', - 'st', - 'session-token-type', - 'username', - 'user', - 'user-type', - 'sso-logout-url', - 'sso-session-id', - 'enterprise-public-key', - 'enterprise-ecc-public-key', -]) - -const ENV_PREFIX = 'RESTORE_SESSION_' - -const FIELD_ENV: Record = { - ACCOUNT_UID: 'accountUid', - CLIENT_KEY: 'clientKey', - DATA_KEY: 'dataKey', - ECC_PRIVATE_KEY: 'eccPrivateKey', - ECC_PUBLIC_KEY: 'eccPublicKey', - MESSAGE_SESSION_UID: 'messageSessionUid', - PRIVATE_KEY: 'privateKey', - SESSION_TOKEN: 'sessionToken', - SESSION_TOKEN_TYPE: 'sessionTokenType', - USERNAME: 'username', - USER_TYPE: 'userType', - SSO_LOGOUT_URL: 'ssoLogoutUrl', - SSO_SESSION_ID: 'ssoSessionId', - ENTERPRISE_PUBLIC_KEY: 'enterprisePublicKey', - ENTERPRISE_ECC_PUBLIC_KEY: 'enterpriseEccPublicKey', -} - -const OPT_TO_FIELD: Record = { - 'account-uid': 'accountUid', - 'client-key': 'clientKey', - 'data-key': 'dataKey', - 'ecc-private-key': 'eccPrivateKey', - 'ecc-public-key': 'eccPublicKey', - 'message-session-uid': 'messageSessionUid', - 'private-key': 'privateKey', - 'session-token': 'sessionToken', - st: 'sessionToken', - 'session-token-type': 'sessionTokenType', - username: 'username', - user: 'username', - 'user-type': 'userType', - 'sso-logout-url': 'ssoLogoutUrl', - 'sso-session-id': 'ssoSessionId', - 'enterprise-public-key': 'enterprisePublicKey', - 'enterprise-ecc-public-key': 'enterpriseEccPublicKey', -} - -function envField(host: KeeperCliHost, key: keyof SessionRestoreInput): string | undefined { - const envKey = Object.entries(FIELD_ENV).find(([, v]) => v === key)?.[0] - return envKey ? host.envString(`${ENV_PREFIX}${envKey}`) : undefined -} - -function buildInputFromFlags(host: KeeperCliHost, parsed: ParsedCli): SessionRestoreInput { - const partial: Partial = {} - - for (const [opt, field] of Object.entries(OPT_TO_FIELD)) { - const fromFlag = getOpt(parsed.opts, opt) - if (fromFlag !== undefined) { - ;(partial as Record)[field] = fromFlag - continue - } - const fromEnv = envField(host, field) - if (fromEnv !== undefined) { - ;(partial as Record)[field] = fromEnv - } - } - - return validateSessionRestoreInput(partial) -} - -export const restoreSessionCommand: CliCommandDefinition = { - name: 'restore-session', - order: 12, - description: - 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', - usage: - 'restore-session --from-json FILE|JSON [--sync] OR restore-session --session-token … (see --help)', - flagOptions: [ - '--sync', - '--from-json', - '--account-uid', - '--client-key', - '--data-key', - '--ecc-private-key', - '--ecc-public-key', - '--message-session-uid', - '--private-key', - '--session-token', - '--session-token-type', - '--username', - '--user-type', - '--sso-logout-url', - '--sso-session-id', - '--enterprise-public-key', - '--enterprise-ecc-public-key', - ], - allowedOptions: RESTORE_ALLOWED, - help: { - description: - 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', - usage: '[-h] [--from-json JSON] [--sync] [session fields as flags]', - options: [ - { - flags: '--from-json', - metavar: 'JSON', - help: 'inline JSON object/string or path to a session JSON file', - }, - { flags: '--account-uid', metavar: 'B64', help: 'account UID (base64)' }, - { flags: '--client-key', metavar: 'B64', help: 'client key (base64)' }, - { flags: '--data-key', metavar: 'B64', help: 'data key (base64)' }, - { flags: '--ecc-private-key', metavar: 'B64', help: 'ECC private key (base64)' }, - { flags: '--ecc-public-key', metavar: 'B64', help: 'ECC public key (base64)' }, - { flags: '--message-session-uid', metavar: 'B64', help: 'message session UID (base64)' }, - { flags: '--private-key', metavar: 'B64', help: 'RSA private key (base64)' }, - { - flags: '--session-token, --st', - metavar: 'TOKEN', - help: 'session token string (as stored; often base64url)', - }, - { flags: '--session-token-type', metavar: 'N', help: 'numeric SessionTokenType enum' }, - { flags: '--username, --user', metavar: 'USER', help: 'account username' }, - { flags: '--user-type', metavar: 'TYPE', help: '0=normal, 1=cloud_sso, 2=onsite_sso' }, - { flags: '--sso-logout-url', metavar: 'URL', help: 'SSO logout URL' }, - { flags: '--sso-session-id', metavar: 'ID', help: 'SSO session ID' }, - { flags: '--enterprise-public-key', metavar: 'B64', help: 'enterprise public key (optional)' }, - { flags: '--enterprise-ecc-public-key', metavar: 'B64', help: 'enterprise ECC public key (optional)' }, - { flags: '--sync', help: 'run syncDown after restoring the session' }, - ], - epilog: `Provide parameters either as one JSON object (--from-json) or as flags / env. -Binary fields are base64 or base64url. sessionToken expires; region must match keeper-host / KEEPER_HOST. - -environment variables: - RESTORE_SESSION_JSON same as --from-json - RESTORE_SESSION_ per-field overrides (see flags above)`, - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(restoreSessionCommand), err: '' } - } - const bad = rejectUnknownOptions(parsed, RESTORE_ALLOWED, 'restore-session') - if (bad) return bad - if (parsed.positional.length > 0) { - return { code: 1, out: '', err: 'restore-session: unexpected positional arguments\n' } - } - - try { - let input: SessionRestoreInput - const jsonRaw = getOpt(parsed.opts, 'from-json') ?? host.envString('RESTORE_SESSION_JSON') - if (jsonRaw) { - const readFile = - host.readTextFile ?? - (typeof document === 'undefined' - ? async (path: string) => (await import('fs/promises')).readFile(path, 'utf8') - : undefined) - input = await resolveSessionRestorePayload(jsonRaw, readFile) - } else { - input = buildInputFromFlags(host, parsed) - } - - await host.getVault().restoreSession(input) - let out = `keeper: session restored for ${input.username}.\n` - if (hasOpt(parsed.opts, 'sync')) { - const syncResult = await runVaultSync(host) - if (syncResult.code !== 0) { - return syncResult - } - out += syncResult.out - } - return { code: 0, out, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('restore-session', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commands/sync.ts b/KeeperSdk/src/cli/commands/sync.ts deleted file mode 100644 index fb983c28..00000000 --- a/KeeperSdk/src/cli/commands/sync.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { SyncResult } from '@keeper-security/keeperapi' -import type { CliCommandDefinition, CliResult, KeeperCliHost } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureLoggedIn } from './login' - -function formatSyncSummary(result: SyncResult): string { - const lines = [`keeper: sync complete for ${result.username}.`, ` pages: ${result.pageCount}`] - if (result.totalTime) lines.push(` total: ${result.totalTime}`) - if (result.networkTime) lines.push(` network: ${result.networkTime}`) - const counts = result.counts ?? {} - const parts = Object.entries(counts) - .filter(([, n]) => typeof n === 'number' && n > 0) - .map(([k, n]) => `${k}=${n}`) - if (parts.length) lines.push(` counts: ${parts.join(', ')}`) - if (result.error) lines.push(` warning: ${result.error}`) - return lines.join('\n') + '\n' -} - -export async function runVaultSync(host: KeeperCliHost): Promise { - const v = host.getVault() - if (!v.isLoggedIn) { - const login = await ensureLoggedIn(host) - if (login.code !== 0) return login - } - const result = await v.sync() - return { code: 0, out: formatSyncSummary(result), err: '' } -} - -export const syncCommand: CliCommandDefinition = { - name: 'sync', - order: 20, - aliases: ['syncdown', 'sync-down', 'd'], - description: 'Download / refresh vault data from Keeper (syncDown).', - usage: 'sync [--help|-h]', - help: { - description: 'Download / refresh vault data from Keeper (syncDown).', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(syncCommand), err: '' } - } - if (parsed.opts.size > 0) { - return { code: 1, out: '', err: 'sync: unknown option (try: sync --help)\n' } - } - if (parsed.positional.length > 0) { - return { code: 1, out: '', err: 'sync: unexpected arguments (try: sync --help)\n' } - } - try { - return await runVaultSync(host) - } catch (e) { - return { code: 1, out: '', err: host.formatError('sync', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commands/vault.ts b/KeeperSdk/src/cli/commands/vault.ts deleted file mode 100644 index 3853ea39..00000000 --- a/KeeperSdk/src/cli/commands/vault.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' - -async function runSummary(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'getSummary', 'vault summary') - if (cap) return cap - await v.sync!() - const summary = v.getSummary!() - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(summary, null, 2) + '\n', err: '' } - } - const lines = [ - `records: ${summary.recordCount}`, - `shared_folders: ${summary.sharedFolderCount}`, - `teams: ${summary.teamCount}`, - `folders: ${summary.folderCount}`, - ] - return { code: 0, out: lines.join('\n') + '\n', err: '' } -} - -export const vaultCommand: CliCommandDefinition = { - name: 'vault', - order: 25, - description: 'Vault summary counts (records, folders, shared folders).', - usage: 'vault summary [--json] [--help|-h]', - subcommands: ['summary'], - flagOptions: ['--json'], - help: { - description: 'Vault summary counts (records, folders, shared folders).', - usage: '[-h] [--json] summary', - positionals: [{ name: 'summary', help: 'print record, shared folder, and user-folder counts' }], - options: [{ flags: '--json', help: 'emit JSON' }], - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(vaultCommand), err: '' } - } - const sub = parsed.positional[0]?.toLowerCase() ?? 'summary' - if (sub !== 'summary') { - return { code: 1, out: '', err: 'Usage: vault summary\n' } - } - try { - return await runSummary(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('vault summary', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/dispatch.ts b/KeeperSdk/src/cli/dispatch.ts deleted file mode 100644 index 9e913453..00000000 --- a/KeeperSdk/src/cli/dispatch.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { CliResult, KeeperCliHost, ParsedCli } from './types' -import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' -import { extractFromJsonFlagValue } from './jsonArg' -import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' -import { formatDetailedHelpForCommand } from './help' -import { isAuthCliCommand } from './access' -import { getCliCommand } from './registry' -import type { CliCommandDefinition } from './types' - -function valueShortFlagsForCommand(def: CliCommandDefinition): ReadonlySet { - const out = new Set() - for (const flag of def.valueShortFlags ?? []) { - out.add(flag.replace(/^-+/, '').toLowerCase()) - } - return out -} - -const NOT_LOGGED_IN_ERR = - 'Not logged in. Run `login` or `restore-session` (see `help`).\n' - -export async function dispatchKeeperCli( - commandName: string, - args: string[], - host: KeeperCliHost, - preParsed?: ParsedCli -): Promise { - const def = getCliCommand(commandName) - if (!def) { - return { code: 1, out: '', err: `Unknown command: ${commandName}\n` } - } - if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { - return { code: 1, out: '', err: NOT_LOGGED_IN_ERR } - } - const parsed = preParsed ?? parseCliArgs(args, { valueShortFlags: valueShortFlagsForCommand(def) }) - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(def), err: '' } - } - return def.run(host, parsed) -} - -export async function dispatchCliLine(line: string, host: KeeperCliHost): Promise { - const trimmed = line.trim() - if (!trimmed) { - return { code: 0, out: '', err: '' } - } - const tokens = tokenizeArguments(trimmed) - const name = tokens[0]?.toLowerCase() - if (!name) { - return { code: 0, out: '', err: '' } - } - const args = tokens.slice(1) - const def = getCliCommand(name) - let preParsed: ParsedCli | undefined - if (name === 'restore-session' && def) { - const json = extractFromJsonFlagValue(trimmed, 'from-json', RESTORE_SESSION_TRAILING_OPTS) - if (json) { - preParsed = parseCliArgs(args, { valueShortFlags: valueShortFlagsForCommand(def) }) - preParsed.opts.set('from-json', json) - } - } - return dispatchKeeperCli(name, args, host, preParsed) -} diff --git a/KeeperSdk/src/cli/help.ts b/KeeperSdk/src/cli/help.ts deleted file mode 100644 index b4edcd72..00000000 --- a/KeeperSdk/src/cli/help.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { CliCommandDefinition, CliHelpDoc, CliHelpOption, CliHelpPositional } from './types' -import { listCliCommands } from './registry' - -const HELP_INDENT = 2 -const MAX_HELP_POSITION = 24 -const HELP_WIDTH = 80 - -export const STANDARD_HELP_OPTION: CliHelpOption = { - flags: '-h, --help', - help: 'show this help message and exit', -} - -function wrapText(text: string, width: number, indent: string): string[] { - const words = text.split(/\s+/).filter(Boolean) - if (words.length === 0) return [] - const lines: string[] = [] - let line = '' - for (const word of words) { - const next = line ? `${line} ${word}` : word - if (next.length > width) { - if (line) lines.push(indent + line) - line = word - } else { - line = next - } - } - if (line) lines.push(indent + line) - return lines -} - -function formatHelpEntry(invocation: string, help: string): string[] { - const prefix = ' '.repeat(HELP_INDENT) - const header = prefix + invocation - const helpCol = HELP_INDENT + MAX_HELP_POSITION - const contIndent = ' '.repeat(helpCol) - const wrapWidth = HELP_WIDTH - helpCol - - const paragraphs = help.split('\n') - const lines: string[] = [] - - for (let p = 0; p < paragraphs.length; p++) { - const paragraph = paragraphs[p].trim() - if (!paragraph) continue - const wrapped = wrapText(paragraph, wrapWidth, contIndent) - - if (p === 0) { - if (header.length < helpCol) { - lines.push(header + ' '.repeat(helpCol - header.length) + wrapped[0].slice(contIndent.length)) - lines.push(...wrapped.slice(1)) - } else { - lines.push(header) - lines.push(...wrapped) - } - } else { - lines.push('') - lines.push(...wrapped) - } - } - - return lines -} - -function positionalUsageToken(pos: CliHelpPositional): string { - if (pos.nargs === '*') return `[${pos.name} ...]` - if (pos.nargs === '+') return `${pos.name} [${pos.name} ...]` - if (pos.nargs === '?') return `[${pos.name}]` - return pos.name -} - -function optionHelpInvocation(opt: CliHelpOption): string { - if (opt.choices) { - const flag = opt.flags.split(',')[0].trim() - return `${flag} {${opt.choices}}` - } - if (opt.metavar) { - const flag = opt.flags.split(',')[0].trim() - return `${flag} ${opt.metavar}` - } - return opt.flags -} - -function buildUsage(def: CliCommandDefinition, doc: CliHelpDoc): string { - if (doc.usage) return `usage: ${def.name} ${doc.usage}` - - const tokens: string[] = ['[-h]'] - for (const opt of doc.options ?? []) { - if (opt.flags.includes('--help') || opt.flags.includes('-h')) continue - tokens.push(optionHelpInvocation(opt)) - } - for (const pos of doc.positionals ?? []) { - tokens.push(positionalUsageToken(pos)) - } - return `usage: ${def.name} ${tokens.join(' ')}` -} - -function appendSection(lines: string[], title: string, body: string[]): void { - if (body.length === 0) return - lines.push('') - lines.push(title) - lines.push(...body) -} - -export function formatArgparseHelp(def: CliCommandDefinition): string { - const doc = def.help - const lines: string[] = [buildUsage(def, doc), '', doc.description.trim()] - - const positionals = doc.positionals ?? [] - if (positionals.length > 0) { - const body: string[] = [] - for (const pos of positionals) { - body.push(...formatHelpEntry(pos.name, pos.help)) - } - appendSection(lines, 'positional arguments:', body) - } - - const options = [...(doc.options ?? []), STANDARD_HELP_OPTION] - const hasHelpAlready = (doc.options ?? []).some( - (o) => o.flags.includes('--help') || o.flags.includes('-h') - ) - const optionList = hasHelpAlready ? (doc.options ?? []) : options - if (optionList.length > 0) { - const body: string[] = [] - for (const opt of optionList) { - body.push(...formatHelpEntry(optionHelpInvocation(opt), opt.help)) - } - appendSection(lines, 'options:', body) - } - - if (doc.epilog?.trim()) { - lines.push('') - lines.push(doc.epilog.trim()) - } - - return `${lines.join('\n')}\n` -} - -export function formatDetailedHelpForCommand(def: CliCommandDefinition): string { - return formatArgparseHelp(def) -} - -export function getDetailedHelpPageForRegistry( - commands: Iterable, - name: string -): string | null { - const key = name.toLowerCase() - for (const def of commands) { - if (def.name === key) return formatDetailedHelpForCommand(def) - if (def.aliases?.some((a) => a.toLowerCase() === key)) { - return formatDetailedHelpForCommand(def) - } - } - return null -} - -export function getDetailedHelpPage(name: string): string | null { - return getDetailedHelpPageForRegistry(listCliCommands(), name) -} - -export type CommandsSummaryOptions = { - header?: string - footer?: string -} - -export function formatAllCommandsSummary( - commands: readonly CliCommandDefinition[], - options?: CommandsSummaryOptions -): string { - const sorted = [...commands].sort((a, b) => a.name.localeCompare(b.name)) - const w = Math.max(...sorted.map((c) => c.name.length), 8) - let out = options?.header ?? 'Supported commands:\n\n' - if (!out.endsWith('\n\n')) { - out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n` - } - for (const c of sorted) { - out += ` ${c.name.padEnd(w)} ${c.description}\n` - } - out += options?.footer ?? '\nRun ` --help` (or `-h`) for details on a specific command.\n' - return out -} - -export function formatShortCommandSummary(def: CliCommandDefinition): string { - return `${def.name} — ${def.description}\n${buildUsage(def, def.help)}\n` -} diff --git a/KeeperSdk/src/cli/index.ts b/KeeperSdk/src/cli/index.ts deleted file mode 100644 index 9327694e..00000000 --- a/KeeperSdk/src/cli/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { registerBuiltinCliCommands } from './builtinCommands' -import { registerCliAlias } from './registry' - -let registryInitialized = false - -export function ensureKeeperCliRegistry(): void { - if (registryInitialized) return - registryInitialized = true - registerBuiltinCliCommands() - registerCliAlias('?', 'help') -} - -ensureKeeperCliRegistry() - -export type { - CliResult, - ParsedCli, - CliHelpDoc, - CliCommandDefinition, - KeeperCliHost, - KeeperCliVault, -} from './types' - -export { - tokenizeArguments, - parseCliArgs, - hasOpt, - getOpt, - getAllOpt, - wantsCliHelp, - rejectUnknownOptions, -} from './parse' - -export { - formatDetailedHelpForCommand, - formatAllCommandsSummary, - formatShortCommandSummary, - getDetailedHelpPageForRegistry, - getDetailedHelpPage, -} from './help' - -export { - registerCliCommand, - registerCliAlias, - resolveCliCommandName, - getCliCommand, - listCliCommands, - listCliCommandNames, - listDocumentedCommands, - clearCliRegistry, -} from './registry' - -export { - AUTH_CLI_COMMAND_NAMES, - isAuthCliCommand, - filterCliCommandsForLoginState, - listCliCommandsForLoginState, - listCliCommandNamesForLoginState, -} from './access' - -export { dispatchKeeperCli, dispatchCliLine } from './dispatch' - -export { KeeperCliParser, createKeeperCliParser } from './parser' -export type { KeeperCliParserOptions } from './parser' - -export { - runLoginCommand, - loginWithCredentials, - loginWithSessionToken, - ensureLoggedIn, - loginCommand, -} from './commands/login' - -export { runLogoutCommand, logoutCommand } from './commands/logout' -export { vaultCommand } from './commands/vault' -export { helpCommand } from './commands/help' -export { restoreSessionCommand } from './commands/restoreSession' -export { syncCommand, runVaultSync } from './commands/sync' - -export { getKeeperCliPromptPrefix } from './prompt' -export { BUILTIN_CLI_COMMANDS, registerBuiltinCliCommands } from './builtinCommands' -export { - getCommand, - executeGet, - listCommand, - searchCommand, - listSfCommand, - whoamiCommand, - lsCommand, - cdCommand, - treeCommand, - mkdirCommand, -} from './commander' - -export { utf8ToBase64Url, recordUid } from './utils' - -export type { SessionRestoreInput } from '../auth/sessionRestore' -export { - toSessionParams, - validateSessionRestoreInput, - sessionRestoreFromJson, - resolveSessionRestorePayload, -} from '../auth/sessionRestore' diff --git a/KeeperSdk/src/cli/jsonArg.ts b/KeeperSdk/src/cli/jsonArg.ts deleted file mode 100644 index 9162a96c..00000000 --- a/KeeperSdk/src/cli/jsonArg.ts +++ /dev/null @@ -1,25 +0,0 @@ -export function extractFromJsonFlagValue( - line: string, - flag = 'from-json', - trailingFlags: readonly string[] = [] -): string | null { - const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const flagRe = new RegExp(`(?:^|\\s)--${escaped}(?:\\s*=\\s*|\\s+)`, 'i') - const m = line.match(flagRe) - if (!m || m.index === undefined) return null - const rest = line.slice(m.index + m[0].length).trim() - if (!rest) return null - return stripTrailingCliFlags(rest, trailingFlags) -} - -export function stripTrailingCliFlags(value: string, flagNames: readonly string[]): string { - if (flagNames.length === 0) return value.trim() - let s = value.trim() - const parts = flagNames.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - const alt = parts.join('|') - const tailFlag = new RegExp(`\\s+--(?:${alt})(?:\\s*=\\s*(?:"[^"]*"|\\S+))?\\s*$`, 'i') - while (tailFlag.test(s)) { - s = s.replace(tailFlag, '').trim() - } - return s -} diff --git a/KeeperSdk/src/cli/parse.ts b/KeeperSdk/src/cli/parse.ts deleted file mode 100644 index 1c653264..00000000 --- a/KeeperSdk/src/cli/parse.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { CliResult, ParsedCli } from './types' - -const isWhitespace = (ch: string) => /\s/.test(ch) - -export function tokenizeArguments(args: string): string[] { - const out: string[] = [] - const sb: string[] = [] - let pos = 0 - let inQuote = false - let escape = false - - const flush = () => { - if (sb.length > 0) { - out.push(sb.join('')) - sb.length = 0 - } - } - - while (pos < args.length) { - const ch = args[pos] - if (escape) { - escape = false - sb.push(ch) - pos++ - continue - } - if (inQuote) { - if (ch === '\\') { - escape = true - pos++ - continue - } - if (ch === '"') { - inQuote = false - pos++ - continue - } - sb.push(ch) - pos++ - continue - } - switch (ch) { - case '\\': - escape = true - pos++ - break - case '"': - inQuote = true - pos++ - break - default: - if (isWhitespace(ch)) { - flush() - pos++ - } else { - sb.push(ch) - pos++ - } - } - } - flush() - return out -} - -function setBool(opts: Map, k: string): void { - opts.set(k.toLowerCase(), true) -} - -function setStr( - opts: Map, - repeated: Map, - k: string, - v: string -): void { - const key = k.toLowerCase() - const prev = opts.get(key) - if (prev !== undefined && prev !== true) { - const list = repeated.get(key) ?? [prev] - list.push(v) - repeated.set(key, list) - } else { - repeated.set(key, [v]) - } - opts.set(key, v) -} - -export function parseCliArgs( - tokens: string[], - options: { valueShortFlags?: ReadonlySet } = {} -): ParsedCli { - const valueShortFlags = options.valueShortFlags ?? new Set() - const positional: string[] = [] - const opts = new Map() - const repeatedOpts = new Map() - - let i = 0 - while (i < tokens.length) { - const t = tokens[i] - if (t === '--') { - positional.push(...tokens.slice(i + 1)) - break - } - if (t === '-' || !t.startsWith('-')) { - positional.push(t) - i++ - continue - } - - if (t.startsWith('--')) { - const body = t.slice(2) - if (!body) { - positional.push(t) - i++ - continue - } - const eq = body.indexOf('=') - if (eq >= 0) { - setStr(opts, repeatedOpts, body.slice(0, eq), body.slice(eq + 1)) - i++ - continue - } - const name = body - const next = tokens[i + 1] - if (next && next !== '--' && !next.startsWith('-')) { - setStr(opts, repeatedOpts, name, next) - i += 2 - continue - } - setBool(opts, name) - i++ - continue - } - - const rest = t.slice(1) - if (!rest) { - positional.push(t) - i++ - continue - } - if (/^[A-Za-z]$/.test(rest)) { - const name = rest - const next = tokens[i + 1] - if ( - valueShortFlags.has(name.toLowerCase()) && - next && - next !== '--' && - !next.startsWith('-') - ) { - setStr(opts, repeatedOpts, name, next) - i += 2 - continue - } - setBool(opts, name) - i++ - continue - } - if (/^[A-Za-z]+$/.test(rest)) { - for (const ch of rest) setBool(opts, ch) - i++ - continue - } - - positional.push(t) - i++ - } - - return { positional, opts, repeatedOpts } -} - -export function getAllOpt(parsed: ParsedCli, ...names: string[]): string[] { - const out: string[] = [] - for (const n of names) { - const key = n.toLowerCase() - const repeated = parsed.repeatedOpts.get(key) - if (repeated) { - out.push(...repeated) - continue - } - const v = parsed.opts.get(key) - if (v !== undefined && v !== true) out.push(v) - } - return out -} - -export function hasOpt(opts: Map, ...names: string[]): boolean { - for (const n of names) { - const v = opts.get(n.toLowerCase()) - if (v === true) return true - } - return false -} - -export function getOpt(opts: Map, ...names: string[]): string | undefined { - for (const n of names) { - const v = opts.get(n.toLowerCase()) - if (v !== undefined && v !== true) return v - } - return undefined -} - -export function wantsCliHelp(parsed: ParsedCli): boolean { - return hasOpt(parsed.opts, 'help', 'h') -} - -export function rejectUnknownOptions( - parsed: ParsedCli, - allowed: ReadonlySet, - commandName: string -): CliResult | null { - for (const k of parsed.opts.keys()) { - if (k === 'help' || k === 'h') continue - if (!allowed.has(k)) { - return { code: 1, out: '', err: `${commandName}: unknown option --${k}\n` } - } - } - return null -} diff --git a/KeeperSdk/src/cli/parser.ts b/KeeperSdk/src/cli/parser.ts deleted file mode 100644 index fefefb61..00000000 --- a/KeeperSdk/src/cli/parser.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from './types' -import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' -import { extractFromJsonFlagValue } from './jsonArg' -import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' -import { AUTH_CLI_COMMAND_NAMES, isAuthCliCommand } from './access' -import { BUILTIN_CLI_COMMANDS } from './builtinCommands' -import { formatAllCommandsSummary, formatDetailedHelpForCommand, formatShortCommandSummary } from './help' - -const NOT_LOGGED_IN_ERR = - 'Not logged in. Run `login` or `restore-session` (see `help`).\n' - -function valueShortFlagsForCommand(def: CliCommandDefinition): ReadonlySet { - const out = new Set() - for (const flag of def.valueShortFlags ?? []) { - out.add(flag.replace(/^-+/, '').toLowerCase()) - } - return out -} - -export type KeeperCliParserOptions = { - prog?: string - description?: string - epilog?: string -} - -export class KeeperCliParser { - private readonly prog: string - private readonly description: string - private readonly epilog?: string - private readonly commands = new Map() - private readonly aliases = new Map() - - constructor(options: KeeperCliParserOptions = {}) { - this.prog = options.prog ?? 'keeper' - this.description = options.description ?? '' - this.epilog = options.epilog - } - - addCommand(def: CliCommandDefinition): this { - const key = def.name.toLowerCase() - this.commands.set(key, def) - if (def.aliases) { - for (const alias of def.aliases) { - this.aliases.set(alias.toLowerCase(), key) - } - } - return this - } - - addCommands(defs: Iterable): this { - for (const def of defs) this.addCommand(def) - return this - } - - list(): CliCommandDefinition[] { - return [...this.commands.values()].sort((a, b) => { - const oa = a.order ?? 500 - const ob = b.order ?? 500 - if (oa !== ob) return oa - ob - return a.name.localeCompare(b.name) - }) - } - - listNames(): string[] { - return this.list().map((c) => c.name) - } - - resolve(name: string): CliCommandDefinition | undefined { - const key = name.toLowerCase() - if (this.commands.has(key)) return this.commands.get(key) - const target = this.aliases.get(key) - return target ? this.commands.get(target) : undefined - } - - formatHelp(host?: KeeperCliHost): string { - const loggedIn = host?.getVault().isLoggedIn ?? true - const commands = loggedIn - ? this.list() - : this.list().filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) - const header = this.description ? `${this.prog} — ${this.description}\n\n` : '' - const body = loggedIn - ? formatAllCommandsSummary(commands) - : formatAllCommandsSummary(commands, { - header: 'Not logged in — sign-in commands:\n\n', - footer: - '\nRun `login` or `restore-session` to open the vault.\n' + - 'After login, run `help` again for vault commands (get, ls, cd, …).\n', - }) - const footer = this.epilog ? `\n${this.epilog}\n` : '' - return header + body + footer - } - - formatCommandHelp(name: string): string | null { - const def = this.resolve(name) - return def ? formatDetailedHelpForCommand(def) : null - } - - formatCommandSummary(name: string): string | null { - const def = this.resolve(name) - return def ? formatShortCommandSummary(def) : null - } - - async parse(line: string | readonly string[], host: KeeperCliHost): Promise { - const { tokens, raw } = normalizeInput(line) - if (tokens.length === 0) { - return ok(this.formatHelp(host)) - } - - const first = tokens[0] - const rest = tokens.slice(1) - - if (isHelpToken(first)) { - const sub = rest[0] - if (!sub) return ok(this.formatHelp(host)) - if (!host.getVault().isLoggedIn && !isAuthCliCommand(sub)) { - return err(NOT_LOGGED_IN_ERR) - } - const page = this.formatCommandHelp(sub) - if (page) return ok(page) - return err(`${this.prog}: unknown command: ${sub}\nTry: ${this.prog} --help\n`) - } - - const def = this.resolve(first) - if (!def) { - return err(`${this.prog}: unknown command: ${first}\nTry: ${this.prog} --help\n`) - } - - if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { - return err(NOT_LOGGED_IN_ERR) - } - - let parsed: ParsedCli - const parseOpts = { valueShortFlags: valueShortFlagsForCommand(def) } - if (def.name === 'restore-session') { - const json = extractFromJsonFlagValue(raw, 'from-json', RESTORE_SESSION_TRAILING_OPTS) - parsed = parseCliArgs(rest, parseOpts) - if (json) parsed.opts.set('from-json', json) - } else { - parsed = parseCliArgs(rest, parseOpts) - } - - if (wantsCliHelp(parsed)) { - return ok(formatDetailedHelpForCommand(def)) - } - return def.run(host, parsed) - } -} - -export function createKeeperCliParser(options: KeeperCliParserOptions = {}): KeeperCliParser { - const parser = new KeeperCliParser(options) - void loadBuiltinsInto(parser) - return parser -} - -function loadBuiltinsInto(parser: KeeperCliParser): void { - parser.addCommands(BUILTIN_CLI_COMMANDS) -} - -function normalizeInput(line: string | readonly string[]): { tokens: string[]; raw: string } { - if (typeof line === 'string') { - const trimmed = line.trim() - return { tokens: trimmed ? tokenizeArguments(trimmed) : [], raw: trimmed } - } - const tokens = [...line].filter((t) => t.length > 0) - return { tokens, raw: tokens.join(' ') } -} - -function isHelpToken(token: string): boolean { - const t = token.toLowerCase() - return t === '--help' || t === '-h' || t === 'help' -} - -function ok(out: string): CliResult { - return { code: 0, out, err: '' } -} - -function err(message: string): CliResult { - return { code: 1, out: '', err: message } -} diff --git a/KeeperSdk/src/cli/prompt.ts b/KeeperSdk/src/cli/prompt.ts deleted file mode 100644 index 0ac8ea99..00000000 --- a/KeeperSdk/src/cli/prompt.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { KeeperCliHost } from './types' - -const NOT_LOGGED_IN_PROMPT = 'Not logged in> ' -const PROMPT_MAX_LEN = 40 - -export function getKeeperCliPromptPrefix(host: KeeperCliHost): string { - const v = host.getVault() - if (!v.isLoggedIn) return NOT_LOGGED_IN_PROMPT - const name = v.getWorkingFolderDisplayName?.() ?? 'My Vault' - const label = name.length > PROMPT_MAX_LEN ? `...${name.slice(-37)}` : name - return `${label}> ` -} diff --git a/KeeperSdk/src/cli/registry.ts b/KeeperSdk/src/cli/registry.ts deleted file mode 100644 index 4e541bac..00000000 --- a/KeeperSdk/src/cli/registry.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { CliCommandDefinition } from './types' - -const commands = new Map() -const aliases = new Map() - -function normalizeName(name: string): string { - return name.toLowerCase() -} - -export function registerCliCommand(def: CliCommandDefinition): void { - const key = normalizeName(def.name) - commands.set(key, def) - if (def.aliases) { - for (const alias of def.aliases) { - aliases.set(normalizeName(alias), key) - } - } -} - -export function registerCliAlias(alias: string, commandName: string): void { - aliases.set(normalizeName(alias), normalizeName(commandName)) -} - -export function resolveCliCommandName(name: string): string | undefined { - const key = normalizeName(name) - if (commands.has(key)) return key - return aliases.get(key) -} - -export function getCliCommand(name: string): CliCommandDefinition | undefined { - const key = resolveCliCommandName(name) - return key ? commands.get(key) : undefined -} - -export function listCliCommands(): CliCommandDefinition[] { - return [...commands.values()].sort((a, b) => { - const oa = a.order ?? 500 - const ob = b.order ?? 500 - if (oa !== ob) return oa - ob - return a.name.localeCompare(b.name) - }) -} - -export function listCliCommandNames(): readonly string[] { - return listCliCommands().map((c) => c.name) -} - -export function listDocumentedCommands(): readonly string[] { - return listCliCommandNames() -} - -export function clearCliRegistry(): void { - commands.clear() - aliases.clear() -} diff --git a/KeeperSdk/src/cli/table.ts b/KeeperSdk/src/cli/table.ts deleted file mode 100644 index e2cefae5..00000000 --- a/KeeperSdk/src/cli/table.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function formatTable(headers: string[], rows: string[][]): string { - if (rows.length === 0) return '' - const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) - const fmt = (cells: string[]): string => - cells.map((s, i) => (i === cells.length - 1 ? s : (s ?? '').padEnd(widths[i]))).join(' ') - return [fmt(headers), ...rows.map(fmt)].join('\n') + '\n' -} diff --git a/KeeperSdk/src/cli/types.ts b/KeeperSdk/src/cli/types.ts deleted file mode 100644 index 109f8682..00000000 --- a/KeeperSdk/src/cli/types.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { DRecord, DSharedFolder, SyncResult } from '@keeper-security/keeperapi' -import type { SessionRestoreInput } from '../auth/sessionRestore' -import type { ChangeDirectoryResult, TryResolvePathResult } from '../folders/changeDirectory' -import type { FolderTreeBuildOptions } from '../folders/folderTree' -import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' -import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' -import type { MkdirOptions } from '../folders/addFolder' -import type { RenameFolderResult } from '../folders/updateFolder' -import type { DeleteFolderResult } from '../folders/deleteFolder' -import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' -import type { RecordShareInfo } from '../sharing/Sharing' -import type { VaultSummary } from '../vault/KeeperVault' -import type { WhoamiInfo } from '../account/whoamiInfo' - -export type CliResult = { - code: number - out: string - err: string - needPassword?: boolean - loginUsername?: string -} - -export type ParsedCli = { - positional: string[] - opts: Map - repeatedOpts: Map -} - -/** - * Vault surface for CLI handlers. Methods beyond session/sync/records are optional; - * commands call `ensureCapability` so thin hosts fail with a clear message. - */ -export type KeeperCliVault = { - readonly isLoggedIn: boolean - login(username: string, password: string): Promise - loginWithSessionToken(username: string, sessionToken: string): Promise - logout(): Promise - sync(): Promise - getRecords(): DRecord[] - getSharedFolders(): DSharedFolder[] - restoreSession(input: SessionRestoreInput): Promise - getSummary?: () => VaultSummary - getWhoamiInfo?: (options?: { includeVaultCounts?: boolean }) => Promise - findRecord?: (uidOrTitle: string) => DRecord | undefined - findRecords?: (criteria: string) => DRecord[] - getRecordShareInfo?: (recordUid: string) => Promise - listSharedFolders?: (options?: ListSharedFoldersOptions) => ListSharedFolderRow[] - listFolder?: (options?: ListFolderOptions) => Promise - tree?: (options?: FolderTreeBuildOptions) => Promise - changeDirectory?: (path: string) => Promise - tryResolvePath?: (path: string) => Promise - getCurrentFolderUid?: () => string | null - getWorkingFolderDisplayName?: () => string - getFolder?: (uidOrName: string, options?: GetFolderOptions) => Promise - mkdir?: (path: string, options?: MkdirOptions) => Promise<{ folderUid: string; success: boolean; message?: string }> - renameFolder?: (folderPath: string, newName: string) => Promise - rmdir?: (patterns: string[], options?: { force?: boolean }) => Promise -} - -/** Host adapter (browser shell, Node script, tests). `readTextFile` is optional. */ -export type KeeperCliHost = { - getVault(): KeeperCliVault - envString(name: string): string | undefined - formatError(context: string, err: unknown): string - readTextFile?: (path: string) => Promise - getAccountUsername?: () => Promise -} - -export type CliHelpPositional = { - name: string - help: string - nargs?: '?' | '*' | '+' -} - -export type CliHelpOption = { - flags: string - help: string - metavar?: string - choices?: string -} - -export type CliHelpDoc = { - description: string - usage?: string - positionals?: CliHelpPositional[] - options?: CliHelpOption[] - epilog?: string -} - -export type CliCommandDefinition = { - name: string - order?: number - description: string - usage: string - aliases?: readonly string[] - subcommands?: readonly string[] - flagOptions?: readonly string[] - valueShortFlags?: readonly string[] - allowedOptions?: ReadonlySet - help: CliHelpDoc - run: (host: KeeperCliHost, parsed: ParsedCli) => Promise -} diff --git a/KeeperSdk/src/cli/utils.ts b/KeeperSdk/src/cli/utils.ts deleted file mode 100644 index 5db5dae4..00000000 --- a/KeeperSdk/src/cli/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function utf8ToBase64Url(s: string): string { - const bytes = new TextEncoder().encode(s) - let b64: string - if (typeof Buffer !== 'undefined') { - b64 = Buffer.from(bytes).toString('base64') - } else { - let bin = '' - for (let i = 0; i < bytes.length; i++) { - bin += String.fromCharCode(bytes[i]!) - } - b64 = globalThis.btoa(bin) - } - return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - -export function recordUid(rec: { uid?: string }): string { - return rec.uid || '(unknown uid)' -} diff --git a/KeeperSdk/src/cli/vaultSurface.ts b/KeeperSdk/src/cli/vaultSurface.ts deleted file mode 100644 index cb3e43f8..00000000 --- a/KeeperSdk/src/cli/vaultSurface.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** Shared footer for vault-related command help (SDK APIs not yet exposed as CLI). */ -export const KEEPER_VAULT_SURFACE = ` -KeeperVault (JavaScript SDK) — operations available in code (not all exposed as CLI yet): - - Session: login, loginWithSessionToken, logout, resumeSession, sync, disconnect - Records: getRecords, findRecord, findRecords, getRecordByUid, getRecordsByType, - addRecord, updateRecord, deleteRecord, moveRecord, getRecordHistory, - printRecords - Sharing: shareRecord, removeRecordShare, getRecordShareInfo - Folders: listFolder, changeDirectory, getFolder, mkdir, addFolder, updateFolder, - renameFolder, deleteFolder, rmdir, tree, getCurrentFolderUid - Shared folders: getSharedFolders, listSharedFolders, shareFolder, … - Metadata: getRecordMetadata, getSummary, … - -Utilities exported from @keeper-security/keeper-sdk-javascript include searchRecords, -formatRecord, getRecordTitle, getRecordPassword, getRecordLogin, shareRecord, … -See the SDK package for full APIs. -`.trim() From 24eaad17b475ea9b5601e91ec74fa47f01c5a087 Mon Sep 17 00:00:00 2001 From: Sergey Aldoukhov Date: Tue, 16 Jun 2026 14:06:30 -0700 Subject: [PATCH 11/48] Use logger in socket connection, add socket connection to the router. (#171) * use logger for KA socket * router socket * listener --- keeperapi/package.json | 2 +- keeperapi/src/auth.ts | 85 +++++++++++++++++++++++++++++++ keeperapi/src/browser/platform.ts | 45 +++++++++------- keeperapi/src/configuration.ts | 4 ++ keeperapi/src/endpoint.ts | 19 +++++++ keeperapi/src/node/platform.ts | 3 +- keeperapi/src/platform.ts | 2 +- keeperapi/src/socket.ts | 16 ++++-- 8 files changed, 151 insertions(+), 25 deletions(-) diff --git a/keeperapi/package.json b/keeperapi/package.json index 59c16077..604b5acf 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -1,7 +1,7 @@ { "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", - "version": "17.2.8", + "version": "17.3.0", "browser": "dist/index.es.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", diff --git a/keeperapi/src/auth.ts b/keeperapi/src/auth.ts index 9ae5b7ec..d6e5616c 100644 --- a/keeperapi/src/auth.ts +++ b/keeperapi/src/auth.ts @@ -141,6 +141,13 @@ export class Auth { private messageSessionUid: Uint8Array options: ClientConfigurationInternal private socket?: SocketListener + // Optional second socket to the KRouter user endpoint (see connectToRouter). + private routerSocket?: SocketListener + // Listener registered via onRouterMessage, retained so it can be (re)attached + // each time the router socket (re)connects. Lets a caller subscribe before the + // socket finishes its async connect (it is opened fire-and-forget after login) + // without racing it. Only one consumer is ever needed. + private routerMessageListener?: (data: Uint8Array) => void public clientKey?: Uint8Array private _accountSummary?: IAccountSummaryElements private _accountSummaryVersion: number = 1 @@ -257,6 +264,17 @@ export class Auth { this.socket = await createAsyncSocket(url, this.messageSessionUid, getConnectionRequest) logger.debug('Socket connected') + // Log every incoming push event with the same logger/format as REST calls. + // Decryption only runs when debug logging is enabled. + this.onPushMessage(async (data: Uint8Array) => { + if (!isLevelEnabled('debug')) return + try { + const wssClientResponse = await this.endpoint.decryptPushMessage(data) + logger.debug(...formatProto('Push message received', wssClientResponse)) + } catch (e) { + logger.debug('Push message received (undecryptable)', e) + } + }) this.onCloseMessage((closeReason: CloseReason) => { if (this.options.onCommandFailure) { this.options.onCommandFailure({ @@ -274,6 +292,70 @@ export class Auth { } } + /** + * Opens a WebSocket to the KRouter user endpoint + * (`wss://connect./api/user/client`). This is separate from the + * KeeperApp push socket opened by `connect()`. Requires a session token, so + * call it after login. When `ClientConfiguration.connectToRouter` is set this + * runs automatically once the session token is available. + * + * Inbound frames are JSON text (`gw_response`, `client_error`, notifications). + * Subscribe with `onRouterMessage`; incoming events are also debug-logged. + */ + async connectToRouter() { + if (!this._sessionToken) { + throw new Error('Cannot connect to router socket without a session token') + } + if (this.routerSocket?.getIsConnected()) { + return + } + const url = await this.endpoint.getRouterConnectionUrl(this._sessionToken) + // No app-level heartbeat: the router treats every binary frame as a + // RouterControllerMessage and relies on protocol-level ping/pong instead. + const routerSocket = await createAsyncSocket(url, undefined, undefined, false) + if (!routerSocket) { + throw new Error('Failed to open router socket') + } + this.routerSocket = routerSocket + logger.debug('Router socket connected') + routerSocket.onPushMessage((data: Uint8Array) => { + if (!isLevelEnabled('debug')) return + const text = platform.bytesToString(data) + try { + logger.debug('Router message received', JSON.parse(text)) + } catch { + logger.debug('Router message received', text) + } + }) + // (Re)attach the subscriber registered before this socket existed (or before + // a reconnect created a fresh one). + if (this.routerMessageListener) { + routerSocket.onPushMessage(this.routerMessageListener) + } + } + + disconnectRouter() { + if (this.routerSocket) { + this.routerSocket.disconnect() + delete this.routerSocket + } + } + + onRouterMessage(callback: (data: Uint8Array) => void): void { + // Retain the listener so it survives (re)connects, then attach it to the + // current socket if one is already open. Safe to call before connectToRouter + // has finished — it does not throw when the socket is not yet available. + this.routerMessageListener = callback + this.routerSocket?.onPushMessage(callback) + } + + onRouterCloseMessage(callback: (data: any) => void): void { + if (!this.routerSocket) { + throw new Error('No router socket available') + } + this.routerSocket.onCloseMessage(callback) + } + /** * @param {LoginPayload} payload - Options for login. * @param {boolean} [payload.disableLinkingForAccountWithYubikey2fa] - @@ -1254,6 +1336,9 @@ export class Auth { this.socket.onOpen(() => { this.socket?.registerLogin(this._sessionToken) }) + if (this.options.connectToRouter) { + this.connectToRouter().catch((e) => logger.debug('Router socket connect failed', e)) + } } async registerDevice() { diff --git a/keeperapi/src/browser/platform.ts b/keeperapi/src/browser/platform.ts index 16c26a7d..c3a00dfa 100644 --- a/keeperapi/src/browser/platform.ts +++ b/keeperapi/src/browser/platform.ts @@ -27,7 +27,6 @@ const rsaAlgorithmName: string = 'RSASSA-PKCS1-v1_5' const CBC_IV_LENGTH = 16 const GCM_IV_LENGTH = 12 const ECC_PUB_KEY_LENGTH = 65 -let socket: WebSocket | null = null let workerPool: CryptoWorkerPool | null = null const base64ToBytes = (data: string): Uint8Array => { @@ -1090,33 +1089,51 @@ export const browserPlatform: Platform = class { } } - static createWebsocket(url: string): SocketProxy { - socket = new WebSocket(url) + static createWebsocket(url: string, sendHeartbeat: boolean = true): SocketProxy { + // Use a per-socket local reference. A module-level variable would be + // overwritten when a second socket is opened (e.g. push + router), + // routing the first socket's send/close to the wrong connection. + const ws = new WebSocket(url) let createdSocket + // App-level keepalive for the push socket. The router socket relies on the + // protocol-level ping/pong handled by the server/browser and treats every + // binary frame as a RouterControllerMessage, so it must NOT receive this. + if (sendHeartbeat) { + const heartbeat = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) return + ws.send(OPCODE_PING) + }, 10000) + ws.addEventListener('close', () => clearInterval(heartbeat)) + } return (createdSocket = { onOpen: (callback: () => void) => { - socket!.onopen = (e: Event) => { + ws.onopen = (e: Event) => { callback() } }, close: () => { - socket!.close() + ws.close() }, onClose: (callback: (e: Event) => void) => { - socket!.addEventListener('close', callback) + ws.addEventListener('close', callback) }, onError: (callback: (e: Event) => void) => { - socket!.addEventListener('error', callback) + ws.addEventListener('error', callback) }, onMessage: (callback: (e: Uint8Array) => void) => { - socket!.onmessage = async (e: MessageEvent) => { - const pmArrBuff = await e.data.arrayBuffer() - const pmUint8Buff = new Uint8Array(pmArrBuff) + ws.onmessage = async (e: MessageEvent) => { + // The push socket delivers binary (Blob/ArrayBuffer) frames; the + // router socket delivers JSON text frames. Text frames arrive as a + // string with no `arrayBuffer()`, so encode them to bytes here. + const pmUint8Buff = + typeof e.data === 'string' + ? new TextEncoder().encode(e.data) + : new Uint8Array(await e.data.arrayBuffer()) callback(pmUint8Buff) } }, send: (message: any) => { - socketSendMessage(message, socket!, createdSocket) + socketSendMessage(message, ws, createdSocket) }, messageQueue: [], }) @@ -1151,12 +1168,6 @@ function bytesToHex(data: Uint8Array): string { const OPCODE_PING = new Uint8Array([0x9]) -const heartbeat = setInterval(() => { - if (!socket) return - if (socket.readyState !== WebSocket.OPEN) return - socket.send(OPCODE_PING) -}, 10000) - let keyBytesCache: Record = {} type CryptoKeyCache = { diff --git a/keeperapi/src/configuration.ts b/keeperapi/src/configuration.ts index a29be947..15553fa9 100644 --- a/keeperapi/src/configuration.ts +++ b/keeperapi/src/configuration.ts @@ -27,6 +27,10 @@ export interface ClientConfiguration { iterations?: number salt?: Uint8Array useHpkeForTransmissionKey?: boolean + // When true, the SDK also opens a WebSocket to the KRouter user socket + // (`wss://connect./api/user/client`) once a session token is available, + // in addition to the KeeperApp push socket. See `Auth.connectToRouter`. + connectToRouter?: boolean } export interface ClientConfigurationInternal extends ClientConfiguration { deviceConfig: DeviceConfig // v15+ device config diff --git a/keeperapi/src/endpoint.ts b/keeperapi/src/endpoint.ts index a2188def..26253004 100644 --- a/keeperapi/src/endpoint.ts +++ b/keeperapi/src/endpoint.ts @@ -417,6 +417,25 @@ export class KeeperEndpoint { return WssClientResponse.decode(decryptedPushMessage) } + // Builds the KRouter user-socket URL. Browsers can't set custom WebSocket + // headers, so the same `keeper-user` credentials that `executeRouterRest` + // sends as `Authorization` / `TransmissionKey` headers are passed as URL-safe + // query parameters instead — KRouter's auth provider falls back to query + // parameters and un-url-safes them (see KeeperAuthenticationProvider.kt). + async getRouterConnectionUrl(sessionToken: string): Promise { + const transmissionKey = await this.getTransmissionKey() + const sessionTokenBytes = normal64Bytes(sessionToken) + const encryptedSessionToken = await platform.aesGcmEncrypt(sessionTokenBytes, transmissionKey.key) + const authorization = `KeeperUser ${webSafe64FromBytes(encryptedSessionToken)}` + const transmissionKeyParam = webSafe64FromBytes(transmissionKey.ecEncryptedKey) + const httpUrl = getKeeperRouterUrl(this.options.host, 'api/user/client') + const wssUrl = httpUrl.replace(/^http/, 'ws') + return ( + `${wssUrl}?Authorization=${encodeURIComponent(authorization)}` + + `&TransmissionKey=${encodeURIComponent(transmissionKeyParam)}` + ) + } + async getPushConnectionRequest(messageSessionUid: Uint8Array) { this._transmissionKey = await this.getTransmissionKey() return getPushConnectionRequest( diff --git a/keeperapi/src/node/platform.ts b/keeperapi/src/node/platform.ts index 657cfd38..54fdbc14 100644 --- a/keeperapi/src/node/platform.ts +++ b/keeperapi/src/node/platform.ts @@ -466,7 +466,8 @@ export const nodePlatform: Platform = class { }) } - static createWebsocket(url: string): SocketProxy { + static createWebsocket(url: string, _sendHeartbeat: boolean = true): SocketProxy { + // The node client has no app-level heartbeat, so `sendHeartbeat` is unused here. const socket = new WebSocket.Client(url) let createdSocket return (createdSocket = { diff --git a/keeperapi/src/platform.ts b/keeperapi/src/platform.ts index c2be9fa1..f97307d9 100644 --- a/keeperapi/src/platform.ts +++ b/keeperapi/src/platform.ts @@ -136,7 +136,7 @@ export interface Platform { closeCryptoWorker(): Promise - createWebsocket(url: string): SocketProxy + createWebsocket(url: string, sendHeartbeat?: boolean): SocketProxy } export interface CryptoTask { diff --git a/keeperapi/src/socket.ts b/keeperapi/src/socket.ts index 4872c05e..9661a23b 100644 --- a/keeperapi/src/socket.ts +++ b/keeperapi/src/socket.ts @@ -56,6 +56,9 @@ export class SocketListener { private onOpenListeners: Array<() => void> // The messageSessionUid private messageSessionUid?: Uint8Array + // Whether to send the app-level keepalive ping (push socket only; the router + // socket rejects arbitrary binary frames — see browser platform createWebsocket). + private sendHeartbeat: boolean private isConnected: boolean private reconnectTimeout?: ReturnType @@ -65,11 +68,13 @@ export class SocketListener { constructor( url: string, messageSessionUid?: Uint8Array, - getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise + getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise, + sendHeartbeat: boolean = true ) { logger.debug('Connecting to ' + url) this.url = url + this.sendHeartbeat = sendHeartbeat this.closeListeners = [] this.singleCloseListeners = [] this.messageListeners = [] @@ -88,9 +93,9 @@ export class SocketListener { async createWebsocket(messageSessionUid?: Uint8Array) { if (this.getConnectionRequest && messageSessionUid) { const connectionRequest = await this.getConnectionRequest(messageSessionUid) - this.socket = platform.createWebsocket(`${this.url}/${connectionRequest}`) + this.socket = platform.createWebsocket(`${this.url}/${connectionRequest}`, this.sendHeartbeat) } else { - this.socket = platform.createWebsocket(this.url) + this.socket = platform.createWebsocket(this.url, this.sendHeartbeat) } this.socket!.onOpen(() => { @@ -275,9 +280,10 @@ export class SocketListener { export async function createAsyncSocket( url: string, messageSessionUid?: Uint8Array, - getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise + getConnectionRequest?: (messageSessionUid: Uint8Array) => Promise, + sendHeartbeat: boolean = true ): Promise { - const socket = new SocketListener(url, messageSessionUid, getConnectionRequest) + const socket = new SocketListener(url, messageSessionUid, getConnectionRequest, sendHeartbeat) await socket.createWebsocket(messageSessionUid) return socket } From 0f4af07ed71502073875feab66656b1b2b630088 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Mon, 29 Jun 2026 13:58:58 +0530 Subject: [PATCH 12/48] added fixes for browser compatibilty --- KeeperSdk/src/api.ts | 51 +++++++++++++++ KeeperSdk/src/folders/addFolder.ts | 12 +++- KeeperSdk/src/folders/deleteFolder.ts | 87 +++++++++++++++++++------ KeeperSdk/src/folders/folderHelpers.ts | 13 +++- KeeperSdk/src/folders/listFolder.ts | 13 +++- KeeperSdk/src/vault/KeeperVault.ts | 37 +++++++++-- keeperapi/src/browser/platform.ts | 50 ++++++++++---- keeperapi/src/vault.ts | 90 ++++++++++++++++++++++++-- 8 files changed, 309 insertions(+), 44 deletions(-) diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts index 225a3cd6..85bc9aeb 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -187,6 +187,57 @@ export type { AuthProvider, SharedFolderPermissionsInput } from './folders/Folde 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 { + 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 { Auth, KeeperEnvironment, syncDown, Authentication } from '@keeper-security/keeperapi' export type { diff --git a/KeeperSdk/src/folders/addFolder.ts b/KeeperSdk/src/folders/addFolder.ts index c90b724c..57749c46 100644 --- a/KeeperSdk/src/folders/addFolder.ts +++ b/KeeperSdk/src/folders/addFolder.ts @@ -24,6 +24,7 @@ export type AddFolderInput = { manageRecords?: boolean canShare?: boolean canEdit?: boolean + color?: string | null } export type AddFolderResult = { @@ -40,6 +41,7 @@ export type MkdirOptions = { manageRecords?: boolean canShare?: boolean canEdit?: boolean + color?: string | null } type ParentContext = { @@ -149,11 +151,17 @@ export async function addFolder(auth: Auth, storage: InMemoryStorage, input: Add 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({ name, title: name }, folderKey), + data: await encryptObjectForStorage(folderData, folderKey), link: false, } @@ -187,6 +195,7 @@ export async function addFolder(auth: Auth, storage: InMemoryStorage, input: Add message: reason, } } + return { folderUid, success: true } } catch (err) { return { @@ -279,6 +288,7 @@ export async function mkdir( manageRecords: isLastSegment && createAsSharedFolder ? manageRecords : undefined, canShare: isLastSegment && createAsSharedFolder ? canShare : undefined, canEdit: isLastSegment && createAsSharedFolder ? canEdit : undefined, + color: isLastSegment ? options.color : undefined, }) if (!lastResult.success) { diff --git a/KeeperSdk/src/folders/deleteFolder.ts b/KeeperSdk/src/folders/deleteFolder.ts index 93a512fe..906b19a4 100644 --- a/KeeperSdk/src/folders/deleteFolder.ts +++ b/KeeperSdk/src/folders/deleteFolder.ts @@ -1,6 +1,7 @@ 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 { getSdkPlatform } from '../platform' import { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, extractErrorMessage, logger } from '../utils' import { listFolder } from './listFolder' @@ -26,6 +27,37 @@ export type RmdirOptions = { 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 { @@ -162,8 +194,8 @@ export async function deleteFolder( } const inner = preResp.pre_delete_response - const token = inner?.pre_delete_token - if (!token) { + let deleteToken = inner?.pre_delete_token + if (!deleteToken) { const reason = preResp.message || preResp.result_code || @@ -177,17 +209,36 @@ export async function deleteFolder( 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.' } + 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 })) + } catch (err) { + return { + success: false, + message: `pre_delete refresh failed for [${targetUids}]: ${extractErrorMessage(err)}`, + } + } + 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, } } } try { - await auth.executeRestCommand(recordDeleteCommand({ pre_delete_token: token })) + await auth.executeRestCommand(recordDeleteCommand({ pre_delete_token: deleteToken })) } catch (err) { return { success: false, @@ -195,6 +246,14 @@ export async function deleteFolder( } } + for (const deleteObject of objects) { + try { + await storage.delete(deleteObject.object_type, deleteObject.object_uid) + } catch (err) { + logger.debug(`Failed to purge ${deleteObject.object_type} ${deleteObject.object_uid}:`, extractErrorMessage(err)) + } + } + return { success: true } } @@ -255,13 +314,7 @@ export async function rmdir( 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 { force = false, quiet = false } = options const folderUids = new Set() for (const pattern of patterns) { @@ -287,7 +340,5 @@ export async function rmdir( logger.info(`\nThe following folder(s) will be removed:\n${sortedNames.join(', ')}\n`) } - const confirmFn = force ? undefined : confirm - - return deleteFolder(auth, storage, [...folderUids], confirmFn) + return deleteFolder(auth, storage, [...folderUids], resolveRmdirConfirm(options)) } diff --git a/KeeperSdk/src/folders/folderHelpers.ts b/KeeperSdk/src/folders/folderHelpers.ts index 618ad342..910f5209 100644 --- a/KeeperSdk/src/folders/folderHelpers.ts +++ b/KeeperSdk/src/folders/folderHelpers.ts @@ -72,11 +72,22 @@ export function folderKindFromString(value: string | undefined | null): FolderKi } } +type UserFolderData = { title?: string; name?: string; color?: string } + export function userFolderName(folder: DUserFolder): string { - const data = folder.data as { title?: string; name?: string } | undefined + 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 diff --git a/KeeperSdk/src/folders/listFolder.ts b/KeeperSdk/src/folders/listFolder.ts index fad61821..1a3de26f 100644 --- a/KeeperSdk/src/folders/listFolder.ts +++ b/KeeperSdk/src/folders/listFolder.ts @@ -17,6 +17,7 @@ import { globToRegex, sharedFolderFolderName, sharedFolderName, + userFolderColor, userFolderName, } from './folderHelpers' @@ -33,6 +34,8 @@ export type ListFolderFolderSimple = { uid: string name: string folderKind: FolderKind + /** User-folder vault color when set (Commander `ls` colorization). */ + color?: string } export type ListFolderRecordSimple = { @@ -133,10 +136,12 @@ export async function listVaultRootFolders(storage: InMemoryStorage): Promise<{ 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 } : {}), }) } @@ -290,7 +295,13 @@ export async function listFolder(storage: InMemoryStorage, options: ListFolderOp if (!userFolder) continue const name = userFolderName(userFolder) if (!matches(name, userFolder.uid)) continue - folderRows.push({ uid: userFolder.uid, name, folderKind: FolderKind.UserFolder }) + 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 diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 73b8196c..8c65b9d8 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -9,6 +9,7 @@ import { DUserFolder, Authentication, normal64Bytes, + platform, } from '@keeper-security/keeperapi' import type { SyncResult, SyncLogFormat, VaultStorage, SessionStorage, AuthUI3 } from '@keeper-security/keeperapi' import { InMemoryStorage } from '../storage/InMemoryStorage' @@ -150,6 +151,7 @@ export class KeeperVault { 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 @@ -340,6 +342,17 @@ export class KeeperVault { ) } + 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}`) } @@ -417,7 +430,7 @@ export class KeeperVault { public async sync(): Promise { const auth = this.getAuthOrThrow() - try{ + try { const result = await syncDown({ auth, storage: this.storage, @@ -429,12 +442,22 @@ export class KeeperVault { } this.synced = true return result - }catch(e) { + } 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 { @@ -687,13 +710,19 @@ export class KeeperVault { confirm?: (summary: string) => boolean | Promise ): Promise { const result = await this.folderManager.deleteFolder(folderRefs, confirm) - if (result.success) await this.syncIfNeeded() + 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.syncIfNeeded() + if (result.success) { + await this.clearSyncCheckpoint() + await this.syncIfNeeded() + } return result } 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/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 From a11459f700edf12c89283e2c27aa43f898578887 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 1 Jul 2026 09:45:06 +0530 Subject: [PATCH 13/48] added additional commands p1 --- KeeperSdk/src/api.ts | 28 ++++++++++ KeeperSdk/src/records/RecordOperations.ts | 68 ++++++++++++++++++----- KeeperSdk/src/vault/KeeperVault.ts | 12 +++- 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts index 85bc9aeb..c784fd9d 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -238,6 +238,34 @@ export type { 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 { diff --git a/KeeperSdk/src/records/RecordOperations.ts b/KeeperSdk/src/records/RecordOperations.ts index acee9afa..00bf737e 100644 --- a/KeeperSdk/src/records/RecordOperations.ts +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -293,19 +293,19 @@ export async function updateRecord( export async function deleteRecord( 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, - ], - } + 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 { @@ -331,6 +331,17 @@ export async function deleteRecord( 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 } } @@ -443,7 +454,13 @@ function resolveFolder(uid: string, storage: InMemoryStorage): FolderInfo { return { uid, folderType: FolderKind.UserFolder, scopeUid: '' } } -async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorage): Promise { +async function findAllRecordFolderLinks( + recordUid: string, + storage: InMemoryStorage +): Promise { + const links: FolderInfo[] = [] + const seen = new Set() + const folderKinds = [ FolderKind.UserFolder, FolderKind.SharedFolder, @@ -458,7 +475,12 @@ async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorag (dependency) => dependency.kind === VaultObjectKind.Record && dependency.uid === recordUid ) ) { - return folder.uid + const info = resolveFolder(folder.uid, storage) + const key = `${info.folderType}:${info.uid}` + if (!seen.has(key)) { + seen.add(key) + links.push(info) + } } } } @@ -466,7 +488,25 @@ async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorag const sharedFolderRecord = storage .getAll(VaultObjectKind.SharedFolderRecord) .find((candidate) => candidate.recordUid === recordUid) - return sharedFolderRecord ? sharedFolderRecord.sharedFolderUid : '' + if (sharedFolderRecord) { + const info = resolveFolder(sharedFolderRecord.sharedFolderUid, storage) + const key = `${info.folderType}:${info.uid}` + if (!seen.has(key)) { + seen.add(key) + links.push(info) + } + } + + if (links.length === 0) { + links.push(resolveFolder('', storage)) + } + + return links +} + +async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorage): Promise { + const links = await findAllRecordFolderLinks(recordUid, storage) + return links[0]?.uid ?? '' } export async function moveRecord( diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 8c65b9d8..2a4e2cd4 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -781,9 +781,17 @@ export class KeeperVault { return result } - public async deleteRecord(recordUid: string): Promise { + public async deleteRecord(uidOrTitle: string): Promise { const auth = this.getAuthOrThrow() - const result = await deleteRecordOp(auth, recordUid) + 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 } From 61b16565f95f9cba3a79ded2008698f0314cd200 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:21:21 -0700 Subject: [PATCH 14/48] Bump the npm_and_yarn group across 4 directories with 3 updates (#174) Bumps the npm_and_yarn group with 1 update in the /KeeperSdk directory: [form-data](https://github.com/form-data/form-data). Bumps the npm_and_yarn group with 1 update in the /examples/print-vault-node directory: [form-data](https://github.com/form-data/form-data). Bumps the npm_and_yarn group with 1 update in the /examples/sdk_example directory: [form-data](https://github.com/form-data/form-data). Bumps the npm_and_yarn group with 3 updates in the /keeperapi directory: [form-data](https://github.com/form-data/form-data), [protobufjs-cli](https://github.com/protobufjs/protobuf.js) and [markdown-it](https://github.com/markdown-it/markdown-it). Updates `form-data` from 4.0.5 to 4.0.6 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `form-data` from 4.0.5 to 4.0.6 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `form-data` from 4.0.5 to 4.0.6 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `form-data` from 4.0.4 to 4.0.6 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `protobufjs-cli` from 1.3.1 to 1.3.3 - [Release notes](https://github.com/protobufjs/protobuf.js/releases) - [Changelog](https://github.com/protobufjs/protobuf.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-cli-v1.3.1...protobufjs-cli-v1.3.3) Updates `markdown-it` from 12.3.2 to 14.2.0 - [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md) - [Commits](https://github.com/markdown-it/markdown-it/compare/12.3.2...14.2.0) --- updated-dependencies: - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 4.0.6 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: protobufjs-cli dependency-version: 1.3.3 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: markdown-it dependency-version: 14.2.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/print-vault-node/package-lock.json | 20 +- examples/sdk_example/package-lock.json | 18 +- keeperapi/package-lock.json | 510 +++++++++++++------- 3 files changed, 344 insertions(+), 204 deletions(-) diff --git a/examples/print-vault-node/package-lock.json b/examples/print-vault-node/package-lock.json index af273d67..1cb0f72c 100644 --- a/examples/print-vault-node/package-lock.json +++ b/examples/print-vault-node/package-lock.json @@ -261,15 +261,15 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "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.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -709,15 +709,15 @@ } }, "form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" } }, "function-bind": { diff --git a/examples/sdk_example/package-lock.json b/examples/sdk_example/package-lock.json index 052d934b..8eebb425 100644 --- a/examples/sdk_example/package-lock.json +++ b/examples/sdk_example/package-lock.json @@ -313,16 +313,15 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", + "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.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -414,10 +413,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", + "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" }, diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index bacdcccc..c653de19 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1,12 +1,12 @@ { "name": "@keeper-security/keeperapi", - "version": "17.2.7", + "version": "17.2.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@keeper-security/keeperapi", - "version": "17.2.7", + "version": "17.2.8", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -76,7 +76,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.9.tgz", "integrity": "sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.5", @@ -1794,6 +1793,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1808,6 +1808,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -2128,6 +2129,51 @@ "node": ">=8" } }, + "node_modules/@jest/core/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==", + "dev": true, + "optional": true, + "peer": true, + "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/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2697,13 +2743,6 @@ "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=", "dev": true }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -2827,7 +2866,8 @@ "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", @@ -2835,7 +2875,8 @@ "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", @@ -2843,7 +2884,8 @@ "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", @@ -2851,7 +2893,8 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.1", @@ -2954,26 +2997,25 @@ } }, "node_modules/@types/linkify-it": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", - "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "dev": true }, "node_modules/@types/markdown-it": { - "version": "12.2.3", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", - "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, - "peer": true, "dependencies": { - "@types/linkify-it": "*", - "@types/mdurl": "*" + "@types/linkify-it": "^5", + "@types/mdurl": "^2" } }, "node_modules/@types/mdurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", - "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "dev": true }, "node_modules/@types/node": { @@ -2981,7 +3023,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.2.tgz", "integrity": "sha512-37MXfxkb0vuIlRKHNxwCkb60PNBpR94u4efQuN4JgIAm66zfCDXGSAFCef9XUWFovX2R1ok6Z7MHhtdVXXkkIw==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3040,7 +3081,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3424,7 +3464,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -3677,7 +3716,8 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -3864,10 +3904,13 @@ "dev": true }, "node_modules/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, + "engines": { + "node": ">=0.12" + }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } @@ -4174,16 +4217,15 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "license": "MIT", + "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.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4404,10 +4446,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", + "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" }, @@ -4718,7 +4759,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.1.tgz", "integrity": "sha512-Nirw5B4nn69rVUZtemCQhwxOBhm0nsp3hmtF4rzCeWD7BkjAXRIji7xWQfnTNbz9g0aVsBX6aZK3n+23LM6uDw==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^29.6.1", "@jest/types": "^29.6.1", @@ -5076,6 +5116,51 @@ "node": ">=8" } }, + "node_modules/jest-cli/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==", + "dev": true, + "optional": true, + "peer": true, + "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/jest-diff": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", @@ -6578,21 +6663,21 @@ } }, "node_modules/jsdoc": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.2.tgz", - "integrity": "sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", + "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", "dev": true, "dependencies": { "@babel/parser": "^7.20.15", "@jsdoc/salty": "^0.2.1", - "@types/markdown-it": "^12.2.3", + "@types/markdown-it": "^14.1.1", "bluebird": "^3.7.2", "catharsis": "^0.9.0", "escape-string-regexp": "^2.0.0", "js2xmlparser": "^4.0.2", "klaw": "^3.0.0", - "markdown-it": "^12.3.2", - "markdown-it-anchor": "^8.4.1", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", "marked": "^4.0.10", "mkdirp": "^1.0.4", "requizzle": "^0.2.3", @@ -6771,12 +6856,22 @@ "dev": true }, "node_modules/linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "dependencies": { - "uc.micro": "^1.0.1" + "uc.micro": "^2.0.0" } }, "node_modules/locate-path": { @@ -6866,19 +6961,30 @@ } }, "node_modules/markdown-it": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", - "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "dependencies": { "argparse": "^2.0.1", - "entities": "~2.1.0", - "linkify-it": "^3.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, "bin": { - "markdown-it": "bin/markdown-it.js" + "markdown-it": "bin/markdown-it.mjs" } }, "node_modules/markdown-it-anchor": { @@ -6919,9 +7025,9 @@ } }, "node_modules/mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true }, "node_modules/merge-stream": { @@ -7186,18 +7292,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7359,13 +7453,11 @@ } }, "node_modules/protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "dev": true, "hasInstallScript": true, - "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -7373,7 +7465,6 @@ "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", @@ -7385,11 +7476,10 @@ } }, "node_modules/protobufjs-cli": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.3.1.tgz", - "integrity": "sha512-gC4QCHbfllrx1sjakFMBBraS4BxPyOlHPAE1/Qk7C+nBT9CPfhQga0u9mR0FO0GdspgSIk+VAvpxoo5wR0Wz7Q==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.3.3.tgz", + "integrity": "sha512-zwmt6JeStPjeofZbl+QADexAVzP1EgsIAsO7mCfKkW/8oBxHwgTqJ1aD7zDrcCC0Nb+SLWSvCR3RDaKgIO3P8w==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "chalk": "^4.0.0", "escodegen": "^1.13.0", @@ -7410,7 +7500,7 @@ "node": ">=12.0.0" }, "peerDependencies": { - "protobufjs": "^7.6.1" + "protobufjs": "^7.6.2" } }, "node_modules/protobufjs-cli/node_modules/ansi-styles": { @@ -7573,6 +7663,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.2.tgz", @@ -7743,7 +7842,6 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -8261,7 +8359,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8271,9 +8368,9 @@ } }, "node_modules/uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, "node_modules/uglify-js": { @@ -8396,7 +8493,8 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/v8-to-istanbul": { "version": "9.1.0", @@ -8736,7 +8834,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.9.tgz", "integrity": "sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==", "dev": true, - "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.5", @@ -9902,10 +9999,12 @@ "dev": true }, "@cspotcode/source-map-support": { - "version": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "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==", "dev": true, "optional": true, + "peer": true, "requires": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -9916,6 +10015,7 @@ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "optional": true, + "peer": true, "requires": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -10152,6 +10252,29 @@ "requires": { "has-flag": "^4.0.0" } + }, + "ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@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" + } } } }, @@ -10588,12 +10711,6 @@ "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=", "dev": true }, - "@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "dev": true - }, "@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -10691,28 +10808,36 @@ "dev": true }, "@tsconfig/node10": { - "version": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@tsconfig/node12": { - "version": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@tsconfig/node14": { - "version": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@tsconfig/node16": { - "version": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@types/babel__core": { "version": "7.20.1", @@ -10815,26 +10940,25 @@ } }, "@types/linkify-it": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", - "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "dev": true }, "@types/markdown-it": { - "version": "12.2.3", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", - "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, - "peer": true, "requires": { - "@types/linkify-it": "*", - "@types/mdurl": "*" + "@types/linkify-it": "^5", + "@types/mdurl": "^2" } }, "@types/mdurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", - "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "dev": true }, "@types/node": { @@ -10842,7 +10966,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.2.tgz", "integrity": "sha512-37MXfxkb0vuIlRKHNxwCkb60PNBpR94u4efQuN4JgIAm66zfCDXGSAFCef9XUWFovX2R1ok6Z7MHhtdVXXkkIw==", "dev": true, - "peer": true, "requires": { "undici-types": "~5.26.4" } @@ -10899,8 +11022,7 @@ "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dev": true, - "peer": true + "dev": true }, "acorn-globals": { "version": "7.0.1", @@ -11188,7 +11310,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", "dev": true, - "peer": true, "requires": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -11365,10 +11486,12 @@ } }, "create-require": { - "version": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "cross-spawn": { "version": "7.0.6", @@ -11509,9 +11632,9 @@ "dev": true }, "entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true }, "error-ex": { @@ -11731,15 +11854,15 @@ } }, "form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" } }, "fs-extra": { @@ -11881,9 +12004,9 @@ } }, "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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==", "requires": { "function-bind": "^1.1.2" } @@ -12124,7 +12247,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.1.tgz", "integrity": "sha512-Nirw5B4nn69rVUZtemCQhwxOBhm0nsp3hmtF4rzCeWD7BkjAXRIji7xWQfnTNbz9g0aVsBX6aZK3n+23LM6uDw==", "dev": true, - "peer": true, "requires": { "@jest/core": "^29.6.1", "@jest/types": "^29.6.1", @@ -12375,6 +12497,29 @@ "requires": { "has-flag": "^4.0.0" } + }, + "ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@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" + } } } }, @@ -13511,21 +13656,21 @@ } }, "jsdoc": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.2.tgz", - "integrity": "sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", + "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", "dev": true, "requires": { "@babel/parser": "^7.20.15", "@jsdoc/salty": "^0.2.1", - "@types/markdown-it": "^12.2.3", + "@types/markdown-it": "^14.1.1", "bluebird": "^3.7.2", "catharsis": "^0.9.0", "escape-string-regexp": "^2.0.0", "js2xmlparser": "^4.0.2", "klaw": "^3.0.0", - "markdown-it": "^12.3.2", - "markdown-it-anchor": "^8.4.1", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", "marked": "^4.0.10", "mkdirp": "^1.0.4", "requizzle": "^0.2.3", @@ -13655,12 +13800,12 @@ "dev": true }, "linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, "requires": { - "uc.micro": "^1.0.1" + "uc.micro": "^2.0.0" } }, "locate-path": { @@ -13739,16 +13884,17 @@ } }, "markdown-it": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", - "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, "requires": { "argparse": "^2.0.1", - "entities": "~2.1.0", - "linkify-it": "^3.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, "dependencies": { "argparse": { @@ -13778,9 +13924,9 @@ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" }, "mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true }, "merge-stream": { @@ -13978,14 +14124,6 @@ "dev": true, "requires": { "entities": "^4.4.0" - }, - "dependencies": { - "entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true - } } }, "path-exists": { @@ -14106,11 +14244,10 @@ } }, "protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "dev": true, - "peer": true, "requires": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -14118,7 +14255,6 @@ "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", @@ -14127,9 +14263,9 @@ } }, "protobufjs-cli": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.3.1.tgz", - "integrity": "sha512-gC4QCHbfllrx1sjakFMBBraS4BxPyOlHPAE1/Qk7C+nBT9CPfhQga0u9mR0FO0GdspgSIk+VAvpxoo5wR0Wz7Q==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.3.3.tgz", + "integrity": "sha512-zwmt6JeStPjeofZbl+QADexAVzP1EgsIAsO7mCfKkW/8oBxHwgTqJ1aD7zDrcCC0Nb+SLWSvCR3RDaKgIO3P8w==", "dev": true, "requires": { "chalk": "^4.0.0", @@ -14262,6 +14398,12 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, + "punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true + }, "pure-rand": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.2.tgz", @@ -14393,7 +14535,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, - "peer": true, "requires": { "fsevents": "~2.3.2" } @@ -14762,13 +14903,12 @@ "version": "4.6.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", - "dev": true, - "peer": true + "dev": true }, "uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, "uglify-js": { @@ -14844,10 +14984,12 @@ } }, "v8-compile-cache-lib": { - "version": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "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==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "v8-to-istanbul": { "version": "9.1.0", From 7276104755ad22e99e3fe33fc2511c479b486846 Mon Sep 17 00:00:00 2001 From: Tyler Carson Date: Wed, 17 Jun 2026 10:40:32 -0700 Subject: [PATCH 15/48] Supply-chain security: use npm ci in CI workflows and add min-release-age to keeperapi Switch all CI workflows from npm install/npm i to npm ci for reproducible, lock-file-enforced installs. Add min-release-age=3 to keeperapi/.npmrc to prevent installing packages published less than 3 days ago. --- .github/workflows/main.yml | 2 +- .github/workflows/publish.keeper-sdk.yml | 2 +- .github/workflows/publish.npm.yml | 2 +- keeperapi/.npmrc | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 81326afc..7f914038 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: node-version: '24' - name: Lib - Install - run: npm i + run: npm ci working-directory: ./keeperapi env: NPM_TOKEN: "" diff --git a/.github/workflows/publish.keeper-sdk.yml b/.github/workflows/publish.keeper-sdk.yml index 37b06b83..bb4ccba6 100644 --- a/.github/workflows/publish.keeper-sdk.yml +++ b/.github/workflows/publish.keeper-sdk.yml @@ -26,7 +26,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install dependencies - run: npm install + run: npm ci - name: Publish package run: npm publish diff --git a/.github/workflows/publish.npm.yml b/.github/workflows/publish.npm.yml index 9fc66b2b..77ae7a09 100644 --- a/.github/workflows/publish.npm.yml +++ b/.github/workflows/publish.npm.yml @@ -26,7 +26,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install dependencies - run: npm install + run: npm ci - name: Publish package run: npm publish diff --git a/keeperapi/.npmrc b/keeperapi/.npmrc index ae643592..5e7ecfcf 100644 --- a/keeperapi/.npmrc +++ b/keeperapi/.npmrc @@ -1 +1,2 @@ //registry.npmjs.org/:_authToken=${NPM_TOKEN} +min-release-age=3 From c859f534ca6b72f9cb7740a93919f8516bc7e1d7 Mon Sep 17 00:00:00 2001 From: Hoseong Lee <154545063+hleekeeper@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:09:42 -0500 Subject: [PATCH 16/48] BE-7699 Removed leading slashes from rest messages (#179) * Removed leading slashes from the rest messages * v17.3.1 * Added the engines field in the package.json * v18.0.0 --- keeperapi/package-lock.json | 298 +--------------------------------- keeperapi/package.json | 5 +- keeperapi/src/restMessages.ts | 10 +- 3 files changed, 14 insertions(+), 299 deletions(-) diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index c653de19..b2ebc28a 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1,12 +1,12 @@ { "name": "@keeper-security/keeperapi", - "version": "17.2.8", + "version": "18.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@keeper-security/keeperapi", - "version": "17.2.8", + "version": "18.0.0", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -32,6 +32,9 @@ "ts-jest": "^29.1.1", "ts-node": "^8.10.2", "typescript": "^4.0.1" + }, + "engines": { + "node": ">=24.13.1" } }, "node_modules/@ampproject/remapping": { @@ -1786,34 +1789,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2129,51 +2104,6 @@ "node": ">=8" } }, - "node_modules/@jest/core/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==", - "dev": true, - "optional": true, - "peer": true, - "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/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2860,42 +2790,6 @@ "node": ">= 10" } }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -3710,15 +3604,6 @@ "url": "https://opencollective.com/core-js" } }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5116,51 +5001,6 @@ "node": ">=8" } }, - "node_modules/jest-cli/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==", - "dev": true, - "optional": true, - "peer": true, - "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/jest-diff": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", @@ -8487,15 +8327,6 @@ "requires-port": "^1.0.0" } }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -9998,31 +9829,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "@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==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "dependencies": { - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - } - } - }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -10252,29 +10058,6 @@ "requires": { "has-flag": "^4.0.0" } - }, - "ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@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" - } } } }, @@ -10807,38 +10590,6 @@ "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "dev": true }, - "@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "optional": true, - "peer": true - }, - "@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==", - "dev": true, - "optional": true, - "peer": true - }, "@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -11485,14 +11236,6 @@ "browserslist": "^4.22.1" } }, - "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==", - "dev": true, - "optional": true, - "peer": true - }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12497,29 +12240,6 @@ "requires": { "has-flag": "^4.0.0" } - }, - "ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@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" - } } } }, @@ -14983,14 +14703,6 @@ "requires-port": "^1.0.0" } }, - "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==", - "dev": true, - "optional": true, - "peer": true - }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", diff --git a/keeperapi/package.json b/keeperapi/package.json index 604b5acf..3d3a3d6b 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -1,12 +1,15 @@ { "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", - "version": "17.3.0", + "version": "18.0.0", "browser": "dist/index.es.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", "repository": "https://github.com/Keeper-Security/keeper-sdk-javascript", "license": "ISC", + "engines": { + "node": ">=24.13.1" + }, "scripts": { "start": "rollup -cw", "build": "node ./scripts/cleanDistFolder.js && rollup -c && cp src/proto.d.ts dist", diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 6d9ab14f..8edaf50a 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -878,17 +878,17 @@ export const ssoCloudValidationRequestMessage = ( ) export const switchAccountListAuthenticated = () => - createOutMessage('/authentication/switch_account_list_authenticated', Authentication.SwitchListResponse) + createOutMessage('authentication/switch_account_list_authenticated', Authentication.SwitchListResponse) export const switchAccountListRemoved = ( data: Authentication.LoginAsUserRequest ): RestInMessage => - createInMessage(data, '/authentication/switch_account_list_remove', Authentication.LoginAsUserRequest) + createInMessage(data, 'authentication/switch_account_list_remove', Authentication.LoginAsUserRequest) export const switchAccountFromAuthenticated = (data: Authentication.LoginAsUserRequest) => createMessage( data, - '/authentication/switch_account_from_authenticated', + 'authentication/switch_account_from_authenticated', Authentication.LoginAsUserRequest, Authentication.LoginResponse ) @@ -993,12 +993,12 @@ export const pamGetOnlineControllersMessage = (): RestOutMessage => - createMessage(data, '/vault/records/v3/add', record.v3.RecordsAddRequest, Records.RecordsModifyResponse) + createMessage(data, 'vault/records/v3/add', record.v3.RecordsAddRequest, Records.RecordsModifyResponse) export const keeperDriveRecordsUpdate = ( data: Records.IRecordsUpdateRequest ): RestMessage => - createMessage(data, '/vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) + createMessage(data, 'vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) export const getSharingAdminsMessage = ( data: Enterprise.IGetSharingAdminsRequest From 6e79fa427c62d059e6833f2f04f0cf11ed800354 Mon Sep 17 00:00:00 2001 From: Tyler Carson Date: Tue, 23 Jun 2026 09:09:37 -0700 Subject: [PATCH 17/48] Use preserveModules for browser dist to enable proto.js tree-shaking by downstream bundlers (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proto.js is 16.1 MB unminified. With a monolithic ES bundle, any keeperapi import pulled in the full file. preserveModules outputs one file per source module so webpack can exclude proto.js entirely from bundles that don't use protobuf (e.g. tabWorker: 16 MB → 42 KB, browser action: eliminated as an async chunk). sideEffects lists configureProtobuf so it is never dropped. --- keeperapi/package-lock.json | 4 ++-- keeperapi/package.json | 7 +++++-- keeperapi/rollup.config.js | 8 +++----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index b2ebc28a..d190d206 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1,12 +1,12 @@ { "name": "@keeper-security/keeperapi", - "version": "18.0.0", + "version": "18.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@keeper-security/keeperapi", - "version": "18.0.0", + "version": "18.0.1", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", diff --git a/keeperapi/package.json b/keeperapi/package.json index 3d3a3d6b..4013e5cd 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -1,10 +1,13 @@ { "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", - "version": "18.0.0", - "browser": "dist/index.es.js", + "version": "18.0.1", + "browser": "dist/browser/index.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", + "sideEffects": [ + "**/configureProtobuf*" + ], "repository": "https://github.com/Keeper-Security/keeper-sdk-javascript", "license": "ISC", "engines": { diff --git a/keeperapi/rollup.config.js b/keeperapi/rollup.config.js index b1e68944..41dda6c3 100644 --- a/keeperapi/rollup.config.js +++ b/keeperapi/rollup.config.js @@ -8,14 +8,12 @@ export default [ input: 'src/browser/index.ts', output: [ { - file: pkg.browser, + dir: 'dist', format: 'es', + preserveModules: true, + preserveModulesRoot: 'src', sourcemap: true, }, - // { - // file: pkg.browsertest, - // format: 'cjs', - // }, ], external: [...Object.keys(pkg.dependencies || {}), 'protobufjs/minimal', '@noble/post-quantum/ml-kem.js'], plugins: [ From 8138e53e19272bfe419ee7ab2b652ab1eb2f93c9 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 24 Jun 2026 09:54:28 +0530 Subject: [PATCH 18/48] NSF command implementation (list, get, ln and rm) (#175) --- KeeperSdk/package-lock.json | 8 +- KeeperSdk/package.json | 2 +- KeeperSdk/src/index.ts | 54 ++ .../NestedShareFolderManager.ts | 97 ++++ KeeperSdk/src/nestedShareFolders/getNsf.ts | 500 ++++++++++++++++++ KeeperSdk/src/nestedShareFolders/index.ts | 78 +++ .../src/nestedShareFolders/linkNsfRecord.ts | 139 +++++ KeeperSdk/src/nestedShareFolders/listNsf.ts | 173 ++++++ .../src/nestedShareFolders/nsfConstants.ts | 54 ++ .../src/nestedShareFolders/nsfHelpers.ts | 458 ++++++++++++++++ .../src/nestedShareFolders/removeNsfRecord.ts | 255 +++++++++ KeeperSdk/src/storage/InMemoryStorage.ts | 4 + KeeperSdk/src/utils/constants.ts | 36 +- KeeperSdk/src/utils/index.ts | 1 + KeeperSdk/src/vault/KeeperVault.ts | 55 ++ examples/sdk_example/package.json | 4 + .../src/nestedShareFolders/get_nsf.ts | 51 ++ .../src/nestedShareFolders/link_nsf.ts | 44 ++ .../src/nestedShareFolders/list_nsf.ts | 69 +++ .../src/nestedShareFolders/remove_nsf.ts | 119 +++++ 20 files changed, 2192 insertions(+), 9 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts create mode 100644 KeeperSdk/src/nestedShareFolders/getNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/index.ts create mode 100644 KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/listNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfConstants.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfHelpers.ts create mode 100644 KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/get_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/link_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/list_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/remove_nsf.ts diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 071875e6..794d061a 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "17.2.6", + "@keeper-security/keeperapi": "17.2.7", "asmcrypto.js": "^2.3.2", "ts-node": "^10.7.0", "typescript": "^4.6.3" @@ -57,9 +57,9 @@ } }, "node_modules/@keeper-security/keeperapi": { - "version": "17.2.6", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.2.6.tgz", - "integrity": "sha512-SEPP2rYioDFBJcCcjq/U2pUw5KBq6ymz85PTs/Na77Jwno7JXjabzKYApq+L1zMql0fW5UKYVUgTrSz2KImtbQ==", + "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", diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index a0dcd9c4..e027abbb 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -24,7 +24,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "17.2.6", + "@keeper-security/keeperapi": "17.2.7", "ts-node": "^10.7.0", "asmcrypto.js": "^2.3.2", "typescript": "^4.6.3" diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 135843d1..df3809cc 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -26,6 +26,7 @@ export { extractResultCode, SdkDefaults, AuthDefaults, + NsfErrorCode, ResultCodes, AuthErrorCode, SessionErrorCode, @@ -444,6 +445,59 @@ export { 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, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, + linkNestedShareRecord, + NsfRemoveOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, + NestedShareFolderManager, +} from './nestedShareFolders' +export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, + LinkNsfRecordResult, + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, +} from './nestedShareFolders' + export type { DRecord, DRecordMetadata, diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts new file mode 100644 index 00000000..4a21c74f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -0,0 +1,97 @@ +import type { Auth } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + formatListNsfOutput, + formatListNsfTable, + listNestedShareFolders, + renderListNsfAsciiTable, + type FormattedListNsfTable, + type ListNsfFormatInput, + type ListNsfOptions, + type ListNsfRow, +} from './listNsf' +import { + formatNsfDetail as renderNsfDetail, + formatNsfFolderDetail as renderNsfFolderDetail, + formatNsfRecordDetail as renderNsfRecordDetail, + getNestedShareFolder, + type GetNsfOptions, + type GetNsfResult, + type NsfFolderView, + type NsfRecordView, +} from './getNsf' +import { linkNestedShareRecord, type LinkNsfRecordResult } from './linkNsfRecord' +import { + formatRemoveNsfPreview, + removeNestedShareRecords, + type RemoveNsfRecordInput, + type RemoveNsfRecordResult, +} from './removeNsfRecord' + +export type AuthProvider = () => Auth + +export class NestedShareFolderManager { + private readonly storage: InMemoryStorage + private readonly 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 + } + + public listNestedShareFolders(options: ListNsfOptions = {}): ListNsfRow[] { + return listNestedShareFolders(this.storage, 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 = 'table'): string { + return formatListNsfOutput(rows, format) + } + + public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { + return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) + } + + public formatNsfDetail(result: GetNsfResult, verbose = false): string { + return renderNsfDetail(result, verbose) + } + + public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { + return renderNsfFolderDetail(view, verbose) + } + + public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { + return renderNsfRecordDetail(view, verbose) + } + + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string + ): Promise { + return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) + } + + public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { + return removeNestedShareRecords(this.storage, this.requireAuth(), input) + } + + public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { + return formatRemoveNsfPreview(preview) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts new file mode 100644 index 00000000..25523668 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -0,0 +1,500 @@ +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' +import { + getRecordFields, + getRecordLogin, + getRecordPassword, + getRecordTitle, + getRecordType, + getRecordUrl, +} from '../records/RecordUtils' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + buildFolderPath, + collectRecordsInFolder, + findRecordFolderLocation, + folderAccessDisplayRole, + formatAccessType, + getFolderAccessEntries, + getKeeperDriveFolder, + getKeeperDriveRecord, + isFolderShareAdministrator, + isFolderUserPermission, + isSensitiveFieldType, + normalizeParentUid, + resolveAccessUsername, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { + NSF_FOLDER_LABEL_WIDTH, + NSF_FOLDER_SHARE_ADMINS_HEADING, + NSF_FOLDER_USER_PERMISSIONS_HEADING, + NSF_MASKED_VALUE, + NSF_RECORD_LABEL_WIDTH, + NSF_RECORD_USER_PERMISSIONS_HEADING, + NSF_TOP_LEVEL_FIELD_TYPES, + NSF_UNKNOWN_RECORD_TITLES, +} from './nsfConstants' + +function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { + if (value == null) return undefined + return typeof value === 'number' ? value : value.toNumber() +} + +function formatNsfFieldParts(values: unknown[]): string[] { + 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) +} + +export enum GetNsfFormat { + Detail = 'detail', + JSON = 'json', +} + +export type GetNsfFormatInput = GetNsfFormat | `${GetNsfFormat}` + +export type GetNsfOptions = { + format?: GetNsfFormatInput + verbose?: boolean + unmask?: boolean +} + +export type NsfFolderAccessRow = { + username: string + role: string +} + +export type NsfFolderPermission = { + accessTypeUid: string + accessType: string + accessRoleType: string + inherited?: boolean + hidden?: boolean + canAdd?: boolean + canRemove?: boolean + canDelete?: boolean + canListAccess?: boolean + canUpdateAccess?: boolean + canEditRecords?: boolean + canViewRecords?: boolean + canListRecords?: boolean +} + +export type NsfRecordPermission = { + username: string + accountUid?: string + owner: boolean + shareAdmin: boolean + shareable: boolean + editable: boolean + awaitingApproval: boolean + expiration?: number +} + +export type NsfFolderView = { + objectType: 'folder' + folderUid: string + name: string + parentUid: string + path: string + userPermissions: NsfFolderAccessRow[] + shareAdmins: NsfFolderAccessRow[] + teamPermissions: NsfFolderPermission[] + records: { uid: string; title: string; type: string }[] +} + +export type NsfRecordView = { + objectType: 'record' + recordUid: string + title: string + type: string + revision: number + version: number + folderLocation: string + login?: string + password?: string + url?: string + notes?: string + fields: { type: string; label?: string; value: string }[] + userPermissions: NsfRecordPermission[] + shareAdmins: string[] +} + +export type GetNsfResult = { kind: 'folder'; view: NsfFolderView } | { kind: 'record'; view: NsfRecordView } + +function folderDetailRow(label: string, value: string): string { + return `${label.padStart(NSF_FOLDER_LABEL_WIDTH)}: ${value}` +} + +function recordDetailRow(label: string, value: string): string { + 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 bytesToUid(bytes: Uint8Array | null | undefined): string | undefined { + return bytes?.length ? webSafe64FromBytes(bytes) : undefined +} + +function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { + const permission = entry.permission + return { + accessTypeUid: entry.accessTypeUid, + accessType: formatAccessType(entry.accessType), + accessRoleType: folderAccessDisplayRole(entry), + inherited: entry.inherited, + hidden: entry.hidden, + canAdd: permission?.canAdd ?? undefined, + canRemove: permission?.canRemove ?? undefined, + canDelete: permission?.canDelete ?? undefined, + canListAccess: permission?.canListAccess ?? undefined, + canUpdateAccess: permission?.canUpdateAccess ?? undefined, + canEditRecords: permission?.canEditRecords ?? undefined, + canViewRecords: permission?.canViewRecords ?? undefined, + canListRecords: permission?.canListRecords ?? undefined, + } +} + +function buildFolderAccessRow( + storage: InMemoryStorage, + folder: DKdFolder, + entry: DKdFolderAccess +): NsfFolderAccessRow { + return { + username: resolveAccessUsername(storage, entry.accessTypeUid, folder), + role: folderAccessDisplayRole(entry), + } +} + +function splitFolderPermissions(storage: InMemoryStorage, folder: DKdFolder) { + const entries = getFolderAccessEntries(storage, folder.uid) + const userPermissions: NsfFolderAccessRow[] = [] + const shareAdmins: NsfFolderAccessRow[] = [] + const teamPermissions: NsfFolderPermission[] = [] + for (const entry of entries) { + if (isFolderUserPermission(entry)) { + userPermissions.push(buildFolderAccessRow(storage, folder, entry)) + } + if (isFolderShareAdministrator(entry)) { + shareAdmins.push(buildFolderAccessRow(storage, folder, entry)) + } + if (entry.accessType === Folder.AccessType.AT_TEAM) { + teamPermissions.push(mapFolderPermission(entry)) + } + } + return { userPermissions, shareAdmins, teamPermissions } +} + +function prependOwnerRow(rows: NsfFolderAccessRow[], ownerUsername: string): NsfFolderAccessRow[] { + if (rows.some((entry) => entry.username === ownerUsername)) return rows + return [{ username: ownerUsername, role: '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), + } +} + +function buildRecordFields(record: DRecord, unmask: boolean): NsfRecordView['fields'] { + return getRecordFields(record) + .filter((field) => !NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) + .map((field) => { + const rawValues = Array.isArray(field.value) ? field.value : [field.value] + const displayValue = formatNsfFieldParts(rawValues).join(', ') + return { field, displayValue } + }) + .filter(({ displayValue }) => displayValue.length > 0) + .map(({ field, displayValue }) => ({ + type: field.type, + label: field.label, + value: !unmask && isSensitiveFieldType(field.type) ? NSF_MASKED_VALUE : displayValue, + })) +} + +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') + lines.push(` Shareable: ${entry.shareable ? 'Yes' : 'No'}`) + lines.push(` Read-Only: ${entry.editable ? 'No' : 'Yes'}`) + return lines +} + +async function fetchRecordPermissions(auth: Auth, recordUid: string): Promise { + try { + const response = await auth.executeRest( + recordDetailsMessage(recordUid, Records.RecordDetailsInclude.SHARE_ONLY) + ) + const detail = response.recordDataWithAccessInfo?.[0] + return (detail?.userPermission ?? []).map((entry) => ({ + username: entry.username || '', + accountUid: bytesToUid(entry.accountUid), + owner: !!entry.owner, + shareAdmin: !!entry.shareAdmin, + shareable: !!entry.sharable, + editable: !!entry.editable, + awaitingApproval: !!entry.awaitingApproval, + expiration: longToNumber(entry.expiration as number | null | undefined), + })) + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}` + ) + } +} + +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 + } +} + +function buildFolderView(storage: InMemoryStorage, folderUid: string): NsfFolderView { + const folder = getKeeperDriveFolder(storage, folderUid) + if (!folder) { + throw new KeeperSdkError(`Nested share folder not found: ${folderUid}`, ResultCodes.NSF_NOT_FOUND) + } + + const split = splitFolderPermissions(storage, folder) + const { userPermissions, shareAdmins } = ensureFolderOwnerListed( + folder, + split.userPermissions, + split.shareAdmins + ) + + return { + objectType: '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), + })), + } +} + +async function buildRecordView( + 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, recordUid), + fetchRecordShareAdmins(auth, recordUid), + ]) + const notes = + typeof record.data?.notes === 'string' && record.data.notes.trim() ? record.data.notes.trim() : undefined + + return { + objectType: 'record', + recordUid, + title, + type: getRecordType(record), + revision: record.revision, + version: record.version, + 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 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 = {} +): 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: 'folder', view: buildFolderView(storage, folderUid) } + } + + const recordUid = resolveNsfRecord(storage, trimmed) + if (recordUid) { + const view = await buildRecordView(auth, storage, recordUid, options.unmask ?? false) + return { kind: '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), + '', + NSF_FOLDER_USER_PERMISSIONS_HEADING, + ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), + '', + 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)) + if (view.records.length > 0) { + 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}`) + } + } + 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), + ] + + 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) { + const label = field.label || field.type + lines.push(recordDetailRow(label.charAt(0).toUpperCase() + label.slice(1), field.value)) + } + + if (view.userPermissions.length > 0) { + lines.push('', NSF_RECORD_USER_PERMISSIONS_HEADING) + for (const entry of view.userPermissions) { + lines.push('', ...formatRecordUserPermissionBlock(entry)) + } + } + + if (view.shareAdmins.length > 0) { + lines.push('', `Share Admins (${view.shareAdmins.length}):`) + for (const admin of view.shareAdmins) { + lines.push(` ${admin}`) + } + } + + if (verbose) { + lines.push( + '', + recordDetailRow('Folder', view.folderLocation), + recordDetailRow('Revision', String(view.revision)), + recordDetailRow('Version', String(view.version)) + ) + } + + return lines.join('\n') +} + +export function formatNsfDetail(result: GetNsfResult, verbose = false): string { + return result.kind === 'folder' + ? formatNsfFolderDetail(result.view, verbose) + : formatNsfRecordDetail(result.view, verbose) +} diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts new file mode 100644 index 00000000..2edc264a --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -0,0 +1,78 @@ +export { + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + resolveKeeperDriveRootParentUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + resolveAccessUsername, + folderAccessDisplayRole, + isNestedShareRecord, + isNestedShareFolder, + ensureNestedShareRecord, + ensureNestedShareFolder, + resolveNsfRecordIdentifier, + resolveNsfFolderIdentifier, + findNestedShareFoldersForRecord, + checkFolderRemovePermission, + checkRecordDeletePermission, +} from './nsfHelpers' + +export { + ListNsfFormat, + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, +} from './listNsf' +export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, +} from './listNsf' + +export { + GetNsfFormat, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, +} from './getNsf' +export type { + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, +} from './getNsf' + +export { linkNestedShareRecord } from './linkNsfRecord' +export type { LinkNsfRecordResult } from './linkNsfRecord' + +export { + NsfRemoveOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, +} from './removeNsfRecord' +export type { + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, +} from './removeNsfRecord' + +export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts new file mode 100644 index 00000000..e277ffad --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -0,0 +1,139 @@ +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' +import { + ensureNestedShareFolder, + ensureNestedShareRecord, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' + +function resolveRecordKeyType( + 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, + } + } + 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 +): 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 { 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 +): 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 + 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 +): Promise { + 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) + } + + 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 + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts new file mode 100644 index 00000000..e4ff8d2c --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -0,0 +1,173 @@ +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { + NsfItemType, + findRecordFolderLocation, + getKeeperDriveFolders, + getKeeperDriveRecords, + getRecordDescription, + normalizeParentUid, +} 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' + +export enum ListNsfFormat { + Table = 'table', + CSV = 'csv', + JSON = 'json', +} + +export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}` + +export type ListNsfOptions = { + folders?: boolean + records?: boolean + format?: ListNsfFormatInput +} + +export type ListNsfRow = { + itemType: NsfItemType + uid: string + title: string + type: string + description: string + parentOrFolder: string +} + +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: normalizeParentUid(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: findRecordFolderLocation(storage, record.uid) || 'root', + })) +} + +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) +} + +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)}...` +} + +export function formatListNsfTable( + 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 } +} + +export function renderListNsfAsciiTable( + 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') +} + +function escapeCsvCell(value: string): string { + 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') +} + +export function formatListNsfJson(rows: ListNsfRow[]): string { + return JSON.stringify( + rows.map((row) => ({ + item_type: row.itemType, + uid: row.uid, + title: row.title, + type: row.type, + description: row.description, + parent_or_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)) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts new file mode 100644 index 00000000..ea667a20 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -0,0 +1,54 @@ +import { Folder } from '@keeper-security/keeperapi' + +export const NSF_LEGACY_RECORD_MSG = + "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." + +export const NSF_ACCESS_ROLE_LABELS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: 'navigator', + [Folder.AccessRoleType.REQUESTOR]: 'requestor', + [Folder.AccessRoleType.VIEWER]: 'viewer', + [Folder.AccessRoleType.SHARED_MANAGER]: 'shared-manager', + [Folder.AccessRoleType.CONTENT_MANAGER]: 'content-manager', + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: 'content-share-manager', + [Folder.AccessRoleType.MANAGER]: 'manager', + [Folder.AccessRoleType.UNRESOLVED]: 'unresolved', +} + +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', +} + +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_FOLDER_USER_PERMISSIONS_HEADING = 'User Permissions:' +export const NSF_FOLDER_SHARE_ADMINS_HEADING = 'Share Administrators:' +export const NSF_RECORD_USER_PERMISSIONS_HEADING = 'User Permissions:' + +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_REMOVALS = 500 diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts new file mode 100644 index 00000000..67b5a171 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -0,0 +1,458 @@ +import type { + DRecord, + DUser, + DKdFolder, + DKdFolderAccess, + DKdFolderRecord, + DKdRecordAccess, +} from '@keeper-security/keeperapi' +import { Folder, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { VaultObjectKind } from '../folders/folderHelpers' +import { KeeperSdkError, ResultCodes } from '../utils' +import { getRecordTitle } from '../records/RecordUtils' +import { + NSF_ACCESS_ROLE_LABELS, + NSF_ACCESS_TYPE_LABELS, + NSF_LEGACY_FOLDER_MSG, + NSF_LEGACY_RECORD_MSG, + NSF_NOTE_FIELD_TYPES, + NSF_RECORD_DESCRIPTION_MAX_LENGTH, + NSF_SENSITIVE_FIELD_TYPES, +} from './nsfConstants' + +export enum KeeperDriveKind { + 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', +} + +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)) +} + +/** Per-account drive root UID inferred from sync (parentUid of top-level folders). */ +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 +} + +export function isRootFolderUid( + storage: InMemoryStorage, + folderUid: string | undefined | null +): boolean { + const value = (folderUid ?? '').trim() + if (!value) return true + const driveRoot = resolveKeeperDriveRootParentUid(storage) + return !!driveRoot && value === driveRoot +} + +export function normalizeParentUid( + storage: InMemoryStorage, + parentUid: string | undefined | null +): string { + 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 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) +} + +function resolveByUidOrName( + 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 +} + +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 +} + +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 anyRecord = storage.getByUid(VaultObjectKind.Record, trimmed) + if (anyRecord) return anyRecord.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 findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { + return storage + .getAll(KeeperDriveKind.FolderRecord) + .filter((entry) => entry.recordUid === recordUid) + .map((entry) => entry.folderUid) +} + +function toRequiredAccountUidStr(accountUid: Uint8Array): string { + 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 +): 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 + )) + ) +} + +function isCurrentUserFolderAccess( + 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 + )) + ) +} + +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' + +function hasFolderPermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array, + permission: FolderPermissionFlag +): boolean { + 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 +} + +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 ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') + ) +} + +export function checkFolderRemovePermission( + storage: InMemoryStorage, + folderUid: string, + recordUid: string, + username: string, + accountUid: Uint8Array +): void { + if (hasFolderPermission(storage, folderUid, username, accountUid, 'canRemove')) return + // Folder-trash and unlink are less destructive than owner-trash. Allow when the user + // can permanently delete the record, or owns it without explicit record-access entries + // (common for records in a personal drive root with no folder-access sync data). + 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 +): 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 + ) +} + +export function formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): string { + if (role == null) return 'unknown' + return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}` +} + +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) +} + +export function getKeeperDriveRecords(storage: InMemoryStorage): DRecord[] { + return storage.getRecords().filter((record) => record.isKeeperDriveData) +} + +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 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 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 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) + } + } + + 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) +} + +export function resolveAccessUsername( + storage: InMemoryStorage, + accessTypeUid: string, + folder?: DKdFolder +): string { + 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 +} + +export function folderAccessDisplayRole(entry: DKdFolderAccess): string { + if (entry.accessType === Folder.AccessType.AT_OWNER) return '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 + ) +} + diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts new file mode 100644 index 00000000..595aee66 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -0,0 +1,255 @@ +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 { + checkFolderRemovePermission, + checkRecordDeletePermission, + ensureNestedShareRecord, + findNestedShareFoldersForRecord, + isRootFolderUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { NSF_MAX_REMOVALS } 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', +} + +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.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 hasPreviewErrors(preview: NsfRemovePreviewItem[]): boolean { + return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) +} + +type RemovalSpec = { + recordUid: string + folderUid?: string + operation: FolderProto.v3.remove.RecordOperationType +} + +function buildRemovals( + 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_REMOVALS) { + throw new KeeperSdkError(`Maximum ${NSF_MAX_REMOVALS} 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 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] + } + + 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) + } + + 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 +): 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, + }) + ) +} + +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}`) + } + } + 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 +): 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) + + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError(formatRemoveNsfPreview(preview) || 'Removal preview failed.', ResultCodes.NSF_REMOVE_FAILED) + } + + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, preview } + } + + 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/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 1ddbe973..0672fe21 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -140,6 +140,7 @@ export class InMemoryStorage implements VaultStorage { token?: string sharedFolderUid?: string recordUid?: string + folderUid?: string accountUid?: string | Uint8Array teamUid?: string } @@ -160,6 +161,9 @@ export class InMemoryStorage implements VaultStorage { 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/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index a50889ce..6678a70b 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,8 +1,13 @@ +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: 'c17.0.0', - DEVICE_NAME: 'JavaScript Keeper SDK', - CONFIG_DIR: '.keeper', - LOG_FORMAT: '!', + 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 = { @@ -49,6 +54,19 @@ export enum RoleErrorCode { RoleEnforcementFailed = 'role_enforcement_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', + MissingKey = 'nsf_missing_key', +} + export enum TeamErrorCode { TeamRequired = 'team_required', TeamNotFound = 'team_not_found', @@ -120,6 +138,16 @@ export const ResultCodes = { 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_MISSING_KEY: NsfErrorCode.MissingKey, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index 7b53cf56..c1595539 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -8,6 +8,7 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + NsfErrorCode, KEEPER_PUBLIC_HOSTS, } from './constants' export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger } from './Logger' diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 2a4e2cd4..af1a0092 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -81,6 +81,11 @@ import { type UpdateRoleResult, } from '../roles' import { UserManager } from '../users/UserManager' +import { NestedShareFolderManager } from '../nestedShareFolders/NestedShareFolderManager' +import type { ListNsfOptions, ListNsfRow, ListNsfFormatInput, FormattedListNsfTable } from '../nestedShareFolders/listNsf' +import type { GetNsfOptions, GetNsfResult } from '../nestedShareFolders/getNsf' +import type { LinkNsfRecordResult } from '../nestedShareFolders/linkNsfRecord' +import type { RemoveNsfRecordInput, RemoveNsfRecordResult } from '../nestedShareFolders/removeNsfRecord' import type { ListUserRow, ListUsersOptions, @@ -158,6 +163,7 @@ export class KeeperVault { private readonly teamManager: TeamManager private readonly roleManager: RoleManager private readonly userManager: UserManager + private readonly nestedShareFolderManager: NestedShareFolderManager constructor(config?: KeeperVaultConfig) { this.config = { @@ -181,6 +187,11 @@ export class KeeperVault { this.teamManager = new TeamManager(authProvider) this.roleManager = new RoleManager(authProvider) this.userManager = new UserManager(authProvider) + this.nestedShareFolderManager = new NestedShareFolderManager(this.storage, authProvider) + } + + public getNestedShareFolderManager(): NestedShareFolderManager { + return this.nestedShareFolderManager } public getFolderManager(): FolderManager { @@ -848,6 +859,50 @@ export class KeeperVault { 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 shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index f53fc689..165e6c7b 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -29,6 +29,10 @@ "roles:add": "ts-node src/roles/addRole.ts", "roles:update": "ts-node src/roles/updateRole.ts", "roles:delete": "ts-node src/roles/deleteRole.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", + "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts new file mode 100644 index 00000000..a997403b --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts @@ -0,0 +1,51 @@ +import { + cleanup, + extractErrorMessage, + GetNsfFormat, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function getNsf() { + const vault = await login() + + try { + const identifier = (await prompt('Record UID, folder UID, or title: ')).trim() + if (!identifier) { + logger.info('No UID or title given.') + return + } + + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const verbose = isYes(await prompt('Verbose permissions? [y/N]: ')) + const unmask = isYes(await prompt('Unmask secrets? [y/N]: ')) + + const restore = suppressLogs() + let result + try { + result = await vault.getNestedShareFolder(identifier, { + format: asJson ? GetNsfFormat.JSON : GetNsfFormat.Detail, + verbose, + unmask, + }) + } finally { + restore() + } + + logger.info('') + const output = asJson ? JSON.stringify(result.view, null, 2) : vault.formatNsfDetail(result, verbose) + process.stdout.write(`${output}\n`) + logger.info('') + } catch (err) { + logger.error(`Lookup failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(getNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts new file mode 100644 index 00000000..896ed6bb --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts @@ -0,0 +1,44 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function linkNsf() { + const vault = await login() + + try { + const recordIdentifier = (await prompt('Record UID or title: ')).trim() + const folderIdentifier = (await prompt('Destination folder UID or name: ')).trim() + if (!recordIdentifier || !folderIdentifier) { + logger.info('Both record and folder are required.') + return + } + + const restore = suppressLogs() + let result + try { + result = await vault.linkNestedShareRecord(recordIdentifier, folderIdentifier) + } finally { + restore() + } + + logger.info('') + logger.info(result.message) + logger.info(`Record: ${result.recordUid}`) + logger.info(`Folder: ${result.folderUid}`) + logger.info(`Status: ${result.status}`) + logger.info('') + } catch (err) { + logger.error(`Link failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(linkNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts new file mode 100644 index 00000000..20896bbc --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts @@ -0,0 +1,69 @@ +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 } from '../utils/format' + +type ListMode = 'all' | 'folders' | 'records' + +const MODE_BY_INPUT: Record = { + '': 'all', + '1': 'all', + '2': 'folders', + '3': 'records', + all: 'all', + folders: 'folders', + records: 'records', +} + +function parseMode(input: string): ListMode { + return MODE_BY_INPUT[input.trim().toLowerCase()] ?? 'all' +} + +async function listNsf() { + const vault = await login() + + try { + logger.info('Show: 1) all 2) folders only 3) records only') + const mode = parseMode(await prompt('Choose [1]: ')) + 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.listNestedShareFolders({ + folders: mode !== 'records', + records: mode !== 'folders', + }) + + if (rows.length === 0) { + logger.info('No nested share folder items found.') + return + } + + const output = vault.formatListNsfOutput(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} item${rows.length === 1 ? '' : 's'}`) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(listNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts new file mode 100644 index 00000000..d4ce1c7d --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts @@ -0,0 +1,119 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfRemoveOperation, + prompt, + suppressLogs, + type RemoveNsfRecordInput, + type RemoveNsfRecordResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveOperation.OwnerTrash, + '1': NsfRemoveOperation.OwnerTrash, + '2': NsfRemoveOperation.FolderTrash, + '3': NsfRemoveOperation.Unlink, + 'owner-trash': NsfRemoveOperation.OwnerTrash, + 'folder-trash': NsfRemoveOperation.FolderTrash, + unlink: NsfRemoveOperation.Unlink, +} + +function parseOperation(input: string): NsfRemoveOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveOperation.OwnerTrash +} + +function printPreview(vault: Awaited>, result: RemoveNsfRecordResult): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(vault.formatRemoveNsfPreview(result.preview)) + logger.info('') +} + +function printPreviewWarnings(result: RemoveNsfRecordResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function removeNestedShareRecords( + vault: Awaited>, + input: RemoveNsfRecordInput +): Promise { + const restore = suppressLogs() + try { + return await vault.removeNestedShareRecords(input) + } finally { + restore() + } +} + +async function removeNsf() { + const vault = await login() + + try { + const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() + const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + logger.info('Operation: 1) owner-trash 2) folder-trash 3) unlink') + const operation = parseOperation(await prompt('Choose [1]: ')) + const folder = + operation === NsfRemoveOperation.Unlink + ? (await prompt('Folder UID or name (required for unlink): ')).trim() + : (await prompt('Folder UID or name (optional): ')).trim() + 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: RemoveNsfRecordInput = { + records, + folder: folder || undefined, + operation, + } + + if (dryRun) { + const result = await removeNestedShareRecords(vault, { ...baseInput, dryRun: true }) + printPreview(vault, result) + logger.info('[Dry-run] No records were removed.') + return + } + + if (force) { + const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) + printPreview(vault, result) + if (result.confirmed && result.message) { + logger.info(result.message) + } + return + } + + const preview = await removeNestedShareRecords(vault, { ...baseInput, force: false }) + printPreview(vault, preview) + printPreviewWarnings(preview) + + if (!isYes(await prompt('Do you want to proceed with deletion? [y/n]: '))) { + logger.info('Removal cancelled.') + return + } + + const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) + if (result.confirmed && result.message) { + logger.info(result.message) + } + } catch (err) { + logger.error(`Remove failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(removeNsf) From 1624afc75cf6f1101b5997ced649cc28f4377e24 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Tue, 16 Jun 2026 21:52:57 +0530 Subject: [PATCH 19/48] initial implementation of vault cli --- KeeperSdk/README.md | 110 +++------ KeeperSdk/package-lock.json | 2 +- KeeperSdk/src/auth/sessionRestore.ts | 8 + KeeperSdk/src/cli/access.ts | 30 +++ KeeperSdk/src/cli/builtinCommands.ts | 36 +++ KeeperSdk/src/cli/commandHelpers.ts | 24 ++ KeeperSdk/src/cli/commander/get.ts | 42 ++++ KeeperSdk/src/cli/commander/getCore.ts | 125 ++++++++++ KeeperSdk/src/cli/commander/index.ts | 4 + KeeperSdk/src/cli/commander/misc.ts | 187 +++++++++++++++ KeeperSdk/src/cli/commander/nav.ts | 234 +++++++++++++++++++ KeeperSdk/src/cli/commands/help.ts | 76 ++++++ KeeperSdk/src/cli/commands/login.ts | 178 ++++++++++++++ KeeperSdk/src/cli/commands/logout.ts | 39 ++++ KeeperSdk/src/cli/commands/restoreSession.ts | 211 +++++++++++++++++ KeeperSdk/src/cli/commands/sync.ts | 61 +++++ KeeperSdk/src/cli/commands/vault.ts | 56 +++++ KeeperSdk/src/cli/dispatch.ts | 52 +++++ KeeperSdk/src/cli/help.ts | 82 +++++++ KeeperSdk/src/cli/index.ts | 109 +++++++++ KeeperSdk/src/cli/jsonArg.ts | 30 +++ KeeperSdk/src/cli/parse.ts | 173 ++++++++++++++ KeeperSdk/src/cli/parser.ts | 172 ++++++++++++++ KeeperSdk/src/cli/prompt.ts | 12 + KeeperSdk/src/cli/registry.ts | 55 +++++ KeeperSdk/src/cli/table.ts | 8 + KeeperSdk/src/cli/types.ts | 90 +++++++ KeeperSdk/src/cli/utils.ts | 18 ++ KeeperSdk/src/cli/vaultSurface.ts | 18 ++ KeeperSdk/src/folders/folderTree.ts | 11 +- 30 files changed, 2167 insertions(+), 86 deletions(-) create mode 100644 KeeperSdk/src/cli/access.ts create mode 100644 KeeperSdk/src/cli/builtinCommands.ts create mode 100644 KeeperSdk/src/cli/commandHelpers.ts create mode 100644 KeeperSdk/src/cli/commander/get.ts create mode 100644 KeeperSdk/src/cli/commander/getCore.ts create mode 100644 KeeperSdk/src/cli/commander/index.ts create mode 100644 KeeperSdk/src/cli/commander/misc.ts create mode 100644 KeeperSdk/src/cli/commander/nav.ts create mode 100644 KeeperSdk/src/cli/commands/help.ts create mode 100644 KeeperSdk/src/cli/commands/login.ts create mode 100644 KeeperSdk/src/cli/commands/logout.ts create mode 100644 KeeperSdk/src/cli/commands/restoreSession.ts create mode 100644 KeeperSdk/src/cli/commands/sync.ts create mode 100644 KeeperSdk/src/cli/commands/vault.ts create mode 100644 KeeperSdk/src/cli/dispatch.ts create mode 100644 KeeperSdk/src/cli/help.ts create mode 100644 KeeperSdk/src/cli/index.ts create mode 100644 KeeperSdk/src/cli/jsonArg.ts create mode 100644 KeeperSdk/src/cli/parse.ts create mode 100644 KeeperSdk/src/cli/parser.ts create mode 100644 KeeperSdk/src/cli/prompt.ts create mode 100644 KeeperSdk/src/cli/registry.ts create mode 100644 KeeperSdk/src/cli/table.ts create mode 100644 KeeperSdk/src/cli/types.ts create mode 100644 KeeperSdk/src/cli/utils.ts create mode 100644 KeeperSdk/src/cli/vaultSurface.ts diff --git a/KeeperSdk/README.md b/KeeperSdk/README.md index 314b3778..c431cfd0 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -33,17 +33,24 @@ await vault.sync() console.log(`Loaded ${vault.getRecords().length} records`) ``` -## Built-in shell CLI +## Supported functionality + +`KeeperVault` exposes vault operations. Enterprise features require an enterprise administrator account. -**CLI docs & changelog:** [`src/cli/README.md`](src/cli/README.md) +- **Authentication**: Login, session token login, resume session, sync, logout +- **Records**: List, search, add, update, delete, move, history +- **Folders**: List, get, create, rename, delete, change directory, folder tree +- **Shared folders**: List shared folders, share with users, update permissions +- **Sharing**: Share and unshare records, check share info +- **Teams / users / roles** (enterprise admin): Available via the SDK API; not exposed as shell commands in this release -Commander-aligned commands via `dispatchCliLine(line, host)` or `createKeeperCliParser()`. +## Built-in shell CLI -### Before login +The package includes a Commander-style CLI (`dispatchCliLine`, `createKeeperCliParser`) for auth, records, and folders. -`help`, `login`, `restore-session`, `register-device` +**Before login:** `help`, `login`, `restore-session` -### After login +**After login:** | Area | Commands | |------|----------| @@ -52,99 +59,34 @@ Commander-aligned commands via `dispatchCliLine(line, host)` or `createKeeperCli | Folders | `ls`, `cd`, `tree`, `mkdir`, `list-sf` (`lsf`) | | Vault info | `vault summary` | -Every command supports `--help` / `-h`. Record/folder **write** operations (`add`, `update`, `delete`, `share`, …) are SDK API only — see [`examples/sdk_example`](../examples/sdk_example). +Every command supports `--help`. Record/folder write operations (`add`, `update`, `delete`, `share`, …) are SDK-only — see the examples below. ### Finding records and folders | Goal | Command | |------|---------| | Record by UID (exact) | `get ` | -| Record by title | `get "Gmail Login"` | -| Text in title/fields | `search ` (all terms must match; not for raw UID paste) | +| Record by title | `get "Gmail Login"` or `search gmail` | +| Text in title/fields | `search ` (all terms must match) | | Shared folder by UID | `get ` or `list-sf ` | | Folder by path/UID | `get `, `ls`, `cd`, `tree` | | All records (table) | `list` or `list --verbose` | +| Account summary | `whoami` or `vault summary` | -`search` covers **vault records** only (title, fields, UID substring). For exact UID lookup use **`get`**, not `search`. - -### `get` (Commander-style detail) - -Default `--format detail` prints aligned fields and, when the host exposes `getRecordShareInfo`, fetches share metadata: - -```text - UID: DAqb-wR89VrcdUxzrcW6ww - Type: login - Title: TestingParam - -User Permissions: - - User: user@example.com - Owner: Yes - Shareable: Yes - Read-Only: No - -Shared Folder Permissions: - - Shared Folder UID: cYEzoDium40DV9VlBwmRJQ - -Share Admins (9): - admin1@example.com - ... -``` - -Other formats: `--format json`, `--format password`, `--format fields`, `--unmask`. - -Implementation: `src/cli/commander/getFormat.ts` + `getCore.ts`. - -### `whoami` - -Uses `vault.getWhoamiInfo()` (account summary, server, data center, license). `--verbose` / `-v` syncs and includes vault counts; `--json` emits Commander-compatible JSON. - -Hosts **must** wire `getWhoamiInfo` on `KeeperCliVault` (see below). - -### CLI usage +`search` only covers **vault records** (title, fields, UID). It does not search teams or enterprise users — use the SDK API (`vault.viewTeam`, `vault.listTeams`, …) or `examples/sdk_example` scripts for those. ```typescript import { dispatchCliLine, type KeeperCliHost } from '@keeper-security/keeper-sdk-javascript' -const result = await dispatchCliLine('get DAqb-wR89VrcdUxzrcW6ww', host) -console.log(result.out) +await dispatchCliLine('restore-session --from-json session.json', host) +await dispatchCliLine('list', host) +await dispatchCliLine('ls', host) ``` -Shell parity example: - -```bash -cd examples/sdk_example -npm run records:list:shell-cli -- --from-json /path/to/session.json -``` - -## Embedding the CLI (`KeeperCliHost`) - -Thin hosts (browser shell, tests) implement `KeeperCliHost` and expose a `KeeperCliVault` adapter around `KeeperVault`. - -**Required for session commands:** `isLoggedIn`, `login`, `loginWithSessionToken`, `logout`, `sync`, `getRecords`, `getSharedFolders`, `restoreSession`. - -**Optional — commands fail with a clear message if missing:** - -| Method | Commands | -|--------|----------| -| `findRecord`, `findRecords` | `get`, `search` | -| `getRecordShareInfo` | `get` (share sections in detail output) | -| `getWhoamiInfo` | `whoami` | -| `getSummary` | `vault summary` | -| `listFolder`, `cd`, `tree`, `mkdir`, … | folder navigation | -| `listSharedFolders` | `list-sf` | - -Full type: `src/cli/types.ts` (`KeeperCliVault`, `KeeperCliHost`). - -A full `KeeperVault` adapter should forward **all** optional methods used by built-in commands — do not omit `getWhoamiInfo` or `getRecordShareInfo` if you want parity with Commander. - -## Supported vault API - -`KeeperVault` exposes auth, records, folders, sharing, and enterprise helpers (teams/users require admin). See `src/vault/KeeperVault.ts` and [`examples/sdk_example`](../examples/sdk_example). - ## Examples +Runnable SDK scripts are in [`examples/sdk_example`](../examples/sdk_example): + ```bash cd examples/sdk_example npm install @@ -155,6 +97,12 @@ npm run folders:ls npm run shared-folders:list-sf ``` +Shell CLI parity (same dispatch path as the vault shell): + +```bash +npm run records:list:shell-cli -- --from-json /path/to/session.json +``` + ## Local development From repo root, build keeperapi first: diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 794d061a..5eff7ccd 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -10,7 +10,7 @@ "license": "ISC", "dependencies": { "@keeper-security/keeperapi": "17.2.7", - "asmcrypto.js": "^2.3.2", + "asmcrypto.js": "^2.7.2", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, diff --git a/KeeperSdk/src/auth/sessionRestore.ts b/KeeperSdk/src/auth/sessionRestore.ts index 746ee0ff..0e64a4e0 100644 --- a/KeeperSdk/src/auth/sessionRestore.ts +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -9,6 +9,7 @@ const UserTypeValues = { onsiteSso: 'onsite_sso' as UserType, } +/** String form of {@link SessionParams} as exported from extension / vault storage. */ export type SessionRestoreInput = { accountUid: string clientKey: string @@ -99,6 +100,7 @@ export function validateSessionRestoreInput(input: Partial) 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) @@ -138,6 +140,7 @@ function looksLikeInlineJson(text: string): boolean { return t.startsWith('{') || t.startsWith('[') || t.startsWith('"') } +/** File path / URL — not inline JSON (avoid JSON.parse on `/path/to/conf.json`). */ function looksLikeFilePath(text: string): boolean { const t = text.trim() if (/^https?:\/\//i.test(t)) return true @@ -147,6 +150,10 @@ function looksLikeFilePath(text: string): boolean { return false } +/** + * Parse session JSON. One JSON.parse when the payload is an object; two when the CLI + * value is a JSON-encoded string (e.g. JSON.stringify(conf) wrapped in quotes). + */ export function sessionRestoreFromJson(json: string): SessionRestoreInput { const text = json.trim().replace(/^\uFEFF/, '') let parsed: unknown @@ -170,6 +177,7 @@ export function sessionRestoreFromJson(json: string): SessionRestoreInput { return validateSessionRestoreInput(parsed as Partial) } +/** Parse `--from-json` payload, or read a file when the value is not JSON. */ export async function resolveSessionRestorePayload( raw: string, readFile?: (path: string) => Promise diff --git a/KeeperSdk/src/cli/access.ts b/KeeperSdk/src/cli/access.ts new file mode 100644 index 00000000..027c0467 --- /dev/null +++ b/KeeperSdk/src/cli/access.ts @@ -0,0 +1,30 @@ +import type { CliCommandDefinition } from './types' +import { listCliCommands, resolveCliCommandName } from './registry' + +/** Commands available before a vault session exists. */ +export const AUTH_CLI_COMMAND_NAMES = new Set([ + 'help', + 'login', + 'restore-session', +]) + +export function isAuthCliCommand(name: string): boolean { + const resolved = resolveCliCommandName(name) + return resolved != null && AUTH_CLI_COMMAND_NAMES.has(resolved) +} + +export function filterCliCommandsForLoginState( + commands: readonly CliCommandDefinition[], + loggedIn: boolean +): CliCommandDefinition[] { + if (loggedIn) return [...commands] + return commands.filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) +} + +export function listCliCommandsForLoginState(loggedIn: boolean): CliCommandDefinition[] { + return filterCliCommandsForLoginState(listCliCommands(), loggedIn) +} + +export function listCliCommandNamesForLoginState(loggedIn: boolean): readonly string[] { + return listCliCommandsForLoginState(loggedIn).map((c) => c.name) +} diff --git a/KeeperSdk/src/cli/builtinCommands.ts b/KeeperSdk/src/cli/builtinCommands.ts new file mode 100644 index 00000000..1cd33332 --- /dev/null +++ b/KeeperSdk/src/cli/builtinCommands.ts @@ -0,0 +1,36 @@ +import type { CliCommandDefinition } from './types' +import { registerCliCommand } from './registry' +import { helpCommand } from './commands/help' +import { loginCommand } from './commands/login' +import { logoutCommand } from './commands/logout' +import { restoreSessionCommand } from './commands/restoreSession' +import { syncCommand } from './commands/sync' +import { vaultCommand } from './commands/vault' +import { getCommand } from './commander/get' +import { cdCommand, lsCommand, mkdirCommand, treeCommand } from './commander/nav' +import { listCommand, listSfCommand, searchCommand, whoamiCommand } from './commander/misc' + +/** Built-in CLI commands (Keeper Commander-style vault shell). */ +export const BUILTIN_CLI_COMMANDS: readonly CliCommandDefinition[] = [ + helpCommand, + loginCommand, + restoreSessionCommand, + syncCommand, + vaultCommand, + getCommand, + listCommand, + lsCommand, + cdCommand, + treeCommand, + mkdirCommand, + searchCommand, + listSfCommand, + whoamiCommand, + logoutCommand, +] + +export function registerBuiltinCliCommands(): void { + for (const def of BUILTIN_CLI_COMMANDS) { + registerCliCommand(def) + } +} diff --git a/KeeperSdk/src/cli/commandHelpers.ts b/KeeperSdk/src/cli/commandHelpers.ts new file mode 100644 index 00000000..e4fa6652 --- /dev/null +++ b/KeeperSdk/src/cli/commandHelpers.ts @@ -0,0 +1,24 @@ +import type { CliResult, KeeperCliHost, KeeperCliVault } from './types' +import { ensureLoggedIn } from './commands/login' + +export async function ensureSession(host: KeeperCliHost): Promise { + const v = host.getVault() + if (v.isLoggedIn) return null + const r = await ensureLoggedIn(host) + return r.code === 0 ? null : r +} + +export function ensureCapability( + v: KeeperCliVault, + name: K, + context: string +): CliResult | null { + if (typeof v[name] !== 'function') { + return { + code: 1, + out: '', + err: `${context}: this host does not expose KeeperCliVault.${String(name)}.\n`, + } + } + return null +} diff --git a/KeeperSdk/src/cli/commander/get.ts b/KeeperSdk/src/cli/commander/get.ts new file mode 100644 index 00000000..24fd82c1 --- /dev/null +++ b/KeeperSdk/src/cli/commander/get.ts @@ -0,0 +1,42 @@ +import type { CliCommandDefinition } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { executeGet } from './getCore' + +export const getCommand: CliCommandDefinition = { + name: 'get', + order: 10, + aliases: ['g'], + description: 'Get details of a record or folder by UID or title.', + usage: 'get [--format {detail,json,password,fields}] [--unmask]', + flagOptions: ['--format', '--unmask', '--detail', '--json'], + help: { + title: 'get — record/folder details (Keeper Commander)', + synopsis: 'usage: get [--unmask] [--format {detail,json,password,fields}] uid', + description: + ' Resolves a vault object by UID or title. Records support all output formats; folders and shared folders support detail/json.\n' + + ' Prefer get for exact UID lookup; search is for text in titles and fields.', + arguments: ' uid Record, folder, or shared-folder UID or title.', + options: ` --format {detail,json,password,fields} + detail (default): human-readable output. + json: JSON object. + password: password field only (records). + fields: JSON array of {name, value} (records). + --unmask Show sensitive field values (records). + --help, -h Show this help.`, + examples: ` get "Amazon" + get AbCdEf123456 --format json --unmask + get MyFolderUid --format json`, + seeAlso: ' ls, search, list', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(getCommand), err: '' } + } + try { + return await executeGet(host, parsed, 'get') + } catch (e) { + return { code: 1, out: '', err: host.formatError('get', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commander/getCore.ts b/KeeperSdk/src/cli/commander/getCore.ts new file mode 100644 index 00000000..6d98bde0 --- /dev/null +++ b/KeeperSdk/src/cli/commander/getCore.ts @@ -0,0 +1,125 @@ +import type { DRecord } from '@keeper-security/keeperapi' +import { + formatRecord, + formatRecordFields, + getRecordPassword, +} from '../../records/RecordUtils' +import type { CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt } from '../parse' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { GetFolderFormat } from '../../folders/getFolder' + +export type GetOutputFormat = 'detail' | 'json' | 'password' | 'fields' + +export function resolveGetFormat(parsed: ParsedCli): GetOutputFormat { + const raw = getOpt(parsed.opts, 'format')?.toLowerCase() + if (raw === 'json' || hasOpt(parsed.opts, 'json')) return 'json' + if (raw === 'password') return 'password' + if (raw === 'fields') return 'fields' + if (raw === 'detail') return 'detail' + return hasOpt(parsed.opts, 'detail') ? 'detail' : 'detail' +} + +export function resolveGetUnmask(parsed: ParsedCli): boolean { + return hasOpt(parsed.opts, 'unmask') +} + +export function getGetTarget(parsed: ParsedCli): string | undefined { + return parsed.positional[0]?.trim() || undefined +} + +async function outputRecord( + host: KeeperCliHost, + record: DRecord, + fmt: GetOutputFormat, + unmask: boolean, + cmd: string +): Promise { + if (fmt === 'password') { + const pw = getRecordPassword(record) + return { code: 0, out: pw ? `${pw}\n` : '', err: pw ? '' : `${cmd}: record has no password field\n` } + } + if (fmt === 'fields') { + return { code: 0, out: JSON.stringify(formatRecordFields(record, unmask), null, 2) + '\n', err: '' } + } + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(record, null, 2) + '\n', err: '' } + } + return { code: 0, out: formatRecord(record, { showDetails: true, unmask }) + '\n', err: '' } +} + +async function tryGetFolder( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + if (!v.getFolder) return null + try { + const res = await v.getFolder(target, { + format: fmt === 'json' ? GetFolderFormat.JSON : GetFolderFormat.Detail, + }) + if (fmt === 'json') { + const json = (res as { json?: Record }).json ?? res + return { code: 0, out: JSON.stringify(json, null, 2) + '\n', err: '' } + } + const name = 'name' in res ? res.name : target + const uid = + 'folder_uid' in res + ? res.folder_uid + : 'shared_folder_uid' in res + ? res.shared_folder_uid + : target + return { code: 0, out: `${name}\t${uid}\n`, err: '' } + } catch { + return null + } +} + +async function tryGetSharedFolderByUid( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + const hit = v.getSharedFolders().find((sf) => sf.uid === target) + if (!hit) return null + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(hit, null, 2) + '\n', err: '' } + } + if (fmt === 'password' || fmt === 'fields') { + return { code: 1, out: '', err: `${cmd}: --format ${fmt} applies to records only\n` } + } + return { code: 0, out: `${hit.name ?? '(unnamed)'}\t${hit.uid}\n`, err: '' } +} + +/** Commander-style `get` (record, folder, or shared folder by UID/title). */ +export async function executeGet(host: KeeperCliHost, parsed: ParsedCli, cmd = 'get'): Promise { + const target = getGetTarget(parsed) + if (!target) { + return { code: 1, out: '', err: `${cmd}: UID parameter is required\n` } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const fmt = resolveGetFormat(parsed) + const unmask = resolveGetUnmask(parsed) + + await v.sync() + + const sf = await tryGetSharedFolderByUid(host, target, fmt, cmd) + if (sf) return sf + + const folder = await tryGetFolder(host, target, fmt, cmd) + if (folder) return folder + + const cap = ensureCapability(v, 'findRecord', cmd) + if (cap) return cap + const record = v.findRecord!(target) + if (!record) { + return { code: 1, out: '', err: `${cmd}: cannot find any object matching "${target}"\n` } + } + return outputRecord(host, record, fmt, unmask, cmd) +} diff --git a/KeeperSdk/src/cli/commander/index.ts b/KeeperSdk/src/cli/commander/index.ts new file mode 100644 index 00000000..6795faac --- /dev/null +++ b/KeeperSdk/src/cli/commander/index.ts @@ -0,0 +1,4 @@ +export { getCommand } from './get' +export { executeGet } from './getCore' +export { lsCommand, cdCommand, treeCommand, mkdirCommand } from './nav' +export { listCommand, searchCommand, listSfCommand, whoamiCommand } from './misc' diff --git a/KeeperSdk/src/cli/commander/misc.ts b/KeeperSdk/src/cli/commander/misc.ts new file mode 100644 index 00000000..5e855aa5 --- /dev/null +++ b/KeeperSdk/src/cli/commander/misc.ts @@ -0,0 +1,187 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' +import { getRecordTitle } from '../../records/RecordUtils' +import { renderRecordsListTable } from '../../records/listRecordsTable' +import { recordUid } from '../utils' +import { formatSharedFoldersTable, renderSharedFoldersAsciiTable } from '../../sharedFolders/listSharedFolders' + +async function runList(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + await v.sync() + const records = v.getRecords() + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(records, null, 2) + '\n', err: '' } + } + if (records.length === 0) { + return { code: 0, out: '(no records)\n', err: '' } + } + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const out = renderRecordsListTable(records, { verbose }) + '\n' + return { code: 0, out, err: '' } +} + +async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { + const pattern = parsed.positional.join(' ') || getOpt(parsed.opts, 'pattern') + if (!pattern?.trim()) { + return { code: 1, out: '', err: 'search: missing search terms. Usage: search \n' } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'findRecords', 'search') + if (cap) return cap + await v.sync() + const matches = v.findRecords!(pattern) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(matches, null, 2) + '\n', err: '' } + } + if (matches.length === 0) { + return { code: 0, out: `(no records matched "${pattern}")\n`, err: '' } + } + const rows = matches.map((rec) => [recordUid(rec), getRecordTitle(rec)]) + return { code: 0, out: formatTable(['record_uid', 'title'], rows), err: '' } +} + +async function runListSf(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') + if (cap) return cap + await v.sync() + const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const rows = v.listSharedFolders!({ pattern, verbose, includeDetails: verbose }) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } + } + if (rows.length === 0) { + return { + code: 0, + out: pattern ? `(no shared folders matched "${pattern}")\n` : '(no shared folders)\n', + err: '', + } + } + const table = formatSharedFoldersTable(rows, { verbose }) + return { code: 0, out: renderSharedFoldersAsciiTable(table) + '\n', err: '' } +} + +async function runWhoami(host: KeeperCliHost): Promise { + const v = host.getVault() + if (!v.isLoggedIn) { + return { code: 1, out: '', err: 'whoami: not logged in\n' } + } + const username = + (await host.getAccountUsername?.()) ?? host.envString('KEEPER_USER') ?? host.envString('KEEPER_USERNAME') + const summary = v.getSummary?.() + const lines = [`username: ${username ?? '(unknown)'}`] + if (summary) { + lines.push( + `records: ${summary.recordCount}`, + `folders: ${summary.folderCount}`, + `shared_folders: ${summary.sharedFolderCount}` + ) + } + return { code: 0, out: lines.join('\n') + '\n', err: '' } +} + +export const listCommand: CliCommandDefinition = { + name: 'list', + order: 14, + aliases: ['l'], + description: 'List all vault records (Commander table).', + usage: 'list [--verbose|-v] [--json]', + flagOptions: ['--json', '--verbose', '-v'], + help: { + title: 'list — all records (Keeper Commander)', + synopsis: 'usage: list [--verbose]', + description: + ' Syncs and prints every record in a Commander-style table: uid, type, title, description, shared, and record category.', + options: ' --verbose, -v Do not truncate long columns (default max width 40).', + seeAlso: ' get, search, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listCommand), err: '' } + try { + return await runList(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list', e) } + } + }, +} + +export const searchCommand: CliCommandDefinition = { + name: 'search', + order: 15, + aliases: ['s'], + description: 'Search vault records by text.', + usage: 'search [--json]', + flagOptions: ['--json', '--pattern'], + help: { + title: 'search — find records (Keeper Commander)', + synopsis: 'usage: search ', + description: + ' Space-separated terms; all terms must match somewhere in the record (title, fields, or UID).\n' + + ' For exact lookup by UID, use get instead.', + examples: ' search amazon\n search bank account\n get zhJdqy7lb_zIEeCJT7GLlQ', + seeAlso: ' get, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(searchCommand), err: '' } + try { + return await runSearch(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('search', e) } + } + }, +} + +export const listSfCommand: CliCommandDefinition = { + name: 'list-sf', + order: 16, + aliases: ['lsf'], + description: 'List shared folders.', + usage: 'list-sf [pattern] [--verbose] [--json]', + flagOptions: ['--verbose', '-v', '--json', '--pattern'], + help: { + title: 'list-sf — shared folders (Keeper Commander)', + synopsis: 'usage: list-sf [pattern]', + seeAlso: ' ls, get', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listSfCommand), err: '' } + try { + return await runListSf(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list-sf', e) } + } + }, +} + +export const whoamiCommand: CliCommandDefinition = { + name: 'whoami', + order: 18, + description: 'Display current user and vault counts.', + usage: 'whoami', + help: { + title: 'whoami — current user (Keeper Commander)', + synopsis: 'usage: whoami', + seeAlso: ' login, sync-down', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(whoamiCommand), err: '' } + if (parsed.opts.size > 0 || parsed.positional.length > 0) { + return { code: 1, out: '', err: 'whoami: unexpected arguments\n' } + } + try { + return await runWhoami(host) + } catch (e) { + return { code: 1, out: '', err: host.formatError('whoami', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commander/nav.ts b/KeeperSdk/src/cli/commander/nav.ts new file mode 100644 index 00000000..0ff0e268 --- /dev/null +++ b/KeeperSdk/src/cli/commander/nav.ts @@ -0,0 +1,234 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' + +function lsPath(parsed: ParsedCli): string | undefined { + return parsed.positional[0] +} + +function formatLs( + result: { + detail: boolean + folders: Array<{ uid: string; name: string }> + records: Array<{ uid: string; name: string; type?: string }> + }, + detail: boolean +): string { + if (result.folders.length + result.records.length === 0) return '(empty)\n' + + const headers = detail ? ['flags', 'uid', 'name', 'type'] : ['kind', 'uid', 'name'] + const rows: string[][] = [] + for (const f of result.folders) { + const flags = ((f as { flags?: string }).flags ?? '').trim() + rows.push(detail ? [flags || 'f---', f.uid, f.name, ''] : ['dir', f.uid, f.name]) + } + for (const r of result.records) { + const flags = ((r as { flags?: string }).flags ?? '').trim() + const type = r.type ?? '' + rows.push(detail ? [flags || 'r---', r.uid, r.name, type] : ['rec', r.uid, r.name]) + } + return formatTable(headers, rows) +} + +async function runLs(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listFolder', cmd) + if (cap) return cap + await v.sync() + + const detail = hasOpt(parsed.opts, 'detail') || hasOpt(parsed.opts, 'list') || hasOpt(parsed.opts, 'l') + const foldersOnly = hasOpt(parsed.opts, 'folders') || hasOpt(parsed.opts, 'f') + const recordsOnly = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') + const target = lsPath(parsed) + + const listOpts = { + detail, + showFolders: recordsOnly ? false : true, + showRecords: foldersOnly ? false : true, + } + + if (!target) { + const result = await v.listFolder!({ ...listOpts }) + return { code: 0, out: formatLs(result, detail), err: '' } + } + + if (!v.changeDirectory || !v.getCurrentFolderUid) { + return { code: 1, out: '', err: `${cmd}: host lacks navigation capabilities.\n` } + } + + const originalUid = v.getCurrentFolderUid() + let resolvedUid: string | null + try { + const cd = await v.changeDirectory(target) + resolvedUid = cd.folderUid + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } + } + try { + const result = await v.listFolder!({ folderUid: resolvedUid ?? null, ...listOpts }) + return { code: 0, out: formatLs(result, detail), err: '' } + } finally { + if (resolvedUid !== originalUid) { + try { + await v.changeDirectory(originalUid ?? '/') + } catch { + /* best-effort */ + } + } + } +} + +async function runCd(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const target = parsed.positional[0] + if (!target) return { code: 1, out: '', err: `${cmd}: missing folder path. Usage: ${cmd} \n` } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'changeDirectory', cmd) + if (cap) return cap + try { + const res = await v.changeDirectory!(target) + return { code: 0, out: `${res.name}\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } + } +} + +async function runTree(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'tree', cmd) + if (cap) return cap + await v.sync() + const folderPath = parsed.positional[0] + const out = await v.tree!(folderPath ? { folderPath, showRecords: true } : { showRecords: true }) + return { code: 0, out: out.endsWith('\n') ? out : out + '\n', err: '' } +} + +async function runMkdir(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const target = parsed.positional[0] + if (!target) { + return { code: 1, out: '', err: `${cmd}: missing path. Usage: ${cmd} [-sf]\n` } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'mkdir', cmd) + if (cap) return cap + const cwd = v.getWorkingFolderDisplayName?.() ?? 'My Vault' + const shared = + hasOpt(parsed.opts, 'shared-folder') || + hasOpt(parsed.opts, 'sf') || + hasOpt(parsed.opts, 'shared') + try { + const res = await v.mkdir!(target, { sharedFolder: shared }) + if (!res.success) { + return { code: 1, out: '', err: `${cmd} [in ${cwd}]: ${res.message ?? 'failed'}\n` } + } + return { code: 0, out: `${res.folderUid}\t${target} (in ${cwd})\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target} [in ${cwd}]`, e) } + } +} + +const lsHelp: CliCommandDefinition['help'] = { + title: 'ls — list folder contents (Keeper Commander)', + synopsis: 'usage: ls [-l] [-f] [-r] [pattern]', + description: ' Lists records and subfolders in the current folder, or in PATH if given.', + options: ` -l, --list Detailed list (flags, types). + -f, --folders Folders only. + -r, --records Records only. + --help, -h Show this help.`, + examples: ' ls\n ls "Marketing"\n ls -l', + seeAlso: ' cd, tree, get', +} + +export const lsCommand: CliCommandDefinition = { + name: 'ls', + order: 11, + description: 'List folder contents (current folder or PATH).', + usage: 'ls [PATH] [-l|--list] [-f|--folders] [-r|--records]', + flagOptions: ['-l', '--list', '-f', '--folders', '-r', '--records', '--detail'], + help: lsHelp, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(lsCommand), err: '' } + try { + return await runLs(host, parsed, 'ls') + } catch (e) { + return { code: 1, out: '', err: host.formatError('ls', e) } + } + }, +} + +export const cdCommand: CliCommandDefinition = { + name: 'cd', + order: 12, + description: 'Change current folder.', + usage: 'cd ', + help: { + title: 'cd — change current folder (Keeper Commander)', + synopsis: 'usage: cd ', + description: ' PATH is a slash-separated folder name/UID sequence, or `/` for vault root.', + examples: ' cd Marketing\n cd ..\n cd /', + seeAlso: ' ls, tree', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(cdCommand), err: '' } + try { + return await runCd(host, parsed, 'cd') + } catch (e) { + return { code: 1, out: '', err: host.formatError('cd', e) } + } + }, +} + +export const treeCommand: CliCommandDefinition = { + name: 'tree', + order: 13, + description: 'Display the folder structure.', + usage: 'tree [PATH]', + help: { + title: 'tree — folder structure (Keeper Commander)', + synopsis: 'usage: tree [folder]', + description: + ' Renders an ASCII tree from PATH or the vault root. Each node is tagged [folder], [shared folder], or [record].', + seeAlso: ' ls, cd', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(treeCommand), err: '' } + try { + return await runTree(host, parsed, 'tree') + } catch (e) { + return { code: 1, out: '', err: host.formatError('tree', e) } + } + }, +} + +export const mkdirCommand: CliCommandDefinition = { + name: 'mkdir', + order: 14, + description: 'Create a folder.', + usage: 'mkdir [-sf|--shared-folder]', + flagOptions: ['-sf', '--shared-folder', '--shared'], + help: { + title: 'mkdir — create folder (Keeper Commander)', + synopsis: 'usage: mkdir [-sf]', + description: ' Creates a user folder under the current folder. -sf creates a shared folder.', + options: ' -sf, --shared-folder Create a shared folder.', + examples: ' mkdir Drafts\n mkdir TeamShare -sf', + seeAlso: ' cd, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(mkdirCommand), err: '' } + try { + return await runMkdir(host, parsed, 'mkdir') + } catch (e) { + return { code: 1, out: '', err: host.formatError('mkdir', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/help.ts b/KeeperSdk/src/cli/commands/help.ts new file mode 100644 index 00000000..17bae201 --- /dev/null +++ b/KeeperSdk/src/cli/commands/help.ts @@ -0,0 +1,76 @@ +import type { CliCommandDefinition, KeeperCliHost } from '../types' +import { wantsCliHelp } from '../parse' +import { + formatAllCommandsSummary, + formatDetailedHelpForCommand, + formatShortCommandSummary, + getDetailedHelpPageForRegistry, +} from '../help' +import { isAuthCliCommand, listCliCommandsForLoginState } from '../access' +import { getCliCommand } from '../registry' + +export const helpCommand: CliCommandDefinition = { + name: 'help', + order: 0, + description: 'Show all commands, or full docs for one command (same as COMMAND --help).', + usage: 'help [command] (see also: help --help)', + help: { + title: 'help — show commands or short syntax for one command', + synopsis: ' help [COMMAND]', + description: ` Without arguments, lists commands for the current session. + When not logged in, only sign-in commands are listed (login, restore-session, …). + After login, lists vault commands as well. + + With COMMAND, prints usage for that command (sign-in commands only when logged out).`, + options: ' None. This command does not take GNU-style flags.', + seeAlso: ' Each command’s --help output.', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(helpCommand), err: '' } + } + if (parsed.opts.size > 0) { + return { code: 1, out: '', err: 'help: unknown option (try `help --help`)\n' } + } + const loggedIn = host.getVault().isLoggedIn + const visible = listCliCommandsForLoginState(loggedIn) + const args = parsed.positional + if (args.length === 0) { + if (loggedIn) { + return { code: 0, out: formatAllCommandsSummary(visible), err: '' } + } + return { + code: 0, + out: formatAllCommandsSummary(visible, { + header: 'Not logged in — sign-in commands:\n\n', + footer: + '\nRun `login` or `restore-session` to open the vault.\n' + + 'After login, run `help` again for vault commands (get, ls, cd, …).\n', + }), + err: '', + } + } + if (args.length > 1) { + return { code: 1, out: '', err: 'Usage: help [command]\n' } + } + const name = args[0] + if (!loggedIn && !isAuthCliCommand(name)) { + return { + code: 1, + out: '', + err: + `help: "${name}" requires a logged-in session. ` + + 'Run `help` for sign-in commands (login, restore-session).\n', + } + } + const long = getDetailedHelpPageForRegistry(visible, name) + if (long) { + return { code: 0, out: long, err: '' } + } + const def = getCliCommand(name) + if (!def) { + return { code: 1, out: '', err: `help: unknown command: ${name}\n` } + } + return { code: 0, out: formatShortCommandSummary(def), err: '' } + }, +} diff --git a/KeeperSdk/src/cli/commands/login.ts b/KeeperSdk/src/cli/commands/login.ts new file mode 100644 index 00000000..b4e2a710 --- /dev/null +++ b/KeeperSdk/src/cli/commands/login.ts @@ -0,0 +1,178 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { utf8ToBase64Url } from '../utils' + +const LOGIN_ALLOWED = new Set([ + 'username', + 'user', + 'session-token', + 'token', + 'st', + 'session-token-plain', +]) + +export async function runLoginCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { + const opts = parsed?.opts ?? new Map() + if (parsed && wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(loginCommand), err: '' } + } + if (parsed) { + for (const secretFlag of ['password', 'pass', 'pwd'] as const) { + if (opts.has(secretFlag)) { + return { + code: 1, + out: '', + err: + 'login: do not pass --password on the command line (it is logged and visible). ' + + 'Use KEEPER_PASSWORD for automation, or run `login --username …` in the shell and enter the password when prompted (masked).\n', + } + } + } + const bad = rejectUnknownOptions(parsed, LOGIN_ALLOWED, 'login') + if (bad) return bad + } + + const username = getOpt(opts, 'username', 'user') ?? host.envString('KEEPER_USERNAME') + const passwordEnv = host.envString('KEEPER_PASSWORD') + const sessionRaw = getOpt(opts, 'session-token', 'token', 'st') ?? host.envString('KEEPER_SESSION_TOKEN') + const sessionPlain = parsed && hasOpt(opts, 'session-token-plain') + + if (parsed) { + const stPlainVal = opts.get('session-token-plain') + if (stPlainVal !== undefined && stPlainVal !== true) { + return { + code: 1, + out: '', + err: 'login: --session-token-plain is a boolean flag (no value)\n', + } + } + } + + if (!username) { + return { + code: 1, + out: '', + err: 'login: provide --username or KEEPER_USERNAME.\n', + } + } + + const sessionTrimmed = typeof sessionRaw === 'string' ? sessionRaw.trim() : '' + if (sessionTrimmed.length > 0) { + return loginWithSessionToken(host, username, sessionTrimmed, { plainToken: !!sessionPlain }) + } + + if (!passwordEnv) { + return { + code: 1, + needPassword: true, + loginUsername: username, + out: '', + err: '', + } + } + + return loginWithCredentials(host, username, passwordEnv) +} + +export async function loginWithCredentials( + host: KeeperCliHost, + username: string, + password: string +): Promise { + try { + const v = host.getVault() + if (v.isLoggedIn) { + await v.logout() + } + await v.login(username, password) + await v.sync() + return { code: 0, out: `keeper: logged in as ${username}.\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +export async function loginWithSessionToken( + host: KeeperCliHost, + username: string, + sessionToken: string, + options?: { plainToken?: boolean } +): Promise { + let token = sessionToken.trim() + if (options?.plainToken && token.length > 0) { + token = utf8ToBase64Url(token) + } + try { + const v = host.getVault() + if (v.isLoggedIn) { + await v.logout() + } + await v.loginWithSessionToken(username, token) + await v.sync() + return { code: 0, out: `keeper: logged in as ${username} (session token).\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +/** Pass-through if logged in; auto-login when `KEEPER_USERNAME` is set; otherwise "not logged in". */ +export async function ensureLoggedIn(host: KeeperCliHost): Promise { + if (host.getVault().isLoggedIn) { + return { code: 0, out: '', err: '' } + } + if (host.envString('KEEPER_USERNAME')) { + return runLoginCommand(host, { positional: [], opts: new Map() }) + } + return { code: 1, out: '', err: 'not logged in\n' } +} + +export const loginCommand: CliCommandDefinition = { + name: 'login', + order: 10, + description: + 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN). Password never on CLI line.', + usage: + 'login [--username|--user ] [--session-token|--token|--st ] [--session-token-plain] [--help|-h]', + flagOptions: [ + '--user', + '--username', + '--session-token', + '--token', + '--st', + '--session-token-plain', + ], + allowedOptions: LOGIN_ALLOWED, + help: { + title: 'login — authenticate to Keeper (vault session)', + synopsis: ` login [--username|--user EMAIL_OR_NAME] + login [--username|--user U] [--session-token|--token|--st TOKEN] + login [--username|--user U] [--session-token TOKEN] [--session-token-plain]`, + description: ` Establishes a Keeper session. + + Username comes from --username / --user or KEEPER_USERNAME. + + Password MUST NOT appear on the CLI line (logging, proxies, browser history). + Automation: set KEEPER_PASSWORD in the environment when embedding in Node. + Web shell: run login with only a username; the UI prompts for a masked password + and sends it through the login transport, not in "line". + + Session token login: pass the token on the command line or via + KEEPER_SESSION_TOKEN (sensitive — same caveats as any secret on argv). + + --session-token-plain treats the value as plain UTF-8 and encodes base64url + before login (same idea as the session_token_login example). + + Device registration: session token login requires deviceToken + privateKey for + this host in session storage (e.g. ~/.keeper/config.json) or a prior password + login in this shell.`, + options: ` --username, --user Account identifier (often email). + --session-token, --token, --st Session token string (or use KEEPER_SESSION_TOKEN). + --session-token-plain Treat --session-token value as plain UTF-8 and encode base64url.`, + environment: ` KEEPER_USERNAME Default username if not passed on the command line. + KEEPER_PASSWORD Password for non-interactive login (no session token). + KEEPER_SESSION_TOKEN Session token when not passed as a flag. + KEEPER_HOST Optional vault host / region (also: keeper-host attribute).`, + }, + run: (host, parsed) => runLoginCommand(host, parsed), +} diff --git a/KeeperSdk/src/cli/commands/logout.ts b/KeeperSdk/src/cli/commands/logout.ts new file mode 100644 index 00000000..aa70924a --- /dev/null +++ b/KeeperSdk/src/cli/commands/logout.ts @@ -0,0 +1,39 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' + +export async function runLogoutCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { + if (parsed && wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(logoutCommand), err: '' } + } + if (parsed && parsed.opts.size > 0) { + return { code: 1, out: '', err: 'logout: no options (try: logout --help)\n' } + } + if (parsed && parsed.positional.length > 0) { + return { code: 1, out: '', err: 'Usage: logout\n' } + } + try { + const v = host.getVault() + if (!v.isLoggedIn) { + return { code: 0, out: 'keeper: already logged out.\n', err: '' } + } + await v.logout() + return { code: 0, out: 'keeper: logged out.\n', err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +export const logoutCommand: CliCommandDefinition = { + name: 'logout', + order: 200, + description: 'Log out of the current Keeper session.', + usage: 'logout [--help|-h]', + help: { + title: 'logout — end the current Keeper session', + synopsis: ' logout', + description: ' Ends the current session if one exists.', + options: ' None.', + }, + run: (host, parsed) => runLogoutCommand(host, parsed), +} diff --git a/KeeperSdk/src/cli/commands/restoreSession.ts b/KeeperSdk/src/cli/commands/restoreSession.ts new file mode 100644 index 00000000..7e416d59 --- /dev/null +++ b/KeeperSdk/src/cli/commands/restoreSession.ts @@ -0,0 +1,211 @@ +import type { CliCommandDefinition, KeeperCliHost, ParsedCli } from '../types' +import { + resolveSessionRestorePayload, + validateSessionRestoreInput, + type SessionRestoreInput, +} from '../../auth/sessionRestore' +import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { runVaultSync } from './sync' + +/** Flags allowed to follow `--from-json ` on the same line. */ +export const RESTORE_SESSION_TRAILING_OPTS = [ + 'sync', + 'account-uid', + 'client-key', + 'data-key', + 'ecc-private-key', + 'ecc-public-key', + 'message-session-uid', + 'private-key', + 'session-token', + 'st', + 'session-token-type', + 'username', + 'user', + 'user-type', + 'sso-logout-url', + 'sso-session-id', + 'enterprise-public-key', + 'enterprise-ecc-public-key', +] as const + +const RESTORE_ALLOWED = new Set([ + 'sync', + 'from-json', + 'account-uid', + 'client-key', + 'data-key', + 'ecc-private-key', + 'ecc-public-key', + 'message-session-uid', + 'private-key', + 'session-token', + 'st', + 'session-token-type', + 'username', + 'user', + 'user-type', + 'sso-logout-url', + 'sso-session-id', + 'enterprise-public-key', + 'enterprise-ecc-public-key', +]) + +const ENV_PREFIX = 'RESTORE_SESSION_' + +const FIELD_ENV: Record = { + ACCOUNT_UID: 'accountUid', + CLIENT_KEY: 'clientKey', + DATA_KEY: 'dataKey', + ECC_PRIVATE_KEY: 'eccPrivateKey', + ECC_PUBLIC_KEY: 'eccPublicKey', + MESSAGE_SESSION_UID: 'messageSessionUid', + PRIVATE_KEY: 'privateKey', + SESSION_TOKEN: 'sessionToken', + SESSION_TOKEN_TYPE: 'sessionTokenType', + USERNAME: 'username', + USER_TYPE: 'userType', + SSO_LOGOUT_URL: 'ssoLogoutUrl', + SSO_SESSION_ID: 'ssoSessionId', + ENTERPRISE_PUBLIC_KEY: 'enterprisePublicKey', + ENTERPRISE_ECC_PUBLIC_KEY: 'enterpriseEccPublicKey', +} + +const OPT_TO_FIELD: Record = { + 'account-uid': 'accountUid', + 'client-key': 'clientKey', + 'data-key': 'dataKey', + 'ecc-private-key': 'eccPrivateKey', + 'ecc-public-key': 'eccPublicKey', + 'message-session-uid': 'messageSessionUid', + 'private-key': 'privateKey', + 'session-token': 'sessionToken', + st: 'sessionToken', + 'session-token-type': 'sessionTokenType', + username: 'username', + user: 'username', + 'user-type': 'userType', + 'sso-logout-url': 'ssoLogoutUrl', + 'sso-session-id': 'ssoSessionId', + 'enterprise-public-key': 'enterprisePublicKey', + 'enterprise-ecc-public-key': 'enterpriseEccPublicKey', +} + +function envField(host: KeeperCliHost, key: keyof SessionRestoreInput): string | undefined { + const envKey = Object.entries(FIELD_ENV).find(([, v]) => v === key)?.[0] + return envKey ? host.envString(`${ENV_PREFIX}${envKey}`) : undefined +} + +function buildInputFromFlags(host: KeeperCliHost, parsed: ParsedCli): SessionRestoreInput { + const partial: Partial = {} + + for (const [opt, field] of Object.entries(OPT_TO_FIELD)) { + const fromFlag = getOpt(parsed.opts, opt) + if (fromFlag !== undefined) { + ;(partial as Record)[field] = fromFlag + continue + } + const fromEnv = envField(host, field) + if (fromEnv !== undefined) { + ;(partial as Record)[field] = fromEnv + } + } + + return validateSessionRestoreInput(partial) +} + +export const restoreSessionCommand: CliCommandDefinition = { + name: 'restore-session', + order: 12, + description: + 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', + usage: + 'restore-session --from-json FILE|JSON [--sync] OR restore-session --session-token … (see --help)', + flagOptions: [ + '--sync', + '--from-json', + '--account-uid', + '--client-key', + '--data-key', + '--ecc-private-key', + '--ecc-public-key', + '--message-session-uid', + '--private-key', + '--session-token', + '--session-token-type', + '--username', + '--user-type', + '--sso-logout-url', + '--sso-session-id', + '--enterprise-public-key', + '--enterprise-ecc-public-key', + ], + allowedOptions: RESTORE_ALLOWED, + help: { + title: 'restore-session — restore SessionParams from extension / vault export', + synopsis: ` restore-session --from-json session.json + restore-session --session-token TOKEN --username U --account-uid B64 …`, + description: ` Loads a full SessionParams snapshot and resumes the session (same path as + the browser extension after login). Use this when you have accountUid, + clientKey, dataKey, keys, sessionToken, username, etc. from extension storage + — deviceToken/device private key are not part of this payload. + + Provide parameters either as one JSON object (--from-json) or as flags / env. + Binary fields are base64 or base64url.`, + options: ` --from-json Inline JSON (object or JSON-stringified object), or a file path + The entire remainder of the command line is passed to JSON.parse (then file read if needed). + --account-uid, --client-key, --data-key, --ecc-private-key, --ecc-public-key + --message-session-uid, --private-key + --session-token, --st Session token string (as stored; often base64url) + --session-token-type Numeric SessionTokenType enum + --username, --user + --user-type 0=normal, 1=cloud_sso, 2=onsite_sso (or string names) + --sso-logout-url, --sso-session-id + --enterprise-public-key, --enterprise-ecc-public-key (optional) + --sync Run syncDown after restoring the session`, + environment: ` RESTORE_SESSION_JSON Same as --from-json + RESTORE_SESSION_ACCOUNT_UID Per-field overrides (see --help flags) + RESTORE_SESSION_SESSION_TOKEN + … (RESTORE_SESSION_ for each field above)`, + note: ' sessionToken expires; region must match keeper-host / KEEPER_HOST.', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(restoreSessionCommand), err: '' } + } + const bad = rejectUnknownOptions(parsed, RESTORE_ALLOWED, 'restore-session') + if (bad) return bad + if (parsed.positional.length > 0) { + return { code: 1, out: '', err: 'restore-session: unexpected positional arguments\n' } + } + + try { + let input: SessionRestoreInput + const jsonRaw = getOpt(parsed.opts, 'from-json') ?? host.envString('RESTORE_SESSION_JSON') + if (jsonRaw) { + const readFile = + host.readTextFile ?? + (typeof document === 'undefined' + ? async (path: string) => (await import('fs/promises')).readFile(path, 'utf8') + : undefined) + input = await resolveSessionRestorePayload(jsonRaw, readFile) + } else { + input = buildInputFromFlags(host, parsed) + } + + await host.getVault().restoreSession(input) + let out = `keeper: session restored for ${input.username}.\n` + if (hasOpt(parsed.opts, 'sync')) { + const syncResult = await runVaultSync(host) + if (syncResult.code !== 0) { + return syncResult + } + out += syncResult.out + } + return { code: 0, out, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('restore-session', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/sync.ts b/KeeperSdk/src/cli/commands/sync.ts new file mode 100644 index 00000000..72d71f67 --- /dev/null +++ b/KeeperSdk/src/cli/commands/sync.ts @@ -0,0 +1,61 @@ +import type { SyncResult } from '@keeper-security/keeperapi' +import type { CliCommandDefinition, CliResult, KeeperCliHost } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureLoggedIn } from './login' + +function formatSyncSummary(result: SyncResult): string { + const lines = [`keeper: sync complete for ${result.username}.`, ` pages: ${result.pageCount}`] + if (result.totalTime) lines.push(` total: ${result.totalTime}`) + if (result.networkTime) lines.push(` network: ${result.networkTime}`) + const counts = result.counts ?? {} + const parts = Object.entries(counts) + .filter(([, n]) => typeof n === 'number' && n > 0) + .map(([k, n]) => `${k}=${n}`) + if (parts.length) lines.push(` counts: ${parts.join(', ')}`) + if (result.error) lines.push(` warning: ${result.error}`) + return lines.join('\n') + '\n' +} + +/** Download vault data via keeperapi syncDown (KeeperVault.sync). */ +export async function runVaultSync(host: KeeperCliHost): Promise { + const v = host.getVault() + if (!v.isLoggedIn) { + const login = await ensureLoggedIn(host) + if (login.code !== 0) return login + } + const result = await v.sync() + return { code: 0, out: formatSyncSummary(result), err: '' } +} + +export const syncCommand: CliCommandDefinition = { + name: 'sync', + order: 20, + aliases: ['syncdown', 'sync-down', 'd'], + description: 'Download / refresh vault data from Keeper (syncDown).', + usage: 'sync [--help|-h]', + help: { + title: 'sync — download vault data (syncDown)', + synopsis: ' sync', + description: ` Pulls records, folders, and related vault data into local storage. + Requires an active session (login or restore-session).`, + options: ' --help, -h Show this help.', + seeAlso: ' restore-session --sync, list, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(syncCommand), err: '' } + } + if (parsed.opts.size > 0) { + return { code: 1, out: '', err: 'sync: unknown option (try: sync --help)\n' } + } + if (parsed.positional.length > 0) { + return { code: 1, out: '', err: 'sync: unexpected arguments (try: sync --help)\n' } + } + try { + return await runVaultSync(host) + } catch (e) { + return { code: 1, out: '', err: host.formatError('sync', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/vault.ts b/KeeperSdk/src/cli/commands/vault.ts new file mode 100644 index 00000000..4bdaf979 --- /dev/null +++ b/KeeperSdk/src/cli/commands/vault.ts @@ -0,0 +1,56 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' + +async function runSummary(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'getSummary', 'vault summary') + if (cap) return cap + await v.sync!() + const summary = v.getSummary!() + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(summary, null, 2) + '\n', err: '' } + } + const lines = [ + `records: ${summary.recordCount}`, + `shared_folders: ${summary.sharedFolderCount}`, + `teams: ${summary.teamCount}`, + `folders: ${summary.folderCount}`, + ] + return { code: 0, out: lines.join('\n') + '\n', err: '' } +} + +export const vaultCommand: CliCommandDefinition = { + name: 'vault', + order: 25, + description: 'Vault summary counts (records, folders, shared folders).', + usage: 'vault summary [--json] [--help|-h]', + subcommands: ['summary'], + flagOptions: ['--json'], + help: { + title: 'vault — vault-wide statistics', + synopsis: ' vault summary [--json]', + description: ' Runs sync, then prints counts from the local vault cache.', + arguments: ' summary Print record, shared folder, and user-folder counts.', + options: ' --json Emit JSON.\n --help, -h Show this help.', + examples: ' vault summary\n vault summary --json', + seeAlso: ' sync, list, tree, whoami', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(vaultCommand), err: '' } + } + const sub = parsed.positional[0]?.toLowerCase() ?? 'summary' + if (sub !== 'summary') { + return { code: 1, out: '', err: 'Usage: vault summary\n' } + } + try { + return await runSummary(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('vault summary', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/dispatch.ts b/KeeperSdk/src/cli/dispatch.ts new file mode 100644 index 00000000..ba49e697 --- /dev/null +++ b/KeeperSdk/src/cli/dispatch.ts @@ -0,0 +1,52 @@ +import type { CliResult, KeeperCliHost, ParsedCli } from './types' +import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' +import { extractFromJsonFlagValue } from './jsonArg' +import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' +import { formatDetailedHelpForCommand } from './help' +import { isAuthCliCommand } from './access' +import { getCliCommand } from './registry' + +const NOT_LOGGED_IN_ERR = + 'Not logged in. Run `login` or `restore-session` (see `help`).\n' + +export async function dispatchKeeperCli( + commandName: string, + args: string[], + host: KeeperCliHost, + preParsed?: ParsedCli +): Promise { + const def = getCliCommand(commandName) + if (!def) { + return { code: 1, out: '', err: `Unknown command: ${commandName}\n` } + } + if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { + return { code: 1, out: '', err: NOT_LOGGED_IN_ERR } + } + const parsed = preParsed ?? parseCliArgs(args) + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(def), err: '' } + } + return def.run(host, parsed) +} + +export async function dispatchCliLine(line: string, host: KeeperCliHost): Promise { + const trimmed = line.trim() + if (!trimmed) { + return { code: 0, out: '', err: '' } + } + const tokens = tokenizeArguments(trimmed) + const name = tokens[0]?.toLowerCase() + if (!name) { + return { code: 0, out: '', err: '' } + } + const args = tokens.slice(1) + let preParsed: ParsedCli | undefined + if (name === 'restore-session') { + const json = extractFromJsonFlagValue(trimmed, 'from-json', RESTORE_SESSION_TRAILING_OPTS) + if (json) { + preParsed = parseCliArgs(args) + preParsed.opts.set('from-json', json) + } + } + return dispatchKeeperCli(name, args, host, preParsed) +} diff --git a/KeeperSdk/src/cli/help.ts b/KeeperSdk/src/cli/help.ts new file mode 100644 index 00000000..40773a4f --- /dev/null +++ b/KeeperSdk/src/cli/help.ts @@ -0,0 +1,82 @@ +import type { CliCommandDefinition, CliHelpDoc } from './types' + +const SECTION_ORDER: (keyof CliHelpDoc)[] = [ + 'synopsis', + 'description', + 'arguments', + 'options', + 'environment', + 'examples', + 'seeAlso', + 'note', +] + +const SECTION_LABELS: Partial> = { + synopsis: 'SYNOPSIS', + description: 'DESCRIPTION', + arguments: 'ARGUMENTS', + options: 'OPTIONS', + environment: 'ENVIRONMENT', + examples: 'EXAMPLES', + seeAlso: 'SEE ALSO', + note: 'NOTE', +} + +export function formatDetailedHelp(doc: CliHelpDoc): string { + const parts: string[] = [doc.title.trim()] + for (const key of SECTION_ORDER) { + const body = doc[key] + if (typeof body !== 'string' || !body.trim()) continue + const label = SECTION_LABELS[key] + if (label) { + parts.push('') + parts.push(label) + } + parts.push(body.trim()) + } + return `${parts.join('\n')}\n` +} + +export function formatDetailedHelpForCommand(def: CliCommandDefinition): string { + return formatDetailedHelp(def.help) +} + +export function getDetailedHelpPageForRegistry( + commands: Iterable, + name: string +): string | null { + const key = name.toLowerCase() + for (const def of commands) { + if (def.name === key) return formatDetailedHelpForCommand(def) + if (def.aliases?.some((a) => a.toLowerCase() === key)) { + return formatDetailedHelpForCommand(def) + } + } + return null +} + +export type CommandsSummaryOptions = { + header?: string + footer?: string +} + +export function formatAllCommandsSummary( + commands: readonly CliCommandDefinition[], + options?: CommandsSummaryOptions +): string { + const sorted = [...commands].sort((a, b) => a.name.localeCompare(b.name)) + const w = Math.max(...sorted.map((c) => c.name.length), 8) + let out = options?.header ?? 'Supported commands:\n\n' + if (!out.endsWith('\n\n')) { + out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n` + } + for (const c of sorted) { + out += ` ${c.name.padEnd(w)} ${c.description}\n` + } + out += options?.footer ?? '\nRun ` --help` (or `-h`) for details on a specific command.\n' + return out +} + +export function formatShortCommandSummary(def: CliCommandDefinition): string { + return `${def.name} — ${def.description}\n Usage: ${def.usage}\n` +} diff --git a/KeeperSdk/src/cli/index.ts b/KeeperSdk/src/cli/index.ts new file mode 100644 index 00000000..a3c67c51 --- /dev/null +++ b/KeeperSdk/src/cli/index.ts @@ -0,0 +1,109 @@ +import { registerBuiltinCliCommands } from './builtinCommands' +import { registerCliAlias } from './registry' + +let registryInitialized = false + +/** Register built-in Keeper CLI commands (idempotent). */ +export function ensureKeeperCliRegistry(): void { + if (registryInitialized) return + registryInitialized = true + registerBuiltinCliCommands() + registerCliAlias('?', 'help') +} + +ensureKeeperCliRegistry() + +export type { + CliResult, + ParsedCli, + CliHelpDoc, + CliCommandDefinition, + KeeperCliHost, + KeeperCliVault, +} from './types' + +export { + tokenizeArguments, + parseCliArgs, + hasOpt, + getOpt, + wantsCliHelp, + rejectUnknownOptions, +} from './parse' + +export { + formatDetailedHelp, + formatDetailedHelpForCommand, + formatAllCommandsSummary, + formatShortCommandSummary, +} from './help' +import { getDetailedHelpPageForRegistry } from './help' +import { listCliCommands } from './registry' + +export function getDetailedHelpPage(name: string): string | null { + ensureKeeperCliRegistry() + return getDetailedHelpPageForRegistry(listCliCommands(), name) +} + +export { + registerCliCommand, + registerCliAlias, + resolveCliCommandName, + getCliCommand, + listCliCommands, + listCliCommandNames, + listDocumentedCommands, + clearCliRegistry, +} from './registry' + +export { + AUTH_CLI_COMMAND_NAMES, + isAuthCliCommand, + filterCliCommandsForLoginState, + listCliCommandsForLoginState, + listCliCommandNamesForLoginState, +} from './access' + +export { dispatchKeeperCli, dispatchCliLine } from './dispatch' + +export { KeeperCliParser, createKeeperCliParser } from './parser' +export type { KeeperCliParserOptions } from './parser' + +export { + runLoginCommand, + loginWithCredentials, + loginWithSessionToken, + ensureLoggedIn, + loginCommand, +} from './commands/login' + +export { runLogoutCommand, logoutCommand } from './commands/logout' +export { vaultCommand } from './commands/vault' +export { helpCommand } from './commands/help' +export { restoreSessionCommand } from './commands/restoreSession' +export { syncCommand, runVaultSync } from './commands/sync' + +export { getKeeperCliPromptPrefix } from './prompt' +export { BUILTIN_CLI_COMMANDS, registerBuiltinCliCommands } from './builtinCommands' +export { + getCommand, + executeGet, + listCommand, + searchCommand, + listSfCommand, + whoamiCommand, + lsCommand, + cdCommand, + treeCommand, + mkdirCommand, +} from './commander' + +export { utf8ToBase64Url, recordUid } from './utils' + +export type { SessionRestoreInput } from '../auth/sessionRestore' +export { + toSessionParams, + validateSessionRestoreInput, + sessionRestoreFromJson, + resolveSessionRestorePayload, +} from '../auth/sessionRestore' diff --git a/KeeperSdk/src/cli/jsonArg.ts b/KeeperSdk/src/cli/jsonArg.ts new file mode 100644 index 00000000..6a11c1bc --- /dev/null +++ b/KeeperSdk/src/cli/jsonArg.ts @@ -0,0 +1,30 @@ +/** + * Everything after `--from-json` on the command line (trimmed). No tokenization — callers use JSON.parse. + * Trailing flags such as `--sync` are stripped via {@link stripTrailingCliFlags}. + */ +export function extractFromJsonFlagValue( + line: string, + flag = 'from-json', + trailingFlags: readonly string[] = [] +): string | null { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const flagRe = new RegExp(`(?:^|\\s)--${escaped}(?:\\s*=\\s*|\\s+)`, 'i') + const m = line.match(flagRe) + if (!m || m.index === undefined) return null + const rest = line.slice(m.index + m[0].length).trim() + if (!rest) return null + return stripTrailingCliFlags(rest, trailingFlags) +} + +/** Remove trailing ` --flag` tokens (e.g. `--sync` after a file path or JSON blob). */ +export function stripTrailingCliFlags(value: string, flagNames: readonly string[]): string { + if (flagNames.length === 0) return value.trim() + let s = value.trim() + const parts = flagNames.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + const alt = parts.join('|') + const tailFlag = new RegExp(`\\s+--(?:${alt})(?:\\s*=\\s*(?:"[^"]*"|\\S+))?\\s*$`, 'i') + while (tailFlag.test(s)) { + s = s.replace(tailFlag, '').trim() + } + return s +} diff --git a/KeeperSdk/src/cli/parse.ts b/KeeperSdk/src/cli/parse.ts new file mode 100644 index 00000000..73f9ef15 --- /dev/null +++ b/KeeperSdk/src/cli/parse.ts @@ -0,0 +1,173 @@ +import type { CliResult, ParsedCli } from './types' + +const isWhitespace = (ch: string) => /\s/.test(ch) + +/** Split a command line into tokens; respects double quotes and `\\` escapes. */ +export function tokenizeArguments(args: string): string[] { + const out: string[] = [] + const sb: string[] = [] + let pos = 0 + let inQuote = false + let escape = false + + const flush = () => { + if (sb.length > 0) { + out.push(sb.join('')) + sb.length = 0 + } + } + + while (pos < args.length) { + const ch = args[pos] + if (escape) { + escape = false + sb.push(ch) + pos++ + continue + } + if (inQuote) { + if (ch === '\\') { + escape = true + pos++ + continue + } + if (ch === '"') { + inQuote = false + pos++ + continue + } + sb.push(ch) + pos++ + continue + } + switch (ch) { + case '\\': + escape = true + pos++ + break + case '"': + inQuote = true + pos++ + break + default: + if (isWhitespace(ch)) { + flush() + pos++ + } else { + sb.push(ch) + pos++ + } + } + } + flush() + return out +} + +function setBool(opts: Map, k: string): void { + opts.set(k.toLowerCase(), true) +} + +function setStr(opts: Map, k: string, v: string): void { + opts.set(k.toLowerCase(), v) +} + +/** Parse argv-style tokens after the command name. */ +export function parseCliArgs(tokens: string[]): ParsedCli { + const positional: string[] = [] + const opts = new Map() + + let i = 0 + while (i < tokens.length) { + const t = tokens[i] + if (t === '--') { + positional.push(...tokens.slice(i + 1)) + break + } + if (t === '-' || !t.startsWith('-')) { + positional.push(t) + i++ + continue + } + + if (t.startsWith('--')) { + const body = t.slice(2) + if (!body) { + positional.push(t) + i++ + continue + } + const eq = body.indexOf('=') + if (eq >= 0) { + setStr(opts, body.slice(0, eq), body.slice(eq + 1)) + i++ + continue + } + const name = body + const next = tokens[i + 1] + if (next && next !== '--' && !next.startsWith('-')) { + setStr(opts, name, next) + i += 2 + continue + } + setBool(opts, name) + i++ + continue + } + + const rest = t.slice(1) + if (!rest) { + positional.push(t) + i++ + continue + } + if (/^[A-Za-z]$/.test(rest)) { + setBool(opts, rest) + i++ + continue + } + if (/^[A-Za-z]+$/.test(rest)) { + for (const ch of rest) setBool(opts, ch) + i++ + continue + } + + positional.push(t) + i++ + } + + return { positional, opts } +} + +export function hasOpt(opts: Map, ...names: string[]): boolean { + for (const n of names) { + const v = opts.get(n.toLowerCase()) + if (v === true) return true + } + return false +} + +export function getOpt(opts: Map, ...names: string[]): string | undefined { + for (const n of names) { + const v = opts.get(n.toLowerCase()) + if (v !== undefined && v !== true) return v + } + return undefined +} + +export function wantsCliHelp(parsed: ParsedCli): boolean { + return hasOpt(parsed.opts, 'help', 'h') +} + +export function rejectUnknownOptions( + parsed: ParsedCli, + allowed: ReadonlySet, + commandName: string +): CliResult | null { + for (const k of parsed.opts.keys()) { + if (k === 'help' || k === 'h') continue + if (!allowed.has(k)) { + return { code: 1, out: '', err: `${commandName}: unknown option --${k}\n` } + } + } + return null +} diff --git a/KeeperSdk/src/cli/parser.ts b/KeeperSdk/src/cli/parser.ts new file mode 100644 index 00000000..2be6be47 --- /dev/null +++ b/KeeperSdk/src/cli/parser.ts @@ -0,0 +1,172 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from './types' +import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' +import { extractFromJsonFlagValue } from './jsonArg' +import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' +import { AUTH_CLI_COMMAND_NAMES, isAuthCliCommand } from './access' +import { BUILTIN_CLI_COMMANDS } from './builtinCommands' +import { formatAllCommandsSummary, formatDetailedHelpForCommand, formatShortCommandSummary } from './help' + +const NOT_LOGGED_IN_ERR = + 'Not logged in. Run `login` or `restore-session` (see `help`).\n' + +export type KeeperCliParserOptions = { + prog?: string + description?: string + epilog?: string +} + +/** Self-contained CLI parser. Register commands, then `parse()` dispatches a line. */ +export class KeeperCliParser { + private readonly prog: string + private readonly description: string + private readonly epilog?: string + private readonly commands = new Map() + private readonly aliases = new Map() + + constructor(options: KeeperCliParserOptions = {}) { + this.prog = options.prog ?? 'keeper' + this.description = options.description ?? '' + this.epilog = options.epilog + } + + addCommand(def: CliCommandDefinition): this { + const key = def.name.toLowerCase() + this.commands.set(key, def) + if (def.aliases) { + for (const alias of def.aliases) { + this.aliases.set(alias.toLowerCase(), key) + } + } + return this + } + + addCommands(defs: Iterable): this { + for (const def of defs) this.addCommand(def) + return this + } + + list(): CliCommandDefinition[] { + return [...this.commands.values()].sort((a, b) => { + const oa = a.order ?? 500 + const ob = b.order ?? 500 + if (oa !== ob) return oa - ob + return a.name.localeCompare(b.name) + }) + } + + listNames(): string[] { + return this.list().map((c) => c.name) + } + + resolve(name: string): CliCommandDefinition | undefined { + const key = name.toLowerCase() + if (this.commands.has(key)) return this.commands.get(key) + const target = this.aliases.get(key) + return target ? this.commands.get(target) : undefined + } + + formatHelp(host?: KeeperCliHost): string { + const loggedIn = host?.getVault().isLoggedIn ?? true + const commands = loggedIn + ? this.list() + : this.list().filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) + const header = this.description ? `${this.prog} — ${this.description}\n\n` : '' + const body = loggedIn + ? formatAllCommandsSummary(commands) + : formatAllCommandsSummary(commands, { + header: 'Not logged in — sign-in commands:\n\n', + footer: + '\nRun `login` or `restore-session` to open the vault.\n' + + 'After login, run `help` again for vault commands (get, ls, cd, …).\n', + }) + const footer = this.epilog ? `\n${this.epilog}\n` : '' + return header + body + footer + } + + formatCommandHelp(name: string): string | null { + const def = this.resolve(name) + return def ? formatDetailedHelpForCommand(def) : null + } + + formatCommandSummary(name: string): string | null { + const def = this.resolve(name) + return def ? formatShortCommandSummary(def) : null + } + + async parse(line: string | readonly string[], host: KeeperCliHost): Promise { + const { tokens, raw } = normalizeInput(line) + if (tokens.length === 0) { + return ok(this.formatHelp(host)) + } + + const first = tokens[0] + const rest = tokens.slice(1) + + if (isHelpToken(first)) { + const sub = rest[0] + if (!sub) return ok(this.formatHelp(host)) + if (!host.getVault().isLoggedIn && !isAuthCliCommand(sub)) { + return err(NOT_LOGGED_IN_ERR) + } + const page = this.formatCommandHelp(sub) + if (page) return ok(page) + return err(`${this.prog}: unknown command: ${sub}\nTry: ${this.prog} --help\n`) + } + + const def = this.resolve(first) + if (!def) { + return err(`${this.prog}: unknown command: ${first}\nTry: ${this.prog} --help\n`) + } + + if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { + return err(NOT_LOGGED_IN_ERR) + } + + let parsed: ParsedCli + if (def.name === 'restore-session') { + const json = extractFromJsonFlagValue(raw, 'from-json', RESTORE_SESSION_TRAILING_OPTS) + parsed = parseCliArgs(rest) + if (json) parsed.opts.set('from-json', json) + } else { + parsed = parseCliArgs(rest) + } + + if (wantsCliHelp(parsed)) { + return ok(formatDetailedHelpForCommand(def)) + } + return def.run(host, parsed) + } +} + +/** Parser pre-loaded with the SDK's built-in commands. */ +export function createKeeperCliParser(options: KeeperCliParserOptions = {}): KeeperCliParser { + const parser = new KeeperCliParser(options) + void loadBuiltinsInto(parser) + return parser +} + +function loadBuiltinsInto(parser: KeeperCliParser): void { + parser.addCommands(BUILTIN_CLI_COMMANDS) +} + +function normalizeInput(line: string | readonly string[]): { tokens: string[]; raw: string } { + if (typeof line === 'string') { + const trimmed = line.trim() + return { tokens: trimmed ? tokenizeArguments(trimmed) : [], raw: trimmed } + } + const tokens = [...line].filter((t) => t.length > 0) + return { tokens, raw: tokens.join(' ') } +} + +function isHelpToken(token: string): boolean { + const t = token.toLowerCase() + return t === '--help' || t === '-h' || t === 'help' +} + +function ok(out: string): CliResult { + return { code: 0, out, err: '' } +} + +function err(message: string): CliResult { + return { code: 1, out: '', err: message } +} diff --git a/KeeperSdk/src/cli/prompt.ts b/KeeperSdk/src/cli/prompt.ts new file mode 100644 index 00000000..0ac8ea99 --- /dev/null +++ b/KeeperSdk/src/cli/prompt.ts @@ -0,0 +1,12 @@ +import type { KeeperCliHost } from './types' + +const NOT_LOGGED_IN_PROMPT = 'Not logged in> ' +const PROMPT_MAX_LEN = 40 + +export function getKeeperCliPromptPrefix(host: KeeperCliHost): string { + const v = host.getVault() + if (!v.isLoggedIn) return NOT_LOGGED_IN_PROMPT + const name = v.getWorkingFolderDisplayName?.() ?? 'My Vault' + const label = name.length > PROMPT_MAX_LEN ? `...${name.slice(-37)}` : name + return `${label}> ` +} diff --git a/KeeperSdk/src/cli/registry.ts b/KeeperSdk/src/cli/registry.ts new file mode 100644 index 00000000..4e541bac --- /dev/null +++ b/KeeperSdk/src/cli/registry.ts @@ -0,0 +1,55 @@ +import type { CliCommandDefinition } from './types' + +const commands = new Map() +const aliases = new Map() + +function normalizeName(name: string): string { + return name.toLowerCase() +} + +export function registerCliCommand(def: CliCommandDefinition): void { + const key = normalizeName(def.name) + commands.set(key, def) + if (def.aliases) { + for (const alias of def.aliases) { + aliases.set(normalizeName(alias), key) + } + } +} + +export function registerCliAlias(alias: string, commandName: string): void { + aliases.set(normalizeName(alias), normalizeName(commandName)) +} + +export function resolveCliCommandName(name: string): string | undefined { + const key = normalizeName(name) + if (commands.has(key)) return key + return aliases.get(key) +} + +export function getCliCommand(name: string): CliCommandDefinition | undefined { + const key = resolveCliCommandName(name) + return key ? commands.get(key) : undefined +} + +export function listCliCommands(): CliCommandDefinition[] { + return [...commands.values()].sort((a, b) => { + const oa = a.order ?? 500 + const ob = b.order ?? 500 + if (oa !== ob) return oa - ob + return a.name.localeCompare(b.name) + }) +} + +export function listCliCommandNames(): readonly string[] { + return listCliCommands().map((c) => c.name) +} + +export function listDocumentedCommands(): readonly string[] { + return listCliCommandNames() +} + +export function clearCliRegistry(): void { + commands.clear() + aliases.clear() +} diff --git a/KeeperSdk/src/cli/table.ts b/KeeperSdk/src/cli/table.ts new file mode 100644 index 00000000..499f206c --- /dev/null +++ b/KeeperSdk/src/cli/table.ts @@ -0,0 +1,8 @@ +/** Fixed-width column formatter. Last column is left unpadded. */ +export function formatTable(headers: string[], rows: string[][]): string { + if (rows.length === 0) return '' + const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) + const fmt = (cells: string[]): string => + cells.map((s, i) => (i === cells.length - 1 ? s : (s ?? '').padEnd(widths[i]))).join(' ') + return [fmt(headers), ...rows.map(fmt)].join('\n') + '\n' +} diff --git a/KeeperSdk/src/cli/types.ts b/KeeperSdk/src/cli/types.ts new file mode 100644 index 00000000..fb6cb6fc --- /dev/null +++ b/KeeperSdk/src/cli/types.ts @@ -0,0 +1,90 @@ +import type { DRecord, DSharedFolder, SyncResult } from '@keeper-security/keeperapi' +import type { SessionRestoreInput } from '../auth/sessionRestore' +import type { ChangeDirectoryResult } from '../folders/changeDirectory' +import type { FolderTreeBuildOptions } from '../folders/folderTree' +import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' +import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' +import type { MkdirOptions } from '../folders/addFolder' +import type { RenameFolderResult } from '../folders/updateFolder' +import type { DeleteFolderResult } from '../folders/deleteFolder' +import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' +import type { RecordShareInfo } from '../sharing/Sharing' +import type { VaultSummary } from '../vault/KeeperVault' + +export type CliResult = { + code: number + out: string + err: string + /** Set when the host UI must prompt for a masked password (never on the CLI line). */ + needPassword?: boolean + loginUsername?: string +} + +export type ParsedCli = { + positional: string[] + opts: Map +} + +/** + * Vault surface for CLI handlers. Methods beyond session/sync/records are optional; + * commands call `ensureCapability` so thin hosts fail with a clear message. + */ +export type KeeperCliVault = { + readonly isLoggedIn: boolean + login(username: string, password: string): Promise + loginWithSessionToken(username: string, sessionToken: string): Promise + logout(): Promise + sync(): Promise + getRecords(): DRecord[] + getSharedFolders(): DSharedFolder[] + restoreSession(input: SessionRestoreInput): Promise + getSummary?: () => VaultSummary + findRecord?: (uidOrTitle: string) => DRecord | undefined + findRecords?: (criteria: string) => DRecord[] + getRecordShareInfo?: (recordUid: string) => Promise + listSharedFolders?: (options?: ListSharedFoldersOptions) => ListSharedFolderRow[] + listFolder?: (options?: ListFolderOptions) => Promise + tree?: (options?: FolderTreeBuildOptions) => Promise + changeDirectory?: (path: string) => Promise + getCurrentFolderUid?: () => string | null + getWorkingFolderDisplayName?: () => string + getFolder?: (uidOrName: string, options?: GetFolderOptions) => Promise + mkdir?: (path: string, options?: MkdirOptions) => Promise<{ folderUid: string; success: boolean; message?: string }> + renameFolder?: (folderPath: string, newName: string) => Promise + rmdir?: (patterns: string[], options?: { force?: boolean }) => Promise +} + +/** Host adapter (browser shell, Node script, tests). `readTextFile` is optional. */ +export type KeeperCliHost = { + getVault(): KeeperCliVault + envString(name: string): string | undefined + formatError(context: string, err: unknown): string + readTextFile?: (path: string) => Promise + getAccountUsername?: () => Promise +} + +export type CliHelpDoc = { + title: string + synopsis?: string + description?: string + arguments?: string + options?: string + environment?: string + examples?: string + seeAlso?: string + note?: string +} + +export type CliCommandDefinition = { + name: string + order?: number + description: string + usage: string + aliases?: readonly string[] + subcommands?: readonly string[] + flagOptions?: readonly string[] + /** When set, options outside this set are rejected (`--help` / `-h` always allowed). */ + allowedOptions?: ReadonlySet + help: CliHelpDoc + run: (host: KeeperCliHost, parsed: ParsedCli) => Promise +} diff --git a/KeeperSdk/src/cli/utils.ts b/KeeperSdk/src/cli/utils.ts new file mode 100644 index 00000000..5db5dae4 --- /dev/null +++ b/KeeperSdk/src/cli/utils.ts @@ -0,0 +1,18 @@ +export function utf8ToBase64Url(s: string): string { + const bytes = new TextEncoder().encode(s) + let b64: string + if (typeof Buffer !== 'undefined') { + b64 = Buffer.from(bytes).toString('base64') + } else { + let bin = '' + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]!) + } + b64 = globalThis.btoa(bin) + } + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export function recordUid(rec: { uid?: string }): string { + return rec.uid || '(unknown uid)' +} diff --git a/KeeperSdk/src/cli/vaultSurface.ts b/KeeperSdk/src/cli/vaultSurface.ts new file mode 100644 index 00000000..cb3e43f8 --- /dev/null +++ b/KeeperSdk/src/cli/vaultSurface.ts @@ -0,0 +1,18 @@ +/** Shared footer for vault-related command help (SDK APIs not yet exposed as CLI). */ +export const KEEPER_VAULT_SURFACE = ` +KeeperVault (JavaScript SDK) — operations available in code (not all exposed as CLI yet): + + Session: login, loginWithSessionToken, logout, resumeSession, sync, disconnect + Records: getRecords, findRecord, findRecords, getRecordByUid, getRecordsByType, + addRecord, updateRecord, deleteRecord, moveRecord, getRecordHistory, + printRecords + Sharing: shareRecord, removeRecordShare, getRecordShareInfo + Folders: listFolder, changeDirectory, getFolder, mkdir, addFolder, updateFolder, + renameFolder, deleteFolder, rmdir, tree, getCurrentFolderUid + Shared folders: getSharedFolders, listSharedFolders, shareFolder, … + Metadata: getRecordMetadata, getSummary, … + +Utilities exported from @keeper-security/keeper-sdk-javascript include searchRecords, +formatRecord, getRecordTitle, getRecordPassword, getRecordLogin, shareRecord, … +See the SDK package for full APIs. +`.trim() diff --git a/KeeperSdk/src/folders/folderTree.ts b/KeeperSdk/src/folders/folderTree.ts index ee7ba7cb..c23d1dbe 100644 --- a/KeeperSdk/src/folders/folderTree.ts +++ b/KeeperSdk/src/folders/folderTree.ts @@ -21,7 +21,8 @@ enum TreeItemKind { } const TREE_TAG = { - sharedFolder: '[SHARED]', + folder: '[folder]', + sharedFolder: '[shared folder]', record: '[record]', } as const @@ -136,16 +137,18 @@ async function collectSharedFolderPermissions( } function folderTreeTag( - _userFolder: DUserFolder | undefined, + userFolder: DUserFolder | undefined, sharedFolder: DSharedFolder | undefined, _sharedFolderFolder: DSharedFolderFolder | undefined ): string { - return sharedFolder ? TREE_TAG.sharedFolder : '' + if (sharedFolder) return TREE_TAG.sharedFolder + if (userFolder) return TREE_TAG.folder + return TREE_TAG.folder } function formatTreeNodeName(baseName: string, tag: string, verbose: boolean, uid?: string): string { const name = verbose && uid ? `${baseName} (${uid})` : baseName - return tag ? `${name} ${tag}` : name + return `${name} ${tag}` } function formatTreeRecordName(title: string, verbose: boolean, recordUid?: string): string { From 38c3a86043c404cfdba039575224873bef9e7fce Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 1 Jul 2026 15:46:37 +0530 Subject: [PATCH 20/48] migrated functions and files from sdk to cli repository --- KeeperSdk/README.md | 47 +- KeeperSdk/package-lock.json | 15 +- KeeperSdk/package.json | 2 +- KeeperSdk/src/api.ts | 22 +- KeeperSdk/src/cli/access.ts | 30 - KeeperSdk/src/cli/builtinCommands.ts | 36 -- KeeperSdk/src/cli/commandHelpers.ts | 24 - KeeperSdk/src/cli/commander/get.ts | 42 -- KeeperSdk/src/cli/commander/getCore.ts | 125 ----- KeeperSdk/src/cli/commander/index.ts | 4 - KeeperSdk/src/cli/commander/misc.ts | 187 ------- KeeperSdk/src/cli/commander/nav.ts | 234 -------- KeeperSdk/src/cli/commands/help.ts | 76 --- KeeperSdk/src/cli/commands/login.ts | 178 ------ KeeperSdk/src/cli/commands/logout.ts | 39 -- KeeperSdk/src/cli/commands/restoreSession.ts | 211 ------- KeeperSdk/src/cli/commands/sync.ts | 61 --- KeeperSdk/src/cli/commands/vault.ts | 56 -- KeeperSdk/src/cli/dispatch.ts | 52 -- KeeperSdk/src/cli/help.ts | 82 --- KeeperSdk/src/cli/index.ts | 109 ---- KeeperSdk/src/cli/jsonArg.ts | 30 - KeeperSdk/src/cli/parse.ts | 173 ------ KeeperSdk/src/cli/parser.ts | 172 ------ KeeperSdk/src/cli/prompt.ts | 12 - KeeperSdk/src/cli/registry.ts | 55 -- KeeperSdk/src/cli/table.ts | 8 - KeeperSdk/src/cli/types.ts | 90 --- KeeperSdk/src/cli/utils.ts | 18 - KeeperSdk/src/cli/vaultSurface.ts | 18 - KeeperSdk/src/index.ts | 22 +- KeeperSdk/src/utils/index.ts | 18 + KeeperSdk/src/utils/passwordGenerator.ts | 545 +++++++++++++++++++ KeeperSdk/src/utils/resources/wordlists.ts | 4 + 34 files changed, 624 insertions(+), 2173 deletions(-) delete mode 100644 KeeperSdk/src/cli/access.ts delete mode 100644 KeeperSdk/src/cli/builtinCommands.ts delete mode 100644 KeeperSdk/src/cli/commandHelpers.ts delete mode 100644 KeeperSdk/src/cli/commander/get.ts delete mode 100644 KeeperSdk/src/cli/commander/getCore.ts delete mode 100644 KeeperSdk/src/cli/commander/index.ts delete mode 100644 KeeperSdk/src/cli/commander/misc.ts delete mode 100644 KeeperSdk/src/cli/commander/nav.ts delete mode 100644 KeeperSdk/src/cli/commands/help.ts delete mode 100644 KeeperSdk/src/cli/commands/login.ts delete mode 100644 KeeperSdk/src/cli/commands/logout.ts delete mode 100644 KeeperSdk/src/cli/commands/restoreSession.ts delete mode 100644 KeeperSdk/src/cli/commands/sync.ts delete mode 100644 KeeperSdk/src/cli/commands/vault.ts delete mode 100644 KeeperSdk/src/cli/dispatch.ts delete mode 100644 KeeperSdk/src/cli/help.ts delete mode 100644 KeeperSdk/src/cli/index.ts delete mode 100644 KeeperSdk/src/cli/jsonArg.ts delete mode 100644 KeeperSdk/src/cli/parse.ts delete mode 100644 KeeperSdk/src/cli/parser.ts delete mode 100644 KeeperSdk/src/cli/prompt.ts delete mode 100644 KeeperSdk/src/cli/registry.ts delete mode 100644 KeeperSdk/src/cli/table.ts delete mode 100644 KeeperSdk/src/cli/types.ts delete mode 100644 KeeperSdk/src/cli/utils.ts delete mode 100644 KeeperSdk/src/cli/vaultSurface.ts create mode 100644 KeeperSdk/src/utils/passwordGenerator.ts create mode 100644 KeeperSdk/src/utils/resources/wordlists.ts diff --git a/KeeperSdk/README.md b/KeeperSdk/README.md index c431cfd0..a3d45764 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -1,6 +1,8 @@ # @keeper-security/keeper-sdk-javascript -Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folders, and a Commander-style CLI (`dispatchCliLine`). +Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folders, and enterprise admin APIs. + +> **CLI:** Commander-style shell commands (`dispatchCliLine`, `help`, `get`, `ls`, …) live in [**@keeper-security/keeper-shell-component**](https://www.npmjs.com/package/@keeper-security/keeper-shell-component) (or this monorepo’s `commander-javascript-cli` package). This SDK package is API-only. [![NPM](https://img.shields.io/npm/v/@keeper-security/keeper-sdk-javascript?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeper-sdk-javascript) @@ -42,49 +44,12 @@ console.log(`Loaded ${vault.getRecords().length} records`) - **Folders**: List, get, create, rename, delete, change directory, folder tree - **Shared folders**: List shared folders, share with users, update permissions - **Sharing**: Share and unshare records, check share info -- **Teams / users / roles** (enterprise admin): Available via the SDK API; not exposed as shell commands in this release - -## Built-in shell CLI - -The package includes a Commander-style CLI (`dispatchCliLine`, `createKeeperCliParser`) for auth, records, and folders. - -**Before login:** `help`, `login`, `restore-session` - -**After login:** - -| Area | Commands | -|------|----------| -| Session | `logout`, `sync` (`syncdown`, `sync-down`, `d`), `whoami` | -| Records | `list` (`l`), `search` (`s`), `get` (`g`) | -| Folders | `ls`, `cd`, `tree`, `mkdir`, `list-sf` (`lsf`) | -| Vault info | `vault summary` | - -Every command supports `--help`. Record/folder write operations (`add`, `update`, `delete`, `share`, …) are SDK-only — see the examples below. - -### Finding records and folders - -| Goal | Command | -|------|---------| -| Record by UID (exact) | `get ` | -| Record by title | `get "Gmail Login"` or `search gmail` | -| Text in title/fields | `search ` (all terms must match) | -| Shared folder by UID | `get ` or `list-sf ` | -| Folder by path/UID | `get `, `ls`, `cd`, `tree` | -| All records (table) | `list` or `list --verbose` | -| Account summary | `whoami` or `vault summary` | - -`search` only covers **vault records** (title, fields, UID). It does not search teams or enterprise users — use the SDK API (`vault.viewTeam`, `vault.listTeams`, …) or `examples/sdk_example` scripts for those. - -```typescript -import { dispatchCliLine, type KeeperCliHost } from '@keeper-security/keeper-sdk-javascript' - -await dispatchCliLine('restore-session --from-json session.json', host) -await dispatchCliLine('list', host) -await dispatchCliLine('ls', host) -``` +- **Teams / users / roles** (enterprise admin): Available via the SDK API ## Examples +Shell CLI (`dispatchCliLine`, categorized `help`, record/folder commands) is provided by **@keeper-security/keeper-shell-component** — see that package’s `src/keeper-cli/README.md`. + Runnable SDK scripts are in [`examples/sdk_example`](../examples/sdk_example): ```bash diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 5eff7ccd..fbf97316 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,8 +9,8 @@ "version": "1.1.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "17.2.7", - "asmcrypto.js": "^2.7.2", + "@keeper-security/keeperapi": "18.0.2", + "asmcrypto.js": "^2.3.2", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, @@ -57,9 +57,9 @@ } }, "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==", + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.2.tgz", + "integrity": "sha512-wF6VfvNJhwIXYaL2av5Rs7qgME1srarZjXaZu9qTbUUhP9BrWlJiP0gqRrfXqokHSWVG1ct3mkte+Z6GRr4nYQ==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -67,6 +67,9 @@ "faye-websocket": "^0.11.3", "form-data": "^4.0.4", "node-rsa": "^1.0.8" + }, + "engines": { + "node": ">=24.13.1" } }, "node_modules/@noble/curves": { @@ -141,7 +144,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -571,7 +573,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index e027abbb..9c019eb3 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -24,7 +24,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "17.2.7", + "@keeper-security/keeperapi": "18.0.2", "ts-node": "^10.7.0", "asmcrypto.js": "^2.3.2", "typescript": "^4.6.3" diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts index c784fd9d..f6c339d4 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -36,8 +36,28 @@ export { 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 type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' export { searchRecords, diff --git a/KeeperSdk/src/cli/access.ts b/KeeperSdk/src/cli/access.ts deleted file mode 100644 index 027c0467..00000000 --- a/KeeperSdk/src/cli/access.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CliCommandDefinition } from './types' -import { listCliCommands, resolveCliCommandName } from './registry' - -/** Commands available before a vault session exists. */ -export const AUTH_CLI_COMMAND_NAMES = new Set([ - 'help', - 'login', - 'restore-session', -]) - -export function isAuthCliCommand(name: string): boolean { - const resolved = resolveCliCommandName(name) - return resolved != null && AUTH_CLI_COMMAND_NAMES.has(resolved) -} - -export function filterCliCommandsForLoginState( - commands: readonly CliCommandDefinition[], - loggedIn: boolean -): CliCommandDefinition[] { - if (loggedIn) return [...commands] - return commands.filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) -} - -export function listCliCommandsForLoginState(loggedIn: boolean): CliCommandDefinition[] { - return filterCliCommandsForLoginState(listCliCommands(), loggedIn) -} - -export function listCliCommandNamesForLoginState(loggedIn: boolean): readonly string[] { - return listCliCommandsForLoginState(loggedIn).map((c) => c.name) -} diff --git a/KeeperSdk/src/cli/builtinCommands.ts b/KeeperSdk/src/cli/builtinCommands.ts deleted file mode 100644 index 1cd33332..00000000 --- a/KeeperSdk/src/cli/builtinCommands.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { CliCommandDefinition } from './types' -import { registerCliCommand } from './registry' -import { helpCommand } from './commands/help' -import { loginCommand } from './commands/login' -import { logoutCommand } from './commands/logout' -import { restoreSessionCommand } from './commands/restoreSession' -import { syncCommand } from './commands/sync' -import { vaultCommand } from './commands/vault' -import { getCommand } from './commander/get' -import { cdCommand, lsCommand, mkdirCommand, treeCommand } from './commander/nav' -import { listCommand, listSfCommand, searchCommand, whoamiCommand } from './commander/misc' - -/** Built-in CLI commands (Keeper Commander-style vault shell). */ -export const BUILTIN_CLI_COMMANDS: readonly CliCommandDefinition[] = [ - helpCommand, - loginCommand, - restoreSessionCommand, - syncCommand, - vaultCommand, - getCommand, - listCommand, - lsCommand, - cdCommand, - treeCommand, - mkdirCommand, - searchCommand, - listSfCommand, - whoamiCommand, - logoutCommand, -] - -export function registerBuiltinCliCommands(): void { - for (const def of BUILTIN_CLI_COMMANDS) { - registerCliCommand(def) - } -} diff --git a/KeeperSdk/src/cli/commandHelpers.ts b/KeeperSdk/src/cli/commandHelpers.ts deleted file mode 100644 index e4fa6652..00000000 --- a/KeeperSdk/src/cli/commandHelpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { CliResult, KeeperCliHost, KeeperCliVault } from './types' -import { ensureLoggedIn } from './commands/login' - -export async function ensureSession(host: KeeperCliHost): Promise { - const v = host.getVault() - if (v.isLoggedIn) return null - const r = await ensureLoggedIn(host) - return r.code === 0 ? null : r -} - -export function ensureCapability( - v: KeeperCliVault, - name: K, - context: string -): CliResult | null { - if (typeof v[name] !== 'function') { - return { - code: 1, - out: '', - err: `${context}: this host does not expose KeeperCliVault.${String(name)}.\n`, - } - } - return null -} diff --git a/KeeperSdk/src/cli/commander/get.ts b/KeeperSdk/src/cli/commander/get.ts deleted file mode 100644 index 24fd82c1..00000000 --- a/KeeperSdk/src/cli/commander/get.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { CliCommandDefinition } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { executeGet } from './getCore' - -export const getCommand: CliCommandDefinition = { - name: 'get', - order: 10, - aliases: ['g'], - description: 'Get details of a record or folder by UID or title.', - usage: 'get [--format {detail,json,password,fields}] [--unmask]', - flagOptions: ['--format', '--unmask', '--detail', '--json'], - help: { - title: 'get — record/folder details (Keeper Commander)', - synopsis: 'usage: get [--unmask] [--format {detail,json,password,fields}] uid', - description: - ' Resolves a vault object by UID or title. Records support all output formats; folders and shared folders support detail/json.\n' + - ' Prefer get for exact UID lookup; search is for text in titles and fields.', - arguments: ' uid Record, folder, or shared-folder UID or title.', - options: ` --format {detail,json,password,fields} - detail (default): human-readable output. - json: JSON object. - password: password field only (records). - fields: JSON array of {name, value} (records). - --unmask Show sensitive field values (records). - --help, -h Show this help.`, - examples: ` get "Amazon" - get AbCdEf123456 --format json --unmask - get MyFolderUid --format json`, - seeAlso: ' ls, search, list', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(getCommand), err: '' } - } - try { - return await executeGet(host, parsed, 'get') - } catch (e) { - return { code: 1, out: '', err: host.formatError('get', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commander/getCore.ts b/KeeperSdk/src/cli/commander/getCore.ts deleted file mode 100644 index 6d98bde0..00000000 --- a/KeeperSdk/src/cli/commander/getCore.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { DRecord } from '@keeper-security/keeperapi' -import { - formatRecord, - formatRecordFields, - getRecordPassword, -} from '../../records/RecordUtils' -import type { CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt } from '../parse' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { GetFolderFormat } from '../../folders/getFolder' - -export type GetOutputFormat = 'detail' | 'json' | 'password' | 'fields' - -export function resolveGetFormat(parsed: ParsedCli): GetOutputFormat { - const raw = getOpt(parsed.opts, 'format')?.toLowerCase() - if (raw === 'json' || hasOpt(parsed.opts, 'json')) return 'json' - if (raw === 'password') return 'password' - if (raw === 'fields') return 'fields' - if (raw === 'detail') return 'detail' - return hasOpt(parsed.opts, 'detail') ? 'detail' : 'detail' -} - -export function resolveGetUnmask(parsed: ParsedCli): boolean { - return hasOpt(parsed.opts, 'unmask') -} - -export function getGetTarget(parsed: ParsedCli): string | undefined { - return parsed.positional[0]?.trim() || undefined -} - -async function outputRecord( - host: KeeperCliHost, - record: DRecord, - fmt: GetOutputFormat, - unmask: boolean, - cmd: string -): Promise { - if (fmt === 'password') { - const pw = getRecordPassword(record) - return { code: 0, out: pw ? `${pw}\n` : '', err: pw ? '' : `${cmd}: record has no password field\n` } - } - if (fmt === 'fields') { - return { code: 0, out: JSON.stringify(formatRecordFields(record, unmask), null, 2) + '\n', err: '' } - } - if (fmt === 'json') { - return { code: 0, out: JSON.stringify(record, null, 2) + '\n', err: '' } - } - return { code: 0, out: formatRecord(record, { showDetails: true, unmask }) + '\n', err: '' } -} - -async function tryGetFolder( - host: KeeperCliHost, - target: string, - fmt: GetOutputFormat, - cmd: string -): Promise { - const v = host.getVault() - if (!v.getFolder) return null - try { - const res = await v.getFolder(target, { - format: fmt === 'json' ? GetFolderFormat.JSON : GetFolderFormat.Detail, - }) - if (fmt === 'json') { - const json = (res as { json?: Record }).json ?? res - return { code: 0, out: JSON.stringify(json, null, 2) + '\n', err: '' } - } - const name = 'name' in res ? res.name : target - const uid = - 'folder_uid' in res - ? res.folder_uid - : 'shared_folder_uid' in res - ? res.shared_folder_uid - : target - return { code: 0, out: `${name}\t${uid}\n`, err: '' } - } catch { - return null - } -} - -async function tryGetSharedFolderByUid( - host: KeeperCliHost, - target: string, - fmt: GetOutputFormat, - cmd: string -): Promise { - const v = host.getVault() - const hit = v.getSharedFolders().find((sf) => sf.uid === target) - if (!hit) return null - if (fmt === 'json') { - return { code: 0, out: JSON.stringify(hit, null, 2) + '\n', err: '' } - } - if (fmt === 'password' || fmt === 'fields') { - return { code: 1, out: '', err: `${cmd}: --format ${fmt} applies to records only\n` } - } - return { code: 0, out: `${hit.name ?? '(unnamed)'}\t${hit.uid}\n`, err: '' } -} - -/** Commander-style `get` (record, folder, or shared folder by UID/title). */ -export async function executeGet(host: KeeperCliHost, parsed: ParsedCli, cmd = 'get'): Promise { - const target = getGetTarget(parsed) - if (!target) { - return { code: 1, out: '', err: `${cmd}: UID parameter is required\n` } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const fmt = resolveGetFormat(parsed) - const unmask = resolveGetUnmask(parsed) - - await v.sync() - - const sf = await tryGetSharedFolderByUid(host, target, fmt, cmd) - if (sf) return sf - - const folder = await tryGetFolder(host, target, fmt, cmd) - if (folder) return folder - - const cap = ensureCapability(v, 'findRecord', cmd) - if (cap) return cap - const record = v.findRecord!(target) - if (!record) { - return { code: 1, out: '', err: `${cmd}: cannot find any object matching "${target}"\n` } - } - return outputRecord(host, record, fmt, unmask, cmd) -} diff --git a/KeeperSdk/src/cli/commander/index.ts b/KeeperSdk/src/cli/commander/index.ts deleted file mode 100644 index 6795faac..00000000 --- a/KeeperSdk/src/cli/commander/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { getCommand } from './get' -export { executeGet } from './getCore' -export { lsCommand, cdCommand, treeCommand, mkdirCommand } from './nav' -export { listCommand, searchCommand, listSfCommand, whoamiCommand } from './misc' diff --git a/KeeperSdk/src/cli/commander/misc.ts b/KeeperSdk/src/cli/commander/misc.ts deleted file mode 100644 index 5e855aa5..00000000 --- a/KeeperSdk/src/cli/commander/misc.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { formatTable } from '../table' -import { getRecordTitle } from '../../records/RecordUtils' -import { renderRecordsListTable } from '../../records/listRecordsTable' -import { recordUid } from '../utils' -import { formatSharedFoldersTable, renderSharedFoldersAsciiTable } from '../../sharedFolders/listSharedFolders' - -async function runList(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - await v.sync() - const records = v.getRecords() - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(records, null, 2) + '\n', err: '' } - } - if (records.length === 0) { - return { code: 0, out: '(no records)\n', err: '' } - } - const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') - const out = renderRecordsListTable(records, { verbose }) + '\n' - return { code: 0, out, err: '' } -} - -async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { - const pattern = parsed.positional.join(' ') || getOpt(parsed.opts, 'pattern') - if (!pattern?.trim()) { - return { code: 1, out: '', err: 'search: missing search terms. Usage: search \n' } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'findRecords', 'search') - if (cap) return cap - await v.sync() - const matches = v.findRecords!(pattern) - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(matches, null, 2) + '\n', err: '' } - } - if (matches.length === 0) { - return { code: 0, out: `(no records matched "${pattern}")\n`, err: '' } - } - const rows = matches.map((rec) => [recordUid(rec), getRecordTitle(rec)]) - return { code: 0, out: formatTable(['record_uid', 'title'], rows), err: '' } -} - -async function runListSf(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') - if (cap) return cap - await v.sync() - const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null - const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') - const rows = v.listSharedFolders!({ pattern, verbose, includeDetails: verbose }) - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } - } - if (rows.length === 0) { - return { - code: 0, - out: pattern ? `(no shared folders matched "${pattern}")\n` : '(no shared folders)\n', - err: '', - } - } - const table = formatSharedFoldersTable(rows, { verbose }) - return { code: 0, out: renderSharedFoldersAsciiTable(table) + '\n', err: '' } -} - -async function runWhoami(host: KeeperCliHost): Promise { - const v = host.getVault() - if (!v.isLoggedIn) { - return { code: 1, out: '', err: 'whoami: not logged in\n' } - } - const username = - (await host.getAccountUsername?.()) ?? host.envString('KEEPER_USER') ?? host.envString('KEEPER_USERNAME') - const summary = v.getSummary?.() - const lines = [`username: ${username ?? '(unknown)'}`] - if (summary) { - lines.push( - `records: ${summary.recordCount}`, - `folders: ${summary.folderCount}`, - `shared_folders: ${summary.sharedFolderCount}` - ) - } - return { code: 0, out: lines.join('\n') + '\n', err: '' } -} - -export const listCommand: CliCommandDefinition = { - name: 'list', - order: 14, - aliases: ['l'], - description: 'List all vault records (Commander table).', - usage: 'list [--verbose|-v] [--json]', - flagOptions: ['--json', '--verbose', '-v'], - help: { - title: 'list — all records (Keeper Commander)', - synopsis: 'usage: list [--verbose]', - description: - ' Syncs and prints every record in a Commander-style table: uid, type, title, description, shared, and record category.', - options: ' --verbose, -v Do not truncate long columns (default max width 40).', - seeAlso: ' get, search, ls', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listCommand), err: '' } - try { - return await runList(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('list', e) } - } - }, -} - -export const searchCommand: CliCommandDefinition = { - name: 'search', - order: 15, - aliases: ['s'], - description: 'Search vault records by text.', - usage: 'search [--json]', - flagOptions: ['--json', '--pattern'], - help: { - title: 'search — find records (Keeper Commander)', - synopsis: 'usage: search ', - description: - ' Space-separated terms; all terms must match somewhere in the record (title, fields, or UID).\n' + - ' For exact lookup by UID, use get instead.', - examples: ' search amazon\n search bank account\n get zhJdqy7lb_zIEeCJT7GLlQ', - seeAlso: ' get, ls', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(searchCommand), err: '' } - try { - return await runSearch(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('search', e) } - } - }, -} - -export const listSfCommand: CliCommandDefinition = { - name: 'list-sf', - order: 16, - aliases: ['lsf'], - description: 'List shared folders.', - usage: 'list-sf [pattern] [--verbose] [--json]', - flagOptions: ['--verbose', '-v', '--json', '--pattern'], - help: { - title: 'list-sf — shared folders (Keeper Commander)', - synopsis: 'usage: list-sf [pattern]', - seeAlso: ' ls, get', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listSfCommand), err: '' } - try { - return await runListSf(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('list-sf', e) } - } - }, -} - -export const whoamiCommand: CliCommandDefinition = { - name: 'whoami', - order: 18, - description: 'Display current user and vault counts.', - usage: 'whoami', - help: { - title: 'whoami — current user (Keeper Commander)', - synopsis: 'usage: whoami', - seeAlso: ' login, sync-down', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(whoamiCommand), err: '' } - if (parsed.opts.size > 0 || parsed.positional.length > 0) { - return { code: 1, out: '', err: 'whoami: unexpected arguments\n' } - } - try { - return await runWhoami(host) - } catch (e) { - return { code: 1, out: '', err: host.formatError('whoami', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commander/nav.ts b/KeeperSdk/src/cli/commander/nav.ts deleted file mode 100644 index 0ff0e268..00000000 --- a/KeeperSdk/src/cli/commander/nav.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { formatTable } from '../table' - -function lsPath(parsed: ParsedCli): string | undefined { - return parsed.positional[0] -} - -function formatLs( - result: { - detail: boolean - folders: Array<{ uid: string; name: string }> - records: Array<{ uid: string; name: string; type?: string }> - }, - detail: boolean -): string { - if (result.folders.length + result.records.length === 0) return '(empty)\n' - - const headers = detail ? ['flags', 'uid', 'name', 'type'] : ['kind', 'uid', 'name'] - const rows: string[][] = [] - for (const f of result.folders) { - const flags = ((f as { flags?: string }).flags ?? '').trim() - rows.push(detail ? [flags || 'f---', f.uid, f.name, ''] : ['dir', f.uid, f.name]) - } - for (const r of result.records) { - const flags = ((r as { flags?: string }).flags ?? '').trim() - const type = r.type ?? '' - rows.push(detail ? [flags || 'r---', r.uid, r.name, type] : ['rec', r.uid, r.name]) - } - return formatTable(headers, rows) -} - -async function runLs(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'listFolder', cmd) - if (cap) return cap - await v.sync() - - const detail = hasOpt(parsed.opts, 'detail') || hasOpt(parsed.opts, 'list') || hasOpt(parsed.opts, 'l') - const foldersOnly = hasOpt(parsed.opts, 'folders') || hasOpt(parsed.opts, 'f') - const recordsOnly = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') - const target = lsPath(parsed) - - const listOpts = { - detail, - showFolders: recordsOnly ? false : true, - showRecords: foldersOnly ? false : true, - } - - if (!target) { - const result = await v.listFolder!({ ...listOpts }) - return { code: 0, out: formatLs(result, detail), err: '' } - } - - if (!v.changeDirectory || !v.getCurrentFolderUid) { - return { code: 1, out: '', err: `${cmd}: host lacks navigation capabilities.\n` } - } - - const originalUid = v.getCurrentFolderUid() - let resolvedUid: string | null - try { - const cd = await v.changeDirectory(target) - resolvedUid = cd.folderUid - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } - } - try { - const result = await v.listFolder!({ folderUid: resolvedUid ?? null, ...listOpts }) - return { code: 0, out: formatLs(result, detail), err: '' } - } finally { - if (resolvedUid !== originalUid) { - try { - await v.changeDirectory(originalUid ?? '/') - } catch { - /* best-effort */ - } - } - } -} - -async function runCd(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const target = parsed.positional[0] - if (!target) return { code: 1, out: '', err: `${cmd}: missing folder path. Usage: ${cmd} \n` } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'changeDirectory', cmd) - if (cap) return cap - try { - const res = await v.changeDirectory!(target) - return { code: 0, out: `${res.name}\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } - } -} - -async function runTree(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'tree', cmd) - if (cap) return cap - await v.sync() - const folderPath = parsed.positional[0] - const out = await v.tree!(folderPath ? { folderPath, showRecords: true } : { showRecords: true }) - return { code: 0, out: out.endsWith('\n') ? out : out + '\n', err: '' } -} - -async function runMkdir(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const target = parsed.positional[0] - if (!target) { - return { code: 1, out: '', err: `${cmd}: missing path. Usage: ${cmd} [-sf]\n` } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'mkdir', cmd) - if (cap) return cap - const cwd = v.getWorkingFolderDisplayName?.() ?? 'My Vault' - const shared = - hasOpt(parsed.opts, 'shared-folder') || - hasOpt(parsed.opts, 'sf') || - hasOpt(parsed.opts, 'shared') - try { - const res = await v.mkdir!(target, { sharedFolder: shared }) - if (!res.success) { - return { code: 1, out: '', err: `${cmd} [in ${cwd}]: ${res.message ?? 'failed'}\n` } - } - return { code: 0, out: `${res.folderUid}\t${target} (in ${cwd})\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target} [in ${cwd}]`, e) } - } -} - -const lsHelp: CliCommandDefinition['help'] = { - title: 'ls — list folder contents (Keeper Commander)', - synopsis: 'usage: ls [-l] [-f] [-r] [pattern]', - description: ' Lists records and subfolders in the current folder, or in PATH if given.', - options: ` -l, --list Detailed list (flags, types). - -f, --folders Folders only. - -r, --records Records only. - --help, -h Show this help.`, - examples: ' ls\n ls "Marketing"\n ls -l', - seeAlso: ' cd, tree, get', -} - -export const lsCommand: CliCommandDefinition = { - name: 'ls', - order: 11, - description: 'List folder contents (current folder or PATH).', - usage: 'ls [PATH] [-l|--list] [-f|--folders] [-r|--records]', - flagOptions: ['-l', '--list', '-f', '--folders', '-r', '--records', '--detail'], - help: lsHelp, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(lsCommand), err: '' } - try { - return await runLs(host, parsed, 'ls') - } catch (e) { - return { code: 1, out: '', err: host.formatError('ls', e) } - } - }, -} - -export const cdCommand: CliCommandDefinition = { - name: 'cd', - order: 12, - description: 'Change current folder.', - usage: 'cd ', - help: { - title: 'cd — change current folder (Keeper Commander)', - synopsis: 'usage: cd ', - description: ' PATH is a slash-separated folder name/UID sequence, or `/` for vault root.', - examples: ' cd Marketing\n cd ..\n cd /', - seeAlso: ' ls, tree', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(cdCommand), err: '' } - try { - return await runCd(host, parsed, 'cd') - } catch (e) { - return { code: 1, out: '', err: host.formatError('cd', e) } - } - }, -} - -export const treeCommand: CliCommandDefinition = { - name: 'tree', - order: 13, - description: 'Display the folder structure.', - usage: 'tree [PATH]', - help: { - title: 'tree — folder structure (Keeper Commander)', - synopsis: 'usage: tree [folder]', - description: - ' Renders an ASCII tree from PATH or the vault root. Each node is tagged [folder], [shared folder], or [record].', - seeAlso: ' ls, cd', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(treeCommand), err: '' } - try { - return await runTree(host, parsed, 'tree') - } catch (e) { - return { code: 1, out: '', err: host.formatError('tree', e) } - } - }, -} - -export const mkdirCommand: CliCommandDefinition = { - name: 'mkdir', - order: 14, - description: 'Create a folder.', - usage: 'mkdir [-sf|--shared-folder]', - flagOptions: ['-sf', '--shared-folder', '--shared'], - help: { - title: 'mkdir — create folder (Keeper Commander)', - synopsis: 'usage: mkdir [-sf]', - description: ' Creates a user folder under the current folder. -sf creates a shared folder.', - options: ' -sf, --shared-folder Create a shared folder.', - examples: ' mkdir Drafts\n mkdir TeamShare -sf', - seeAlso: ' cd, ls', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(mkdirCommand), err: '' } - try { - return await runMkdir(host, parsed, 'mkdir') - } catch (e) { - return { code: 1, out: '', err: host.formatError('mkdir', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commands/help.ts b/KeeperSdk/src/cli/commands/help.ts deleted file mode 100644 index 17bae201..00000000 --- a/KeeperSdk/src/cli/commands/help.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { CliCommandDefinition, KeeperCliHost } from '../types' -import { wantsCliHelp } from '../parse' -import { - formatAllCommandsSummary, - formatDetailedHelpForCommand, - formatShortCommandSummary, - getDetailedHelpPageForRegistry, -} from '../help' -import { isAuthCliCommand, listCliCommandsForLoginState } from '../access' -import { getCliCommand } from '../registry' - -export const helpCommand: CliCommandDefinition = { - name: 'help', - order: 0, - description: 'Show all commands, or full docs for one command (same as COMMAND --help).', - usage: 'help [command] (see also: help --help)', - help: { - title: 'help — show commands or short syntax for one command', - synopsis: ' help [COMMAND]', - description: ` Without arguments, lists commands for the current session. - When not logged in, only sign-in commands are listed (login, restore-session, …). - After login, lists vault commands as well. - - With COMMAND, prints usage for that command (sign-in commands only when logged out).`, - options: ' None. This command does not take GNU-style flags.', - seeAlso: ' Each command’s --help output.', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(helpCommand), err: '' } - } - if (parsed.opts.size > 0) { - return { code: 1, out: '', err: 'help: unknown option (try `help --help`)\n' } - } - const loggedIn = host.getVault().isLoggedIn - const visible = listCliCommandsForLoginState(loggedIn) - const args = parsed.positional - if (args.length === 0) { - if (loggedIn) { - return { code: 0, out: formatAllCommandsSummary(visible), err: '' } - } - return { - code: 0, - out: formatAllCommandsSummary(visible, { - header: 'Not logged in — sign-in commands:\n\n', - footer: - '\nRun `login` or `restore-session` to open the vault.\n' + - 'After login, run `help` again for vault commands (get, ls, cd, …).\n', - }), - err: '', - } - } - if (args.length > 1) { - return { code: 1, out: '', err: 'Usage: help [command]\n' } - } - const name = args[0] - if (!loggedIn && !isAuthCliCommand(name)) { - return { - code: 1, - out: '', - err: - `help: "${name}" requires a logged-in session. ` + - 'Run `help` for sign-in commands (login, restore-session).\n', - } - } - const long = getDetailedHelpPageForRegistry(visible, name) - if (long) { - return { code: 0, out: long, err: '' } - } - const def = getCliCommand(name) - if (!def) { - return { code: 1, out: '', err: `help: unknown command: ${name}\n` } - } - return { code: 0, out: formatShortCommandSummary(def), err: '' } - }, -} diff --git a/KeeperSdk/src/cli/commands/login.ts b/KeeperSdk/src/cli/commands/login.ts deleted file mode 100644 index b4e2a710..00000000 --- a/KeeperSdk/src/cli/commands/login.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { utf8ToBase64Url } from '../utils' - -const LOGIN_ALLOWED = new Set([ - 'username', - 'user', - 'session-token', - 'token', - 'st', - 'session-token-plain', -]) - -export async function runLoginCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { - const opts = parsed?.opts ?? new Map() - if (parsed && wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(loginCommand), err: '' } - } - if (parsed) { - for (const secretFlag of ['password', 'pass', 'pwd'] as const) { - if (opts.has(secretFlag)) { - return { - code: 1, - out: '', - err: - 'login: do not pass --password on the command line (it is logged and visible). ' + - 'Use KEEPER_PASSWORD for automation, or run `login --username …` in the shell and enter the password when prompted (masked).\n', - } - } - } - const bad = rejectUnknownOptions(parsed, LOGIN_ALLOWED, 'login') - if (bad) return bad - } - - const username = getOpt(opts, 'username', 'user') ?? host.envString('KEEPER_USERNAME') - const passwordEnv = host.envString('KEEPER_PASSWORD') - const sessionRaw = getOpt(opts, 'session-token', 'token', 'st') ?? host.envString('KEEPER_SESSION_TOKEN') - const sessionPlain = parsed && hasOpt(opts, 'session-token-plain') - - if (parsed) { - const stPlainVal = opts.get('session-token-plain') - if (stPlainVal !== undefined && stPlainVal !== true) { - return { - code: 1, - out: '', - err: 'login: --session-token-plain is a boolean flag (no value)\n', - } - } - } - - if (!username) { - return { - code: 1, - out: '', - err: 'login: provide --username or KEEPER_USERNAME.\n', - } - } - - const sessionTrimmed = typeof sessionRaw === 'string' ? sessionRaw.trim() : '' - if (sessionTrimmed.length > 0) { - return loginWithSessionToken(host, username, sessionTrimmed, { plainToken: !!sessionPlain }) - } - - if (!passwordEnv) { - return { - code: 1, - needPassword: true, - loginUsername: username, - out: '', - err: '', - } - } - - return loginWithCredentials(host, username, passwordEnv) -} - -export async function loginWithCredentials( - host: KeeperCliHost, - username: string, - password: string -): Promise { - try { - const v = host.getVault() - if (v.isLoggedIn) { - await v.logout() - } - await v.login(username, password) - await v.sync() - return { code: 0, out: `keeper: logged in as ${username}.\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -export async function loginWithSessionToken( - host: KeeperCliHost, - username: string, - sessionToken: string, - options?: { plainToken?: boolean } -): Promise { - let token = sessionToken.trim() - if (options?.plainToken && token.length > 0) { - token = utf8ToBase64Url(token) - } - try { - const v = host.getVault() - if (v.isLoggedIn) { - await v.logout() - } - await v.loginWithSessionToken(username, token) - await v.sync() - return { code: 0, out: `keeper: logged in as ${username} (session token).\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -/** Pass-through if logged in; auto-login when `KEEPER_USERNAME` is set; otherwise "not logged in". */ -export async function ensureLoggedIn(host: KeeperCliHost): Promise { - if (host.getVault().isLoggedIn) { - return { code: 0, out: '', err: '' } - } - if (host.envString('KEEPER_USERNAME')) { - return runLoginCommand(host, { positional: [], opts: new Map() }) - } - return { code: 1, out: '', err: 'not logged in\n' } -} - -export const loginCommand: CliCommandDefinition = { - name: 'login', - order: 10, - description: - 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN). Password never on CLI line.', - usage: - 'login [--username|--user ] [--session-token|--token|--st ] [--session-token-plain] [--help|-h]', - flagOptions: [ - '--user', - '--username', - '--session-token', - '--token', - '--st', - '--session-token-plain', - ], - allowedOptions: LOGIN_ALLOWED, - help: { - title: 'login — authenticate to Keeper (vault session)', - synopsis: ` login [--username|--user EMAIL_OR_NAME] - login [--username|--user U] [--session-token|--token|--st TOKEN] - login [--username|--user U] [--session-token TOKEN] [--session-token-plain]`, - description: ` Establishes a Keeper session. - - Username comes from --username / --user or KEEPER_USERNAME. - - Password MUST NOT appear on the CLI line (logging, proxies, browser history). - Automation: set KEEPER_PASSWORD in the environment when embedding in Node. - Web shell: run login with only a username; the UI prompts for a masked password - and sends it through the login transport, not in "line". - - Session token login: pass the token on the command line or via - KEEPER_SESSION_TOKEN (sensitive — same caveats as any secret on argv). - - --session-token-plain treats the value as plain UTF-8 and encodes base64url - before login (same idea as the session_token_login example). - - Device registration: session token login requires deviceToken + privateKey for - this host in session storage (e.g. ~/.keeper/config.json) or a prior password - login in this shell.`, - options: ` --username, --user Account identifier (often email). - --session-token, --token, --st Session token string (or use KEEPER_SESSION_TOKEN). - --session-token-plain Treat --session-token value as plain UTF-8 and encode base64url.`, - environment: ` KEEPER_USERNAME Default username if not passed on the command line. - KEEPER_PASSWORD Password for non-interactive login (no session token). - KEEPER_SESSION_TOKEN Session token when not passed as a flag. - KEEPER_HOST Optional vault host / region (also: keeper-host attribute).`, - }, - run: (host, parsed) => runLoginCommand(host, parsed), -} diff --git a/KeeperSdk/src/cli/commands/logout.ts b/KeeperSdk/src/cli/commands/logout.ts deleted file mode 100644 index aa70924a..00000000 --- a/KeeperSdk/src/cli/commands/logout.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' - -export async function runLogoutCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { - if (parsed && wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(logoutCommand), err: '' } - } - if (parsed && parsed.opts.size > 0) { - return { code: 1, out: '', err: 'logout: no options (try: logout --help)\n' } - } - if (parsed && parsed.positional.length > 0) { - return { code: 1, out: '', err: 'Usage: logout\n' } - } - try { - const v = host.getVault() - if (!v.isLoggedIn) { - return { code: 0, out: 'keeper: already logged out.\n', err: '' } - } - await v.logout() - return { code: 0, out: 'keeper: logged out.\n', err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -export const logoutCommand: CliCommandDefinition = { - name: 'logout', - order: 200, - description: 'Log out of the current Keeper session.', - usage: 'logout [--help|-h]', - help: { - title: 'logout — end the current Keeper session', - synopsis: ' logout', - description: ' Ends the current session if one exists.', - options: ' None.', - }, - run: (host, parsed) => runLogoutCommand(host, parsed), -} diff --git a/KeeperSdk/src/cli/commands/restoreSession.ts b/KeeperSdk/src/cli/commands/restoreSession.ts deleted file mode 100644 index 7e416d59..00000000 --- a/KeeperSdk/src/cli/commands/restoreSession.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { CliCommandDefinition, KeeperCliHost, ParsedCli } from '../types' -import { - resolveSessionRestorePayload, - validateSessionRestoreInput, - type SessionRestoreInput, -} from '../../auth/sessionRestore' -import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { runVaultSync } from './sync' - -/** Flags allowed to follow `--from-json ` on the same line. */ -export const RESTORE_SESSION_TRAILING_OPTS = [ - 'sync', - 'account-uid', - 'client-key', - 'data-key', - 'ecc-private-key', - 'ecc-public-key', - 'message-session-uid', - 'private-key', - 'session-token', - 'st', - 'session-token-type', - 'username', - 'user', - 'user-type', - 'sso-logout-url', - 'sso-session-id', - 'enterprise-public-key', - 'enterprise-ecc-public-key', -] as const - -const RESTORE_ALLOWED = new Set([ - 'sync', - 'from-json', - 'account-uid', - 'client-key', - 'data-key', - 'ecc-private-key', - 'ecc-public-key', - 'message-session-uid', - 'private-key', - 'session-token', - 'st', - 'session-token-type', - 'username', - 'user', - 'user-type', - 'sso-logout-url', - 'sso-session-id', - 'enterprise-public-key', - 'enterprise-ecc-public-key', -]) - -const ENV_PREFIX = 'RESTORE_SESSION_' - -const FIELD_ENV: Record = { - ACCOUNT_UID: 'accountUid', - CLIENT_KEY: 'clientKey', - DATA_KEY: 'dataKey', - ECC_PRIVATE_KEY: 'eccPrivateKey', - ECC_PUBLIC_KEY: 'eccPublicKey', - MESSAGE_SESSION_UID: 'messageSessionUid', - PRIVATE_KEY: 'privateKey', - SESSION_TOKEN: 'sessionToken', - SESSION_TOKEN_TYPE: 'sessionTokenType', - USERNAME: 'username', - USER_TYPE: 'userType', - SSO_LOGOUT_URL: 'ssoLogoutUrl', - SSO_SESSION_ID: 'ssoSessionId', - ENTERPRISE_PUBLIC_KEY: 'enterprisePublicKey', - ENTERPRISE_ECC_PUBLIC_KEY: 'enterpriseEccPublicKey', -} - -const OPT_TO_FIELD: Record = { - 'account-uid': 'accountUid', - 'client-key': 'clientKey', - 'data-key': 'dataKey', - 'ecc-private-key': 'eccPrivateKey', - 'ecc-public-key': 'eccPublicKey', - 'message-session-uid': 'messageSessionUid', - 'private-key': 'privateKey', - 'session-token': 'sessionToken', - st: 'sessionToken', - 'session-token-type': 'sessionTokenType', - username: 'username', - user: 'username', - 'user-type': 'userType', - 'sso-logout-url': 'ssoLogoutUrl', - 'sso-session-id': 'ssoSessionId', - 'enterprise-public-key': 'enterprisePublicKey', - 'enterprise-ecc-public-key': 'enterpriseEccPublicKey', -} - -function envField(host: KeeperCliHost, key: keyof SessionRestoreInput): string | undefined { - const envKey = Object.entries(FIELD_ENV).find(([, v]) => v === key)?.[0] - return envKey ? host.envString(`${ENV_PREFIX}${envKey}`) : undefined -} - -function buildInputFromFlags(host: KeeperCliHost, parsed: ParsedCli): SessionRestoreInput { - const partial: Partial = {} - - for (const [opt, field] of Object.entries(OPT_TO_FIELD)) { - const fromFlag = getOpt(parsed.opts, opt) - if (fromFlag !== undefined) { - ;(partial as Record)[field] = fromFlag - continue - } - const fromEnv = envField(host, field) - if (fromEnv !== undefined) { - ;(partial as Record)[field] = fromEnv - } - } - - return validateSessionRestoreInput(partial) -} - -export const restoreSessionCommand: CliCommandDefinition = { - name: 'restore-session', - order: 12, - description: - 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', - usage: - 'restore-session --from-json FILE|JSON [--sync] OR restore-session --session-token … (see --help)', - flagOptions: [ - '--sync', - '--from-json', - '--account-uid', - '--client-key', - '--data-key', - '--ecc-private-key', - '--ecc-public-key', - '--message-session-uid', - '--private-key', - '--session-token', - '--session-token-type', - '--username', - '--user-type', - '--sso-logout-url', - '--sso-session-id', - '--enterprise-public-key', - '--enterprise-ecc-public-key', - ], - allowedOptions: RESTORE_ALLOWED, - help: { - title: 'restore-session — restore SessionParams from extension / vault export', - synopsis: ` restore-session --from-json session.json - restore-session --session-token TOKEN --username U --account-uid B64 …`, - description: ` Loads a full SessionParams snapshot and resumes the session (same path as - the browser extension after login). Use this when you have accountUid, - clientKey, dataKey, keys, sessionToken, username, etc. from extension storage - — deviceToken/device private key are not part of this payload. - - Provide parameters either as one JSON object (--from-json) or as flags / env. - Binary fields are base64 or base64url.`, - options: ` --from-json Inline JSON (object or JSON-stringified object), or a file path - The entire remainder of the command line is passed to JSON.parse (then file read if needed). - --account-uid, --client-key, --data-key, --ecc-private-key, --ecc-public-key - --message-session-uid, --private-key - --session-token, --st Session token string (as stored; often base64url) - --session-token-type Numeric SessionTokenType enum - --username, --user - --user-type 0=normal, 1=cloud_sso, 2=onsite_sso (or string names) - --sso-logout-url, --sso-session-id - --enterprise-public-key, --enterprise-ecc-public-key (optional) - --sync Run syncDown after restoring the session`, - environment: ` RESTORE_SESSION_JSON Same as --from-json - RESTORE_SESSION_ACCOUNT_UID Per-field overrides (see --help flags) - RESTORE_SESSION_SESSION_TOKEN - … (RESTORE_SESSION_ for each field above)`, - note: ' sessionToken expires; region must match keeper-host / KEEPER_HOST.', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(restoreSessionCommand), err: '' } - } - const bad = rejectUnknownOptions(parsed, RESTORE_ALLOWED, 'restore-session') - if (bad) return bad - if (parsed.positional.length > 0) { - return { code: 1, out: '', err: 'restore-session: unexpected positional arguments\n' } - } - - try { - let input: SessionRestoreInput - const jsonRaw = getOpt(parsed.opts, 'from-json') ?? host.envString('RESTORE_SESSION_JSON') - if (jsonRaw) { - const readFile = - host.readTextFile ?? - (typeof document === 'undefined' - ? async (path: string) => (await import('fs/promises')).readFile(path, 'utf8') - : undefined) - input = await resolveSessionRestorePayload(jsonRaw, readFile) - } else { - input = buildInputFromFlags(host, parsed) - } - - await host.getVault().restoreSession(input) - let out = `keeper: session restored for ${input.username}.\n` - if (hasOpt(parsed.opts, 'sync')) { - const syncResult = await runVaultSync(host) - if (syncResult.code !== 0) { - return syncResult - } - out += syncResult.out - } - return { code: 0, out, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('restore-session', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commands/sync.ts b/KeeperSdk/src/cli/commands/sync.ts deleted file mode 100644 index 72d71f67..00000000 --- a/KeeperSdk/src/cli/commands/sync.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { SyncResult } from '@keeper-security/keeperapi' -import type { CliCommandDefinition, CliResult, KeeperCliHost } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureLoggedIn } from './login' - -function formatSyncSummary(result: SyncResult): string { - const lines = [`keeper: sync complete for ${result.username}.`, ` pages: ${result.pageCount}`] - if (result.totalTime) lines.push(` total: ${result.totalTime}`) - if (result.networkTime) lines.push(` network: ${result.networkTime}`) - const counts = result.counts ?? {} - const parts = Object.entries(counts) - .filter(([, n]) => typeof n === 'number' && n > 0) - .map(([k, n]) => `${k}=${n}`) - if (parts.length) lines.push(` counts: ${parts.join(', ')}`) - if (result.error) lines.push(` warning: ${result.error}`) - return lines.join('\n') + '\n' -} - -/** Download vault data via keeperapi syncDown (KeeperVault.sync). */ -export async function runVaultSync(host: KeeperCliHost): Promise { - const v = host.getVault() - if (!v.isLoggedIn) { - const login = await ensureLoggedIn(host) - if (login.code !== 0) return login - } - const result = await v.sync() - return { code: 0, out: formatSyncSummary(result), err: '' } -} - -export const syncCommand: CliCommandDefinition = { - name: 'sync', - order: 20, - aliases: ['syncdown', 'sync-down', 'd'], - description: 'Download / refresh vault data from Keeper (syncDown).', - usage: 'sync [--help|-h]', - help: { - title: 'sync — download vault data (syncDown)', - synopsis: ' sync', - description: ` Pulls records, folders, and related vault data into local storage. - Requires an active session (login or restore-session).`, - options: ' --help, -h Show this help.', - seeAlso: ' restore-session --sync, list, ls', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(syncCommand), err: '' } - } - if (parsed.opts.size > 0) { - return { code: 1, out: '', err: 'sync: unknown option (try: sync --help)\n' } - } - if (parsed.positional.length > 0) { - return { code: 1, out: '', err: 'sync: unexpected arguments (try: sync --help)\n' } - } - try { - return await runVaultSync(host) - } catch (e) { - return { code: 1, out: '', err: host.formatError('sync', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commands/vault.ts b/KeeperSdk/src/cli/commands/vault.ts deleted file mode 100644 index 4bdaf979..00000000 --- a/KeeperSdk/src/cli/commands/vault.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' - -async function runSummary(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'getSummary', 'vault summary') - if (cap) return cap - await v.sync!() - const summary = v.getSummary!() - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(summary, null, 2) + '\n', err: '' } - } - const lines = [ - `records: ${summary.recordCount}`, - `shared_folders: ${summary.sharedFolderCount}`, - `teams: ${summary.teamCount}`, - `folders: ${summary.folderCount}`, - ] - return { code: 0, out: lines.join('\n') + '\n', err: '' } -} - -export const vaultCommand: CliCommandDefinition = { - name: 'vault', - order: 25, - description: 'Vault summary counts (records, folders, shared folders).', - usage: 'vault summary [--json] [--help|-h]', - subcommands: ['summary'], - flagOptions: ['--json'], - help: { - title: 'vault — vault-wide statistics', - synopsis: ' vault summary [--json]', - description: ' Runs sync, then prints counts from the local vault cache.', - arguments: ' summary Print record, shared folder, and user-folder counts.', - options: ' --json Emit JSON.\n --help, -h Show this help.', - examples: ' vault summary\n vault summary --json', - seeAlso: ' sync, list, tree, whoami', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(vaultCommand), err: '' } - } - const sub = parsed.positional[0]?.toLowerCase() ?? 'summary' - if (sub !== 'summary') { - return { code: 1, out: '', err: 'Usage: vault summary\n' } - } - try { - return await runSummary(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('vault summary', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/dispatch.ts b/KeeperSdk/src/cli/dispatch.ts deleted file mode 100644 index ba49e697..00000000 --- a/KeeperSdk/src/cli/dispatch.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { CliResult, KeeperCliHost, ParsedCli } from './types' -import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' -import { extractFromJsonFlagValue } from './jsonArg' -import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' -import { formatDetailedHelpForCommand } from './help' -import { isAuthCliCommand } from './access' -import { getCliCommand } from './registry' - -const NOT_LOGGED_IN_ERR = - 'Not logged in. Run `login` or `restore-session` (see `help`).\n' - -export async function dispatchKeeperCli( - commandName: string, - args: string[], - host: KeeperCliHost, - preParsed?: ParsedCli -): Promise { - const def = getCliCommand(commandName) - if (!def) { - return { code: 1, out: '', err: `Unknown command: ${commandName}\n` } - } - if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { - return { code: 1, out: '', err: NOT_LOGGED_IN_ERR } - } - const parsed = preParsed ?? parseCliArgs(args) - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(def), err: '' } - } - return def.run(host, parsed) -} - -export async function dispatchCliLine(line: string, host: KeeperCliHost): Promise { - const trimmed = line.trim() - if (!trimmed) { - return { code: 0, out: '', err: '' } - } - const tokens = tokenizeArguments(trimmed) - const name = tokens[0]?.toLowerCase() - if (!name) { - return { code: 0, out: '', err: '' } - } - const args = tokens.slice(1) - let preParsed: ParsedCli | undefined - if (name === 'restore-session') { - const json = extractFromJsonFlagValue(trimmed, 'from-json', RESTORE_SESSION_TRAILING_OPTS) - if (json) { - preParsed = parseCliArgs(args) - preParsed.opts.set('from-json', json) - } - } - return dispatchKeeperCli(name, args, host, preParsed) -} diff --git a/KeeperSdk/src/cli/help.ts b/KeeperSdk/src/cli/help.ts deleted file mode 100644 index 40773a4f..00000000 --- a/KeeperSdk/src/cli/help.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { CliCommandDefinition, CliHelpDoc } from './types' - -const SECTION_ORDER: (keyof CliHelpDoc)[] = [ - 'synopsis', - 'description', - 'arguments', - 'options', - 'environment', - 'examples', - 'seeAlso', - 'note', -] - -const SECTION_LABELS: Partial> = { - synopsis: 'SYNOPSIS', - description: 'DESCRIPTION', - arguments: 'ARGUMENTS', - options: 'OPTIONS', - environment: 'ENVIRONMENT', - examples: 'EXAMPLES', - seeAlso: 'SEE ALSO', - note: 'NOTE', -} - -export function formatDetailedHelp(doc: CliHelpDoc): string { - const parts: string[] = [doc.title.trim()] - for (const key of SECTION_ORDER) { - const body = doc[key] - if (typeof body !== 'string' || !body.trim()) continue - const label = SECTION_LABELS[key] - if (label) { - parts.push('') - parts.push(label) - } - parts.push(body.trim()) - } - return `${parts.join('\n')}\n` -} - -export function formatDetailedHelpForCommand(def: CliCommandDefinition): string { - return formatDetailedHelp(def.help) -} - -export function getDetailedHelpPageForRegistry( - commands: Iterable, - name: string -): string | null { - const key = name.toLowerCase() - for (const def of commands) { - if (def.name === key) return formatDetailedHelpForCommand(def) - if (def.aliases?.some((a) => a.toLowerCase() === key)) { - return formatDetailedHelpForCommand(def) - } - } - return null -} - -export type CommandsSummaryOptions = { - header?: string - footer?: string -} - -export function formatAllCommandsSummary( - commands: readonly CliCommandDefinition[], - options?: CommandsSummaryOptions -): string { - const sorted = [...commands].sort((a, b) => a.name.localeCompare(b.name)) - const w = Math.max(...sorted.map((c) => c.name.length), 8) - let out = options?.header ?? 'Supported commands:\n\n' - if (!out.endsWith('\n\n')) { - out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n` - } - for (const c of sorted) { - out += ` ${c.name.padEnd(w)} ${c.description}\n` - } - out += options?.footer ?? '\nRun ` --help` (or `-h`) for details on a specific command.\n' - return out -} - -export function formatShortCommandSummary(def: CliCommandDefinition): string { - return `${def.name} — ${def.description}\n Usage: ${def.usage}\n` -} diff --git a/KeeperSdk/src/cli/index.ts b/KeeperSdk/src/cli/index.ts deleted file mode 100644 index a3c67c51..00000000 --- a/KeeperSdk/src/cli/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { registerBuiltinCliCommands } from './builtinCommands' -import { registerCliAlias } from './registry' - -let registryInitialized = false - -/** Register built-in Keeper CLI commands (idempotent). */ -export function ensureKeeperCliRegistry(): void { - if (registryInitialized) return - registryInitialized = true - registerBuiltinCliCommands() - registerCliAlias('?', 'help') -} - -ensureKeeperCliRegistry() - -export type { - CliResult, - ParsedCli, - CliHelpDoc, - CliCommandDefinition, - KeeperCliHost, - KeeperCliVault, -} from './types' - -export { - tokenizeArguments, - parseCliArgs, - hasOpt, - getOpt, - wantsCliHelp, - rejectUnknownOptions, -} from './parse' - -export { - formatDetailedHelp, - formatDetailedHelpForCommand, - formatAllCommandsSummary, - formatShortCommandSummary, -} from './help' -import { getDetailedHelpPageForRegistry } from './help' -import { listCliCommands } from './registry' - -export function getDetailedHelpPage(name: string): string | null { - ensureKeeperCliRegistry() - return getDetailedHelpPageForRegistry(listCliCommands(), name) -} - -export { - registerCliCommand, - registerCliAlias, - resolveCliCommandName, - getCliCommand, - listCliCommands, - listCliCommandNames, - listDocumentedCommands, - clearCliRegistry, -} from './registry' - -export { - AUTH_CLI_COMMAND_NAMES, - isAuthCliCommand, - filterCliCommandsForLoginState, - listCliCommandsForLoginState, - listCliCommandNamesForLoginState, -} from './access' - -export { dispatchKeeperCli, dispatchCliLine } from './dispatch' - -export { KeeperCliParser, createKeeperCliParser } from './parser' -export type { KeeperCliParserOptions } from './parser' - -export { - runLoginCommand, - loginWithCredentials, - loginWithSessionToken, - ensureLoggedIn, - loginCommand, -} from './commands/login' - -export { runLogoutCommand, logoutCommand } from './commands/logout' -export { vaultCommand } from './commands/vault' -export { helpCommand } from './commands/help' -export { restoreSessionCommand } from './commands/restoreSession' -export { syncCommand, runVaultSync } from './commands/sync' - -export { getKeeperCliPromptPrefix } from './prompt' -export { BUILTIN_CLI_COMMANDS, registerBuiltinCliCommands } from './builtinCommands' -export { - getCommand, - executeGet, - listCommand, - searchCommand, - listSfCommand, - whoamiCommand, - lsCommand, - cdCommand, - treeCommand, - mkdirCommand, -} from './commander' - -export { utf8ToBase64Url, recordUid } from './utils' - -export type { SessionRestoreInput } from '../auth/sessionRestore' -export { - toSessionParams, - validateSessionRestoreInput, - sessionRestoreFromJson, - resolveSessionRestorePayload, -} from '../auth/sessionRestore' diff --git a/KeeperSdk/src/cli/jsonArg.ts b/KeeperSdk/src/cli/jsonArg.ts deleted file mode 100644 index 6a11c1bc..00000000 --- a/KeeperSdk/src/cli/jsonArg.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Everything after `--from-json` on the command line (trimmed). No tokenization — callers use JSON.parse. - * Trailing flags such as `--sync` are stripped via {@link stripTrailingCliFlags}. - */ -export function extractFromJsonFlagValue( - line: string, - flag = 'from-json', - trailingFlags: readonly string[] = [] -): string | null { - const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const flagRe = new RegExp(`(?:^|\\s)--${escaped}(?:\\s*=\\s*|\\s+)`, 'i') - const m = line.match(flagRe) - if (!m || m.index === undefined) return null - const rest = line.slice(m.index + m[0].length).trim() - if (!rest) return null - return stripTrailingCliFlags(rest, trailingFlags) -} - -/** Remove trailing ` --flag` tokens (e.g. `--sync` after a file path or JSON blob). */ -export function stripTrailingCliFlags(value: string, flagNames: readonly string[]): string { - if (flagNames.length === 0) return value.trim() - let s = value.trim() - const parts = flagNames.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - const alt = parts.join('|') - const tailFlag = new RegExp(`\\s+--(?:${alt})(?:\\s*=\\s*(?:"[^"]*"|\\S+))?\\s*$`, 'i') - while (tailFlag.test(s)) { - s = s.replace(tailFlag, '').trim() - } - return s -} diff --git a/KeeperSdk/src/cli/parse.ts b/KeeperSdk/src/cli/parse.ts deleted file mode 100644 index 73f9ef15..00000000 --- a/KeeperSdk/src/cli/parse.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { CliResult, ParsedCli } from './types' - -const isWhitespace = (ch: string) => /\s/.test(ch) - -/** Split a command line into tokens; respects double quotes and `\\` escapes. */ -export function tokenizeArguments(args: string): string[] { - const out: string[] = [] - const sb: string[] = [] - let pos = 0 - let inQuote = false - let escape = false - - const flush = () => { - if (sb.length > 0) { - out.push(sb.join('')) - sb.length = 0 - } - } - - while (pos < args.length) { - const ch = args[pos] - if (escape) { - escape = false - sb.push(ch) - pos++ - continue - } - if (inQuote) { - if (ch === '\\') { - escape = true - pos++ - continue - } - if (ch === '"') { - inQuote = false - pos++ - continue - } - sb.push(ch) - pos++ - continue - } - switch (ch) { - case '\\': - escape = true - pos++ - break - case '"': - inQuote = true - pos++ - break - default: - if (isWhitespace(ch)) { - flush() - pos++ - } else { - sb.push(ch) - pos++ - } - } - } - flush() - return out -} - -function setBool(opts: Map, k: string): void { - opts.set(k.toLowerCase(), true) -} - -function setStr(opts: Map, k: string, v: string): void { - opts.set(k.toLowerCase(), v) -} - -/** Parse argv-style tokens after the command name. */ -export function parseCliArgs(tokens: string[]): ParsedCli { - const positional: string[] = [] - const opts = new Map() - - let i = 0 - while (i < tokens.length) { - const t = tokens[i] - if (t === '--') { - positional.push(...tokens.slice(i + 1)) - break - } - if (t === '-' || !t.startsWith('-')) { - positional.push(t) - i++ - continue - } - - if (t.startsWith('--')) { - const body = t.slice(2) - if (!body) { - positional.push(t) - i++ - continue - } - const eq = body.indexOf('=') - if (eq >= 0) { - setStr(opts, body.slice(0, eq), body.slice(eq + 1)) - i++ - continue - } - const name = body - const next = tokens[i + 1] - if (next && next !== '--' && !next.startsWith('-')) { - setStr(opts, name, next) - i += 2 - continue - } - setBool(opts, name) - i++ - continue - } - - const rest = t.slice(1) - if (!rest) { - positional.push(t) - i++ - continue - } - if (/^[A-Za-z]$/.test(rest)) { - setBool(opts, rest) - i++ - continue - } - if (/^[A-Za-z]+$/.test(rest)) { - for (const ch of rest) setBool(opts, ch) - i++ - continue - } - - positional.push(t) - i++ - } - - return { positional, opts } -} - -export function hasOpt(opts: Map, ...names: string[]): boolean { - for (const n of names) { - const v = opts.get(n.toLowerCase()) - if (v === true) return true - } - return false -} - -export function getOpt(opts: Map, ...names: string[]): string | undefined { - for (const n of names) { - const v = opts.get(n.toLowerCase()) - if (v !== undefined && v !== true) return v - } - return undefined -} - -export function wantsCliHelp(parsed: ParsedCli): boolean { - return hasOpt(parsed.opts, 'help', 'h') -} - -export function rejectUnknownOptions( - parsed: ParsedCli, - allowed: ReadonlySet, - commandName: string -): CliResult | null { - for (const k of parsed.opts.keys()) { - if (k === 'help' || k === 'h') continue - if (!allowed.has(k)) { - return { code: 1, out: '', err: `${commandName}: unknown option --${k}\n` } - } - } - return null -} diff --git a/KeeperSdk/src/cli/parser.ts b/KeeperSdk/src/cli/parser.ts deleted file mode 100644 index 2be6be47..00000000 --- a/KeeperSdk/src/cli/parser.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from './types' -import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' -import { extractFromJsonFlagValue } from './jsonArg' -import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' -import { AUTH_CLI_COMMAND_NAMES, isAuthCliCommand } from './access' -import { BUILTIN_CLI_COMMANDS } from './builtinCommands' -import { formatAllCommandsSummary, formatDetailedHelpForCommand, formatShortCommandSummary } from './help' - -const NOT_LOGGED_IN_ERR = - 'Not logged in. Run `login` or `restore-session` (see `help`).\n' - -export type KeeperCliParserOptions = { - prog?: string - description?: string - epilog?: string -} - -/** Self-contained CLI parser. Register commands, then `parse()` dispatches a line. */ -export class KeeperCliParser { - private readonly prog: string - private readonly description: string - private readonly epilog?: string - private readonly commands = new Map() - private readonly aliases = new Map() - - constructor(options: KeeperCliParserOptions = {}) { - this.prog = options.prog ?? 'keeper' - this.description = options.description ?? '' - this.epilog = options.epilog - } - - addCommand(def: CliCommandDefinition): this { - const key = def.name.toLowerCase() - this.commands.set(key, def) - if (def.aliases) { - for (const alias of def.aliases) { - this.aliases.set(alias.toLowerCase(), key) - } - } - return this - } - - addCommands(defs: Iterable): this { - for (const def of defs) this.addCommand(def) - return this - } - - list(): CliCommandDefinition[] { - return [...this.commands.values()].sort((a, b) => { - const oa = a.order ?? 500 - const ob = b.order ?? 500 - if (oa !== ob) return oa - ob - return a.name.localeCompare(b.name) - }) - } - - listNames(): string[] { - return this.list().map((c) => c.name) - } - - resolve(name: string): CliCommandDefinition | undefined { - const key = name.toLowerCase() - if (this.commands.has(key)) return this.commands.get(key) - const target = this.aliases.get(key) - return target ? this.commands.get(target) : undefined - } - - formatHelp(host?: KeeperCliHost): string { - const loggedIn = host?.getVault().isLoggedIn ?? true - const commands = loggedIn - ? this.list() - : this.list().filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) - const header = this.description ? `${this.prog} — ${this.description}\n\n` : '' - const body = loggedIn - ? formatAllCommandsSummary(commands) - : formatAllCommandsSummary(commands, { - header: 'Not logged in — sign-in commands:\n\n', - footer: - '\nRun `login` or `restore-session` to open the vault.\n' + - 'After login, run `help` again for vault commands (get, ls, cd, …).\n', - }) - const footer = this.epilog ? `\n${this.epilog}\n` : '' - return header + body + footer - } - - formatCommandHelp(name: string): string | null { - const def = this.resolve(name) - return def ? formatDetailedHelpForCommand(def) : null - } - - formatCommandSummary(name: string): string | null { - const def = this.resolve(name) - return def ? formatShortCommandSummary(def) : null - } - - async parse(line: string | readonly string[], host: KeeperCliHost): Promise { - const { tokens, raw } = normalizeInput(line) - if (tokens.length === 0) { - return ok(this.formatHelp(host)) - } - - const first = tokens[0] - const rest = tokens.slice(1) - - if (isHelpToken(first)) { - const sub = rest[0] - if (!sub) return ok(this.formatHelp(host)) - if (!host.getVault().isLoggedIn && !isAuthCliCommand(sub)) { - return err(NOT_LOGGED_IN_ERR) - } - const page = this.formatCommandHelp(sub) - if (page) return ok(page) - return err(`${this.prog}: unknown command: ${sub}\nTry: ${this.prog} --help\n`) - } - - const def = this.resolve(first) - if (!def) { - return err(`${this.prog}: unknown command: ${first}\nTry: ${this.prog} --help\n`) - } - - if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { - return err(NOT_LOGGED_IN_ERR) - } - - let parsed: ParsedCli - if (def.name === 'restore-session') { - const json = extractFromJsonFlagValue(raw, 'from-json', RESTORE_SESSION_TRAILING_OPTS) - parsed = parseCliArgs(rest) - if (json) parsed.opts.set('from-json', json) - } else { - parsed = parseCliArgs(rest) - } - - if (wantsCliHelp(parsed)) { - return ok(formatDetailedHelpForCommand(def)) - } - return def.run(host, parsed) - } -} - -/** Parser pre-loaded with the SDK's built-in commands. */ -export function createKeeperCliParser(options: KeeperCliParserOptions = {}): KeeperCliParser { - const parser = new KeeperCliParser(options) - void loadBuiltinsInto(parser) - return parser -} - -function loadBuiltinsInto(parser: KeeperCliParser): void { - parser.addCommands(BUILTIN_CLI_COMMANDS) -} - -function normalizeInput(line: string | readonly string[]): { tokens: string[]; raw: string } { - if (typeof line === 'string') { - const trimmed = line.trim() - return { tokens: trimmed ? tokenizeArguments(trimmed) : [], raw: trimmed } - } - const tokens = [...line].filter((t) => t.length > 0) - return { tokens, raw: tokens.join(' ') } -} - -function isHelpToken(token: string): boolean { - const t = token.toLowerCase() - return t === '--help' || t === '-h' || t === 'help' -} - -function ok(out: string): CliResult { - return { code: 0, out, err: '' } -} - -function err(message: string): CliResult { - return { code: 1, out: '', err: message } -} diff --git a/KeeperSdk/src/cli/prompt.ts b/KeeperSdk/src/cli/prompt.ts deleted file mode 100644 index 0ac8ea99..00000000 --- a/KeeperSdk/src/cli/prompt.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { KeeperCliHost } from './types' - -const NOT_LOGGED_IN_PROMPT = 'Not logged in> ' -const PROMPT_MAX_LEN = 40 - -export function getKeeperCliPromptPrefix(host: KeeperCliHost): string { - const v = host.getVault() - if (!v.isLoggedIn) return NOT_LOGGED_IN_PROMPT - const name = v.getWorkingFolderDisplayName?.() ?? 'My Vault' - const label = name.length > PROMPT_MAX_LEN ? `...${name.slice(-37)}` : name - return `${label}> ` -} diff --git a/KeeperSdk/src/cli/registry.ts b/KeeperSdk/src/cli/registry.ts deleted file mode 100644 index 4e541bac..00000000 --- a/KeeperSdk/src/cli/registry.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { CliCommandDefinition } from './types' - -const commands = new Map() -const aliases = new Map() - -function normalizeName(name: string): string { - return name.toLowerCase() -} - -export function registerCliCommand(def: CliCommandDefinition): void { - const key = normalizeName(def.name) - commands.set(key, def) - if (def.aliases) { - for (const alias of def.aliases) { - aliases.set(normalizeName(alias), key) - } - } -} - -export function registerCliAlias(alias: string, commandName: string): void { - aliases.set(normalizeName(alias), normalizeName(commandName)) -} - -export function resolveCliCommandName(name: string): string | undefined { - const key = normalizeName(name) - if (commands.has(key)) return key - return aliases.get(key) -} - -export function getCliCommand(name: string): CliCommandDefinition | undefined { - const key = resolveCliCommandName(name) - return key ? commands.get(key) : undefined -} - -export function listCliCommands(): CliCommandDefinition[] { - return [...commands.values()].sort((a, b) => { - const oa = a.order ?? 500 - const ob = b.order ?? 500 - if (oa !== ob) return oa - ob - return a.name.localeCompare(b.name) - }) -} - -export function listCliCommandNames(): readonly string[] { - return listCliCommands().map((c) => c.name) -} - -export function listDocumentedCommands(): readonly string[] { - return listCliCommandNames() -} - -export function clearCliRegistry(): void { - commands.clear() - aliases.clear() -} diff --git a/KeeperSdk/src/cli/table.ts b/KeeperSdk/src/cli/table.ts deleted file mode 100644 index 499f206c..00000000 --- a/KeeperSdk/src/cli/table.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Fixed-width column formatter. Last column is left unpadded. */ -export function formatTable(headers: string[], rows: string[][]): string { - if (rows.length === 0) return '' - const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) - const fmt = (cells: string[]): string => - cells.map((s, i) => (i === cells.length - 1 ? s : (s ?? '').padEnd(widths[i]))).join(' ') - return [fmt(headers), ...rows.map(fmt)].join('\n') + '\n' -} diff --git a/KeeperSdk/src/cli/types.ts b/KeeperSdk/src/cli/types.ts deleted file mode 100644 index fb6cb6fc..00000000 --- a/KeeperSdk/src/cli/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { DRecord, DSharedFolder, SyncResult } from '@keeper-security/keeperapi' -import type { SessionRestoreInput } from '../auth/sessionRestore' -import type { ChangeDirectoryResult } from '../folders/changeDirectory' -import type { FolderTreeBuildOptions } from '../folders/folderTree' -import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' -import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' -import type { MkdirOptions } from '../folders/addFolder' -import type { RenameFolderResult } from '../folders/updateFolder' -import type { DeleteFolderResult } from '../folders/deleteFolder' -import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' -import type { RecordShareInfo } from '../sharing/Sharing' -import type { VaultSummary } from '../vault/KeeperVault' - -export type CliResult = { - code: number - out: string - err: string - /** Set when the host UI must prompt for a masked password (never on the CLI line). */ - needPassword?: boolean - loginUsername?: string -} - -export type ParsedCli = { - positional: string[] - opts: Map -} - -/** - * Vault surface for CLI handlers. Methods beyond session/sync/records are optional; - * commands call `ensureCapability` so thin hosts fail with a clear message. - */ -export type KeeperCliVault = { - readonly isLoggedIn: boolean - login(username: string, password: string): Promise - loginWithSessionToken(username: string, sessionToken: string): Promise - logout(): Promise - sync(): Promise - getRecords(): DRecord[] - getSharedFolders(): DSharedFolder[] - restoreSession(input: SessionRestoreInput): Promise - getSummary?: () => VaultSummary - findRecord?: (uidOrTitle: string) => DRecord | undefined - findRecords?: (criteria: string) => DRecord[] - getRecordShareInfo?: (recordUid: string) => Promise - listSharedFolders?: (options?: ListSharedFoldersOptions) => ListSharedFolderRow[] - listFolder?: (options?: ListFolderOptions) => Promise - tree?: (options?: FolderTreeBuildOptions) => Promise - changeDirectory?: (path: string) => Promise - getCurrentFolderUid?: () => string | null - getWorkingFolderDisplayName?: () => string - getFolder?: (uidOrName: string, options?: GetFolderOptions) => Promise - mkdir?: (path: string, options?: MkdirOptions) => Promise<{ folderUid: string; success: boolean; message?: string }> - renameFolder?: (folderPath: string, newName: string) => Promise - rmdir?: (patterns: string[], options?: { force?: boolean }) => Promise -} - -/** Host adapter (browser shell, Node script, tests). `readTextFile` is optional. */ -export type KeeperCliHost = { - getVault(): KeeperCliVault - envString(name: string): string | undefined - formatError(context: string, err: unknown): string - readTextFile?: (path: string) => Promise - getAccountUsername?: () => Promise -} - -export type CliHelpDoc = { - title: string - synopsis?: string - description?: string - arguments?: string - options?: string - environment?: string - examples?: string - seeAlso?: string - note?: string -} - -export type CliCommandDefinition = { - name: string - order?: number - description: string - usage: string - aliases?: readonly string[] - subcommands?: readonly string[] - flagOptions?: readonly string[] - /** When set, options outside this set are rejected (`--help` / `-h` always allowed). */ - allowedOptions?: ReadonlySet - help: CliHelpDoc - run: (host: KeeperCliHost, parsed: ParsedCli) => Promise -} diff --git a/KeeperSdk/src/cli/utils.ts b/KeeperSdk/src/cli/utils.ts deleted file mode 100644 index 5db5dae4..00000000 --- a/KeeperSdk/src/cli/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function utf8ToBase64Url(s: string): string { - const bytes = new TextEncoder().encode(s) - let b64: string - if (typeof Buffer !== 'undefined') { - b64 = Buffer.from(bytes).toString('base64') - } else { - let bin = '' - for (let i = 0; i < bytes.length; i++) { - bin += String.fromCharCode(bytes[i]!) - } - b64 = globalThis.btoa(bin) - } - return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - -export function recordUid(rec: { uid?: string }): string { - return rec.uid || '(unknown uid)' -} diff --git a/KeeperSdk/src/cli/vaultSurface.ts b/KeeperSdk/src/cli/vaultSurface.ts deleted file mode 100644 index cb3e43f8..00000000 --- a/KeeperSdk/src/cli/vaultSurface.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** Shared footer for vault-related command help (SDK APIs not yet exposed as CLI). */ -export const KEEPER_VAULT_SURFACE = ` -KeeperVault (JavaScript SDK) — operations available in code (not all exposed as CLI yet): - - Session: login, loginWithSessionToken, logout, resumeSession, sync, disconnect - Records: getRecords, findRecord, findRecords, getRecordByUid, getRecordsByType, - addRecord, updateRecord, deleteRecord, moveRecord, getRecordHistory, - printRecords - Sharing: shareRecord, removeRecordShare, getRecordShareInfo - Folders: listFolder, changeDirectory, getFolder, mkdir, addFolder, updateFolder, - renameFolder, deleteFolder, rmdir, tree, getCurrentFolderUid - Shared folders: getSharedFolders, listSharedFolders, shareFolder, … - Metadata: getRecordMetadata, getSummary, … - -Utilities exported from @keeper-security/keeper-sdk-javascript include searchRecords, -formatRecord, getRecordTitle, getRecordPassword, getRecordLogin, shareRecord, … -See the SDK package for full APIs. -`.trim() diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index df3809cc..774d3f43 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -45,8 +45,28 @@ export { 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 { + ILogger, + Nullable, + Optional, + DeepPartial, + Immutable, + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, } from './utils' -export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' export { searchRecords, diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index c1595539..6f20150a 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -27,3 +27,21 @@ export { 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..26ebcd4a --- /dev/null +++ b/KeeperSdk/src/utils/passwordGenerator.ts @@ -0,0 +1,545 @@ +/** + * 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/resources/wordlists.ts b/KeeperSdk/src/utils/resources/wordlists.ts new file mode 100644 index 00000000..340f887f --- /dev/null +++ b/KeeperSdk/src/utils/resources/wordlists.ts @@ -0,0 +1,4 @@ +// 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"] From 237cce2e141277c23ae47040e0be4c9bd4b5b904 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Fri, 10 Jul 2026 15:06:56 +0530 Subject: [PATCH 21/48] formatting changes after running formatting. --- KeeperSdk/README.md | 26 +- KeeperSdk/package-lock.json | 1250 +-- KeeperSdk/package.json | 68 +- KeeperSdk/src/account/whoamiInfo.ts | 165 +- KeeperSdk/src/api.ts | 590 +- KeeperSdk/src/auth/ConsoleAuthUI.ts | 357 +- KeeperSdk/src/auth/ConsoleLogin.ts | 583 +- KeeperSdk/src/auth/SessionManager.ts | 460 +- KeeperSdk/src/auth/UnavailableAuthUI.ts | 37 +- KeeperSdk/src/auth/config.ts | 58 +- KeeperSdk/src/auth/node/FileConfigLoader.ts | 57 +- KeeperSdk/src/auth/sessionRestore.ts | 356 +- KeeperSdk/src/browser.ts | 8 +- KeeperSdk/src/folders/FolderManager.ts | 421 +- KeeperSdk/src/folders/addFolder.ts | 630 +- KeeperSdk/src/folders/changeDirectory.ts | 398 +- KeeperSdk/src/folders/deleteFolder.ts | 619 +- KeeperSdk/src/folders/folderHelpers.ts | 153 +- KeeperSdk/src/folders/folderTree.ts | 652 +- KeeperSdk/src/folders/getFolder.ts | 406 +- KeeperSdk/src/folders/listFolder.ts | 778 +- KeeperSdk/src/folders/updateFolder.ts | 421 +- KeeperSdk/src/index.ts | 984 +- .../NestedShareFolderManager.ts | 184 +- KeeperSdk/src/nestedShareFolders/getNsf.ts | 926 +- KeeperSdk/src/nestedShareFolders/index.ts | 132 +- .../src/nestedShareFolders/linkNsfRecord.ts | 261 +- KeeperSdk/src/nestedShareFolders/listNsf.ts | 268 +- .../src/nestedShareFolders/nsfConstants.ts | 112 +- .../src/nestedShareFolders/nsfHelpers.ts | 766 +- .../src/nestedShareFolders/removeNsfRecord.ts | 491 +- KeeperSdk/src/platform/browser/platform.ts | 122 +- KeeperSdk/src/platform/index.ts | 20 +- KeeperSdk/src/platform/node/platform.ts | 66 +- KeeperSdk/src/platform/types.ts | 28 +- KeeperSdk/src/records/RecordOperations.ts | 1061 +- KeeperSdk/src/records/RecordUtils.ts | 696 +- KeeperSdk/src/records/Totp.ts | 179 +- KeeperSdk/src/records/listRecordsTable.ts | 150 +- KeeperSdk/src/roles/RoleManager.ts | 289 +- KeeperSdk/src/roles/addRole.ts | 456 +- KeeperSdk/src/roles/deleteRole.ts | 172 +- KeeperSdk/src/roles/index.ts | 120 +- KeeperSdk/src/roles/listRoles.ts | 601 +- KeeperSdk/src/roles/roleTypes.ts | 75 +- KeeperSdk/src/roles/roleUtils.ts | 399 +- KeeperSdk/src/roles/updateRole.ts | 338 +- KeeperSdk/src/roles/viewRole.ts | 804 +- .../src/sharedFolders/SharedFolderManager.ts | 95 +- .../src/sharedFolders/listSharedFolders.ts | 355 +- KeeperSdk/src/sharedFolders/shareFolder.ts | 885 +- KeeperSdk/src/sharing/Sharing.ts | 514 +- KeeperSdk/src/storage/InMemoryStorage.ts | 342 +- KeeperSdk/src/teams/TeamManager.ts | 261 +- KeeperSdk/src/teams/addTeam.ts | 857 +- KeeperSdk/src/teams/deleteTeam.ts | 271 +- KeeperSdk/src/teams/enterpriseData.ts | 1304 ++- KeeperSdk/src/teams/listTeams.ts | 641 +- KeeperSdk/src/teams/teamRole.ts | 237 + KeeperSdk/src/teams/teamUtils.ts | 396 +- KeeperSdk/src/teams/updateTeam.ts | 406 +- KeeperSdk/src/teams/viewTeam.ts | 445 +- KeeperSdk/src/users/UserManager.ts | 344 +- KeeperSdk/src/users/actionUser.ts | 649 +- KeeperSdk/src/users/addUser.ts | 544 +- KeeperSdk/src/users/aliasUser.ts | 249 +- KeeperSdk/src/users/deleteUser.ts | 241 +- KeeperSdk/src/users/listUsers.ts | 871 +- KeeperSdk/src/users/teamUser.ts | 1351 ++- KeeperSdk/src/users/updateUser.ts | 638 +- KeeperSdk/src/users/userTypes.ts | 559 +- KeeperSdk/src/users/viewUser.ts | 579 +- KeeperSdk/src/utils/Logger.ts | 96 +- KeeperSdk/src/utils/constants.ts | 324 +- KeeperSdk/src/utils/errors.ts | 176 +- KeeperSdk/src/utils/guards.ts | 12 +- KeeperSdk/src/utils/index.ts | 106 +- KeeperSdk/src/utils/passwordGenerator.ts | 966 +- KeeperSdk/src/utils/patterns.ts | 35 +- KeeperSdk/src/utils/resources/wordlists.ts | 9830 ++++++++++++++++- KeeperSdk/src/utils/types.ts | 10 +- KeeperSdk/src/vault/KeeperVault.ts | 2086 ++-- KeeperSdk/tsconfig.json | 28 +- keeperapi/src/utils.ts | 11 +- 84 files changed, 29148 insertions(+), 15759 deletions(-) create mode 100644 KeeperSdk/src/teams/teamRole.ts diff --git a/KeeperSdk/README.md b/KeeperSdk/README.md index a3d45764..b4b5ca70 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -14,25 +14,29 @@ npm install @keeper-security/keeper-sdk-javascript ## Entry points -| Environment | Import | Notes | -|-------------|--------|--------| -| Node | `@keeper-security/keeper-sdk-javascript` → `dist/index.js` | `ConsoleAuthUI`, `FileConfigLoader`, full auth | -| Browser | same package → `dist/browser.js` | Platform shim only; use in-memory session + `restore-session` | +| Environment | Import | Notes | +| ----------- | ---------------------------------------------------------- | ------------------------------------------------------------- | +| Node | `@keeper-security/keeper-sdk-javascript` → `dist/index.js` | `ConsoleAuthUI`, `FileConfigLoader`, full auth | +| Browser | same package → `dist/browser.js` | Platform shim only; use in-memory session + `restore-session` | ## Quickstart (Node) ```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(), - sessionStorage: new FileConfigLoader('./keeper-config.json'), -}) + authUI: new ConsoleAuthUI(), + sessionStorage: new FileConfigLoader("./keeper-config.json"), +}); -await vault.login('user@example.com', 'password') -await vault.sync() +await vault.login("user@example.com", "password"); +await vault.sync(); -console.log(`Loaded ${vault.getRecords().length} records`) +console.log(`Loaded ${vault.getRecords().length} records`); ``` ## Supported functionality diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index fbf97316..db1eb418 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -1,629 +1,629 @@ { - "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": "18.0.2", - "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.2", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.2.tgz", - "integrity": "sha512-wF6VfvNJhwIXYaL2av5Rs7qgME1srarZjXaZu9qTbUUhP9BrWlJiP0gqRrfXqokHSWVG1ct3mkte+Z6GRr4nYQ==", - "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.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "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.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/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==", - "license": "MIT", - "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==", - "license": "MIT", - "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.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", - "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.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "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.5", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", - "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", - "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.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@keeper-security/keeper-sdk-javascript", + "version": "1.1.0", + "license": "ISC", + "dependencies": { + "@keeper-security/keeperapi": "18.0.2", + "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.2", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.2.tgz", + "integrity": "sha512-wF6VfvNJhwIXYaL2av5Rs7qgME1srarZjXaZu9qTbUUhP9BrWlJiP0gqRrfXqokHSWVG1ct3mkte+Z6GRr4nYQ==", + "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.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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==", + "license": "MIT", + "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==", + "license": "MIT", + "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.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "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.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "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.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "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 9c019eb3..af068fbc 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -1,36 +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": "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 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.2", - "ts-node": "^10.7.0", - "asmcrypto.js": "^2.3.2", - "typescript": "^4.6.3" - }, - "devDependencies": { - "@types/node": "^25.6.0", - "prettier": "^3.8.1" - } + "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": "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 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.2", + "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 index caedf053..a0dc0210 100644 --- a/KeeperSdk/src/account/whoamiInfo.ts +++ b/KeeperSdk/src/account/whoamiInfo.ts @@ -1,109 +1,122 @@ -import type { AccountSummary } from '@keeper-security/keeperapi' -import type { VaultSummary } from '../vault/KeeperVault' -import { KEEPER_PUBLIC_HOSTS } from '../utils/constants' +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 -} + 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 -} + username: string; + host: string; + accountSummary: AccountSummary.IAccountSummaryElements; + vaultSummary?: VaultSummary; +}; export function normalizeServerHost(host: string): string { - return host.toLowerCase().trim().replace(/^(qa|dev|dev2|local)\./, '') + 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 - } + const normalized = normalizeServerHost(host); + for (const [dataCenter, publicHost] of Object.entries(KEEPER_PUBLIC_HOSTS)) { + if (normalized === publicHost || normalized.endsWith(`.${publicHost}`)) { + return dataCenter; } - return 'US' + } + return "US"; } export function buildWhoamiInfo(input: BuildWhoamiInfoInput): WhoamiInfo { - const license = input.accountSummary.license ?? input.accountSummary.personalLicense ?? {} - const server = normalizeServerHost(input.host) + 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, - } + 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 - } + if (input.vaultSummary) { + info.recordsCount = input.vaultSummary.recordCount; + info.sharedFoldersCount = input.vaultSummary.sharedFolderCount; + info.teamsCount = input.vaultSummary.teamCount; + } - return info + 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' + 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) + 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 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` + 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 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 + if (license.breachWatchFeatureDisable) return false; + return !!license.breachWatchEnabled; } diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts index f6c339d4..7de9e954 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -1,306 +1,386 @@ -export { SessionManager } from './auth/SessionManager' +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' + 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 { 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' + 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' + 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' + 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' + PasswordRecordData, + TypedRecordData, + RecordFieldInput, + NewRecordInput, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + HistoryEntry, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from "./records/RecordOperations"; -export { shareRecord, removeRecordShare, getRecordShareInfo } from './sharing/Sharing' +export { + shareRecord, + removeRecordShare, + getRecordShareInfo, +} from "./sharing/Sharing"; export type { - ShareRecordInput, - ShareRecordResult, - RemoveShareInput, - RemoveShareResult, - RecordShareInfo, - RecordUserPermission, - RecordSharedFolderPermission, -} from './sharing/Sharing' + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, + RecordShareInfo, + RecordUserPermission, + RecordSharedFolderPermission, +} from "./sharing/Sharing"; -export { KeeperVault } from './vault/KeeperVault' -export type { KeeperVaultConfig, VaultSummary } from './vault/KeeperVault' +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 { + buildWhoamiInfo, + normalizeServerHost, + resolveDataCenter, +} from "./account/whoamiInfo"; +export type { WhoamiInfo, BuildWhoamiInfoInput } from "./account/whoamiInfo"; -export type { SessionRestoreInput } from './auth/sessionRestore' +export type { SessionRestoreInput } from "./auth/sessionRestore"; export { - toSessionParams, - validateSessionRestoreInput, - sessionRestoreFromJson, - resolveSessionRestorePayload, -} from './auth/sessionRestore' + toSessionParams, + validateSessionRestoreInput, + sessionRestoreFromJson, + resolveSessionRestorePayload, +} from "./auth/sessionRestore"; -export { getFolder, findFolder, GetFolderFormat } from './folders/getFolder' +export { getFolder, findFolder, GetFolderFormat } from "./folders/getFolder"; export type { - GetFolderOptions, - GetFolderResult, - GetFolderResultFolder, - GetFolderResultSharedFolder, - GetFolderFormatInput, - FoundFolder, -} from './folders/getFolder' + 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' + FolderKind, + ParentFolderKind, + FolderObjectType, + FolderResultStatus, + DeleteResolution, + DeleteObjectType, + folderKindFromString, +} from "./folders/folderHelpers"; +export type { FolderKindOrLiteral } from "./folders/folderHelpers"; -export { listFolder, findFolderUidByNameOrUid, listRootUserFolders } from './folders/listFolder' +export { + 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, + ShareFolderAction, + ShareFolderUserResultStatus, +} from "./sharedFolders/shareFolder"; export type { - ShareFolderActionInput, - ShareFolderInput, - ShareFolderResult, - ShareFolderUserStatus, -} from './sharedFolders/shareFolder' + 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' + 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 { 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 { + 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' + 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' + 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 { FolderManager } from "./folders/FolderManager"; +export type { + AuthProvider, + SharedFolderPermissionsInput, +} from "./folders/FolderManager"; -export { SharedFolderManager } from './sharedFolders/SharedFolderManager' +export { SharedFolderManager } from "./sharedFolders/SharedFolderManager"; export { - listTeams, - formatTeamsTable, - renderTeamsAsciiTable, - formatTeamRestricts, - TeamColumn, - SUPPORTED_TEAM_COLUMNS, - DEFAULT_TEAM_COLUMNS, -} from './teams/listTeams' + listTeams, + formatTeamsTable, + renderTeamsAsciiTable, + formatTeamRestricts, + TeamColumn, + SUPPORTED_TEAM_COLUMNS, + DEFAULT_TEAM_COLUMNS, +} from "./teams/listTeams"; export type { - ListTeamsOptions, - ListTeamRow, - TeamColumnInput, - FormattedTeamsTable, - FormatTeamsTableOptions, -} from './teams/listTeams' + ListTeamsOptions, + ListTeamRow, + TeamColumnInput, + FormattedTeamsTable, + FormatTeamsTableOptions, +} from "./teams/listTeams"; -export { viewTeam, formatTeamView, teamViewTable } from './teams/viewTeam' +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 { - listUsers, - formatUsersTable, - renderUsersAsciiTable, - UserColumn, - SUPPORTED_USER_COLUMNS, - DEFAULT_USER_COLUMNS, -} from './users/listUsers' + changeTeamRoles, + TeamRoleStatus, +} from "./teams/teamRole"; export type { - UserColumnInput, - ListUsersOptions, - ListUserRow, - FormattedUsersTable, - FormatUsersTableOptions, -} from './users/listUsers' + ChangeTeamRolesInput, + TeamRoleResult, + TeamRoleItemResult, +} from "./teams/teamRole"; -export { viewUser, formatUserView, userViewTable } from './users/viewUser' +export { + addUsersToTeams, + removeUsersFromTeams, + formatTeamUserResult, + renderTeamUserAsciiTable, + TeamUserStatus, + TeamUserSkipReason, +} from "./users/teamUser"; export type { - UserView, - FormatUserViewOptions, - FormattedUserViewTable, - UserViewTableRow, -} from './users/userTypes' + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserItemResult, + TeamUserResult, + FormattedTeamUserTable, +} from "./users/userTypes"; export { - listRoles, - formatRolesTable, - renderRolesAsciiTable, - RoleColumn, - SUPPORTED_ROLE_COLUMNS, - DEFAULT_ROLE_COLUMNS, - ALL_COLUMNS_WILDCARD, - viewRole, - formatRoleView, - roleViewTable, -} from './roles' + 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 { - ListRolesOptions, - ListRoleRow, - RoleColumnInput, - FormattedRolesTable, - FormatRolesTableOptions, - RoleView, - RoleTeamInfo, - RoleUserInfo, - RoleManagedNodeInfo, - RoleEnforcementInfo, - FormatRoleViewOptions, - FormattedRoleViewTable, - RoleViewTableRow, -} from './roles' + UserView, + FormatUserViewOptions, + FormattedUserViewTable, + UserViewTableRow, +} from "./users/userTypes"; -export { Auth, KeeperEnvironment, syncDown, Authentication } from '@keeper-security/keeperapi' +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' + 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 e4f8cb89..a0a79965 100644 --- a/KeeperSdk/src/auth/ConsoleAuthUI.ts +++ b/KeeperSdk/src/auth/ConsoleAuthUI.ts @@ -1,169 +1,208 @@ -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' +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 { - const platform = getSdkPlatform() - if (!cancel) { - await platform.delay(timeoutMs) - return - } - await Promise.race([platform.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 = 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() - } + 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 = 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 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 = 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() - } + } + + 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 93f30bba..7ca0a915 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -1,346 +1,377 @@ -import type { AuthUI3 } from '@keeper-security/keeperapi' -import { KeeperVault } from '../vault/KeeperVault' -import { getSdkPlatform } from '../platform' -import type { SdkReadline } from '../platform' +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 type { KeeperJsonConfig } from './config' - -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 -} - -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: SdkReadline | null = null + private rl: SdkReadline | null = null; - private getOrCreate(): SdkReadline { - if (!this.rl) { - this.rl = getSdkPlatform().createReadline(process.stdin, process.stdout) - } - return this.rl + private getOrCreate(): SdkReadline { + if (!this.rl) { + this.rl = getSdkPlatform().createReadline(process.stdin, process.stdout); } - - 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 - } + 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 getSdkPlatform().createFileConfigLoader().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 0370b3ce..30a21b84 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -1,240 +1,274 @@ import { - 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' + 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' + ConfigLoader, + KeeperJsonConfig, + ConfigurationServerConfig, + ConfigurationUser, +} from "./config"; +import { isValidKeeperConfig } from "./config"; export type { - KeeperJsonConfig, - ConfigLoader, - ConfigurationUser, - ConfigurationServerConfig, - ConfigurationDeviceConfig, -} from './config' + 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 -} + 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') { - this.configLoader = getSdkPlatform().createFileConfigLoader(configDirOrLoader) - } else if (configDirOrLoader === undefined) { - this.configLoader = getSdkPlatform().createFileConfigLoader() - } 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 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 get lastUsername(): string | undefined { - return this._lastUsername + 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 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, - } - } + 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 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 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) - } + 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, + ), + }; } - - 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) + 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 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 - } + return null; + } - public async saveSessionParameters(params: Partial): Promise { - this.sessionParams = { ...this.sessionParams, ...params } as SessionParams - if (params.username) { - this._lastUsername = params.username - } - } - - 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 - } - - 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 { - return isValidKeeperConfig(value) - } + 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 index b0b69cb6..fb2e63ef 100644 --- a/KeeperSdk/src/auth/UnavailableAuthUI.ts +++ b/KeeperSdk/src/auth/UnavailableAuthUI.ts @@ -1,22 +1,29 @@ -import type { AuthUI3, DeviceApprovalChannel, TwoFactorChannelData } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' +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).' + "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 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 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) - } + 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 index af3d2dd7..1f0b7641 100644 --- a/KeeperSdk/src/auth/config.ts +++ b/KeeperSdk/src/auth/config.ts @@ -1,42 +1,42 @@ export type ConfigurationUser = { - user?: string - server?: string - last_device?: { device_token?: string } -} + user?: string; + server?: string; + last_device?: { device_token?: string }; +}; export type ConfigurationServerConfig = { - server?: string - clone_code?: string -} + server?: string; + clone_code?: string; +}; export type ConfigurationDeviceConfig = { - device_token?: string - private_key?: string - server_info?: Array -} + 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 -} + 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 + 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 + 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 index 328fa254..638e80a7 100644 --- a/KeeperSdk/src/auth/node/FileConfigLoader.ts +++ b/KeeperSdk/src/auth/node/FileConfigLoader.ts @@ -1,36 +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' +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 + public readonly configDir: string; - constructor(configDir?: string) { - this.configDir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) - } + 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 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, - }) - } + 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 index 0e64a4e0..beff79bb 100644 --- a/KeeperSdk/src/auth/sessionRestore.ts +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -1,153 +1,198 @@ -import { Authentication, normal64Bytes, type SessionParams } from '@keeper-security/keeperapi' -import { KeeperSdkError, ResultCodes } from '../utils' +import { + Authentication, + normal64Bytes, + type SessionParams, +} from "@keeper-security/keeperapi"; +import { KeeperSdkError, ResultCodes } from "../utils"; -type UserType = SessionParams['userType'] +type UserType = SessionParams["userType"]; const UserTypeValues = { - normal: 'normal' as UserType, - cloudSso: 'cloud_sso' as UserType, - onsiteSso: 'onsite_sso' as UserType, -} + 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 -} + 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', -] + "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 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 - } + 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) + } + 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 +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 +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) + 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 } : {}), - } + 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(') + } + if (typeof parsed !== "object" || parsed === null) { + throw new KeeperSdkError( + "restore-session: JSON must be an object", + ResultCodes.INVALID_CREDENTIALS, + ); + } + return validateSessionRestoreInput(parsed as Partial); } /** Parse `--from-json` payload, or read a file when the value is not JSON. */ export async function resolveSessionRestorePayload( - raw: string, - readFile?: (path: string) => Promise + 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) + 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 index 463daba6..87898979 100644 --- a/KeeperSdk/src/browser.ts +++ b/KeeperSdk/src/browser.ts @@ -1,6 +1,6 @@ -import { connectSdkPlatform } from './platform' -import { browserSdkPlatform } from './platform/browser/platform' +import { connectSdkPlatform } from "./platform"; +import { browserSdkPlatform } from "./platform/browser/platform"; -connectSdkPlatform(browserSdkPlatform) +connectSdkPlatform(browserSdkPlatform); -export * from './api' +export * from "./api"; 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 57749c46..50ab6fa1 100644 --- a/KeeperSdk/src/folders/addFolder.ts +++ b/KeeperSdk/src/folders/addFolder.ts @@ -1,305 +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 - color?: string | null -} + 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 - color?: string | null -} + sharedFolder?: boolean; + userFolder?: boolean; + grantAll?: boolean; + manageUsers?: boolean; + manageRecords?: boolean; + canShare?: boolean; + canEdit?: boolean; + color?: string | null; +}; type ParentContext = { - 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') + 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 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 +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.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 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 +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, - color: isLastSegment ? options.color : 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 dc25c587..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"; -export 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 906b19a4..1072cc83 100644 --- a/KeeperSdk/src/folders/deleteFolder.ts +++ b/KeeperSdk/src/folders/deleteFolder.ts @@ -1,344 +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 { 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 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 - /** Override prompt (e.g. browser shell UI). Defaults to SdkPlatform readline. */ - ask?: (prompt: string) => 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' + 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() - } + const rl = getSdkPlatform().createReadline(); + try { + return await rl.question(prompt); + } finally { + rl.close(); + } } function resolveRmdirConfirm( - options: RmdirOptions + 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) - } + 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(", "); + + let preResp: KeeperPreDeleteResponse; + try { + preResp = await auth.executeRestCommand(preDeleteCommand({ objects })); + } catch (err) { + return { + success: false, + message: `pre_delete failed for [${targetUids}]: ${extractErrorMessage(err)}`, + }; + } + + 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." }; } - const targetUids = objects.map((deleteObject) => deleteObject.object_uid).join(', ') - - let preResp: KeeperPreDeleteResponse + // 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 - let deleteToken = inner?.pre_delete_token + deleteToken = preResp.pre_delete_response?.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 })) - } catch (err) { - return { - success: false, - message: `pre_delete refresh failed for [${targetUids}]: ${extractErrorMessage(err)}`, - } - } - 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, - } - } + 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, + }; } - + } + + 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: deleteToken })) + await storage.delete(deleteObject.object_type, deleteObject.object_uid); } catch (err) { - return { - success: false, - message: `record_delete failed for [${targetUids}]: ${extractErrorMessage(err)}`, - } - } - - for (const deleteObject of objects) { - try { - await storage.delete(deleteObject.object_type, deleteObject.object_uid) - } catch (err) { - logger.debug(`Failed to purge ${deleteObject.object_type} ${deleteObject.object_uid}:`, 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 { folderUid: baseUid, remaining } = await tryResolvePath( + storage, + session, + trimmedPattern, + ); + const remainingPattern = remaining.trim(); + + if (!remainingPattern) { + if (baseUid !== null) { + matchedUids.add(baseUid); } - - const children = await listFolderChildrenFolders(storage, baseUid) - const exactChild = children.find((child) => child.uid === remainingPattern) - if (exactChild) { - matchedUids.add(exactChild.uid) - return matchedUids + 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 } = options - 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(); + + 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); } - - if (folderUids.size === 0) { - throw new KeeperSdkError('Enter name of an existing folder.', 'rmdir_no_match') - } - - await dedupeNestedFolderDeletes(storage, folderUids) - - const sortedNames = [...folderUids] - .map((uid) => folderDisplayName(storage, uid)) - .sort((nameA, nameB) => nameA.localeCompare(nameB, undefined, { sensitivity: 'base' })) - - if (!quiet || !force) { - logger.info(`\nThe following folder(s) will be removed:\n${sortedNames.join(', ')}\n`) - } - - return deleteFolder(auth, storage, [...folderUids], resolveRmdirConfirm(options)) + } + + if (folderUids.size === 0) { + throw new KeeperSdkError( + "Enter name of an existing folder.", + "rmdir_no_match", + ); + } + + await dedupeNestedFolderDeletes(storage, folderUids); + + const sortedNames = [...folderUids] + .map((uid) => folderDisplayName(storage, uid)) + .sort((nameA, nameB) => + nameA.localeCompare(nameB, undefined, { sensitivity: "base" }), + ); + + const foldersPreview = `\nThe following folder(s) will be removed:\n${sortedNames.join(", ")}\n`; + if (!quiet || !force) { + logger.info(foldersPreview); + } + + 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 910f5209..e1321b48 100644 --- a/KeeperSdk/src/folders/folderHelpers.ts +++ b/KeeperSdk/src/folders/folderHelpers.ts @@ -1,118 +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 } +type UserFolderData = { title?: string; name?: string; color?: string }; export function userFolderName(folder: DUserFolder): string { - const data = folder.data as UserFolderData | 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 + 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 c23d1dbe..0420bccf 100644 --- a/KeeperSdk/src/folders/folderTree.ts +++ b/KeeperSdk/src/folders/folderTree.ts @@ -1,344 +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, VAULT_ROOT_DISPLAY_NAME, 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 + 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(), - }) - } - - 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 })) + 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 })); } function folderTreeTag( - userFolder: DUserFolder | undefined, - sharedFolder: DSharedFolder | undefined, - _sharedFolderFolder: DSharedFolderFolder | undefined + 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 + if (sharedFolder) return TREE_TAG.sharedFolder; + if (userFolder) return TREE_TAG.folder; + return TREE_TAG.folder; } -function formatTreeNodeName(baseName: string, tag: string, verbose: boolean, uid?: string): string { - const name = verbose && uid ? `${baseName} (${uid})` : baseName - return `${name} ${tag}` +function formatTreeNodeName( + baseName: string, + tag: string, + verbose: boolean, + uid?: string, +): string { + const name = verbose && uid ? `${baseName} (${uid})` : baseName; + return `${name} ${tag}`; } -function formatTreeRecordName(title: string, verbose: boolean, recordUid?: string): string { - const name = verbose && recordUid ? `${title} (${recordUid})` : title - return `${name} ${TREE_TAG.record}` +function formatTreeRecordName( + title: string, + verbose: boolean, + recordUid?: string, +): string { + const name = verbose && recordUid ? `${title} (${recordUid})` : title; + return `${name} ${TREE_TAG.record}`; } -type BuildOpts = Required> & { - promotedRootSharedUids?: Set - accountUidEmailMap: Map -} +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 = 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 + 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: 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 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 }) - } - } - for (const child of node.children) { - items.push({ kind: TreeItemKind.Folder, node: child }) + const items: TreeItem[] = []; + if (node.permissions) { + for (const permission of node.permissions) { + items.push({ + kind: TreeItemKind.Permission, + display: permission.display, + }); } - if (node.records) { - for (const record of node.records) { - items.push({ kind: TreeItemKind.Record, display: record.display }) - } + } + 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 }); } - 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 1a3de26f..0eb6e69e 100644 --- a/KeeperSdk/src/folders/listFolder.ts +++ b/KeeperSdk/src/folders/listFolder.ts @@ -1,396 +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, - userFolderColor, - 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 - recursive?: boolean -} + folderUid?: string | null; + pattern?: string | null; + showFolders?: boolean; + showRecords?: boolean; + detail?: boolean; + recursive?: boolean; +}; export type ListFolderFolderSimple = { - uid: string - name: string - folderKind: FolderKind - /** User-folder vault color when set (Commander `ls` colorization). */ - color?: string -} + 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) - 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 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 - } - - 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 - 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), - }) - } +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); } + } - 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) - } - } + 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 774d3f43..079460b9 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -1,536 +1,588 @@ -import { connectSdkPlatform } from './platform' -import { nodeSdkPlatform } from './platform/node/platform' +import { connectSdkPlatform } from "./platform"; +import { nodeSdkPlatform } from "./platform/node/platform"; -connectSdkPlatform(nodeSdkPlatform) +connectSdkPlatform(nodeSdkPlatform); -export * from './api' +export * from "./api"; /** Node.js Commander / CLI helpers (readline, ~/.keeper config). */ -export { ConsoleAuthUI } from './auth/ConsoleAuthUI' -export { FileConfigLoader } from './auth/node/FileConfigLoader' -export { login, cleanup, prompt, suppressLogs, loadKeeperConfig, resolveServer } from './auth/ConsoleLogin' +export { ConsoleAuthUI } from "./auth/ConsoleAuthUI"; +export { FileConfigLoader } from "./auth/node/FileConfigLoader"; +export { + login, + cleanup, + prompt, + suppressLogs, + loadKeeperConfig, + resolveServer, +} from "./auth/ConsoleLogin"; -export { InMemoryStorage } from './storage/InMemoryStorage' +export { InMemoryStorage } from "./storage/InMemoryStorage"; export { - Logger, - ConsoleLogger, - LogLevel, - logger, - setLogger, - getLogger, - resetLogger, - KeeperSdkError, - isKeeperError, - extractErrorMessage, - extractResultCode, - SdkDefaults, - AuthDefaults, - NsfErrorCode, - ResultCodes, - AuthErrorCode, - SessionErrorCode, - ValidationErrorCode, - RoleErrorCode, - TeamErrorCode, - UserErrorCode, - 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' + Logger, + ConsoleLogger, + LogLevel, + logger, + setLogger, + getLogger, + resetLogger, + KeeperSdkError, + isKeeperError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + AuthDefaults, + NsfErrorCode, + ResultCodes, + AuthErrorCode, + SessionErrorCode, + ValidationErrorCode, + RoleErrorCode, + TeamErrorCode, + UserErrorCode, + 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 { + ILogger, + Nullable, + Optional, + DeepPartial, + Immutable, + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, +} from "./utils"; + +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"; export type { - ILogger, - Nullable, - Optional, - DeepPartial, - Immutable, - GenPasswordAlgorithm, - PasswordGenerationOptions, - PasswordComplexityPolicy, - PassphraseGenOptions, -} from './utils' + PasswordRecordData, + TypedRecordData, + RecordFieldInput, + NewRecordInput, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + HistoryEntry, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from "./records/RecordOperations"; 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' + shareRecord, + removeRecordShare, + getRecordShareInfo, +} from "./sharing/Sharing"; export type { - PasswordRecordData, - TypedRecordData, - RecordFieldInput, - NewRecordInput, - AddRecordResult, - UpdateRecordResult, - DeleteRecordResult, - HistoryEntry, - RecordHistoryResult, - MoveRecordInput, - MoveRecordResult, -} from './records/RecordOperations' - -export { shareRecord, removeRecordShare, getRecordShareInfo } from './sharing/Sharing' + 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 { + listFolder, + findFolderUidByNameOrUid, + listRootUserFolders, +} from "./folders/listFolder"; 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' + ListFolderOptions, + ListFolderResult, + ListFolderFolderSimple, + ListFolderRecordSimple, + ListFolderFolderDetail, + ListFolderRecordDetail, +} from "./folders/listFolder"; + +export { + listSharedFolders, + formatSharedFoldersTable, + renderSharedFoldersAsciiTable, +} from "./sharedFolders/listSharedFolders"; export type { - GetFolderOptions, - GetFolderResult, - GetFolderResultFolder, - GetFolderResultSharedFolder, - GetFolderFormatInput, - FoundFolder, -} from './folders/getFolder' + ListSharedFoldersOptions, + ListSharedFolderRow, + FormattedSharedFoldersTable, +} from "./sharedFolders/listSharedFolders"; export { - FolderKind, - ParentFolderKind, - FolderObjectType, - FolderResultStatus, - DeleteResolution, - DeleteObjectType, - folderKindFromString, -} from './folders/folderHelpers' -export type { FolderKindOrLiteral } from './folders/folderHelpers' - -export { listFolder, findFolderUidByNameOrUid, listRootUserFolders } from './folders/listFolder' + shareFolder, + ShareFolderAction, + ShareFolderUserResultStatus, +} from "./sharedFolders/shareFolder"; export type { - ListFolderOptions, - ListFolderResult, - ListFolderFolderSimple, - ListFolderRecordSimple, - ListFolderFolderDetail, - ListFolderRecordDetail, -} from './folders/listFolder' + ShareFolderActionInput, + ShareFolderInput, + ShareFolderResult, + ShareFolderUserStatus, +} from "./sharedFolders/shareFolder"; export { - listSharedFolders, - formatSharedFoldersTable, - renderSharedFoldersAsciiTable, -} from './sharedFolders/listSharedFolders' + changeDirectory, + createVaultFolderSession, + tryResolvePath, + resolveSingleFolder, + getWorkingFolderDisplayName, + findParentFolderUid, + splitPathComponents, +} from "./folders/changeDirectory"; export type { - ListSharedFoldersOptions, - ListSharedFolderRow, - FormattedSharedFoldersTable, -} from './sharedFolders/listSharedFolders' + VaultFolderSession, + ChangeDirectoryResult, + TryResolvePathResult, +} from "./folders/changeDirectory"; -export { shareFolder, ShareFolderAction, ShareFolderUserResultStatus } from './sharedFolders/shareFolder' +export { addFolder, mkdir } from "./folders/addFolder"; export type { - ShareFolderActionInput, - ShareFolderInput, - ShareFolderResult, - ShareFolderUserStatus, -} from './sharedFolders/shareFolder' + AddFolderInput, + AddFolderResult, + MkdirOptions, +} from "./folders/addFolder"; 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' + 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' + 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' + 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 { FolderManager } from "./folders/FolderManager"; +export type { + AuthProvider, + SharedFolderPermissionsInput, +} from "./folders/FolderManager"; -export { SharedFolderManager } from './sharedFolders/SharedFolderManager' +export { SharedFolderManager } from "./sharedFolders/SharedFolderManager"; export { - listTeams, - formatTeamsTable, - renderTeamsAsciiTable, - formatTeamRestricts, - TeamColumn, - SUPPORTED_TEAM_COLUMNS, - DEFAULT_TEAM_COLUMNS, -} from './teams/listTeams' + listTeams, + formatTeamsTable, + renderTeamsAsciiTable, + formatTeamRestricts, + TeamColumn, + SUPPORTED_TEAM_COLUMNS, + DEFAULT_TEAM_COLUMNS, +} from "./teams/listTeams"; export type { - ListTeamsOptions, - ListTeamRow, - TeamColumnInput, - FormattedTeamsTable, - FormatTeamsTableOptions, -} from './teams/listTeams' + ListTeamsOptions, + ListTeamRow, + TeamColumnInput, + FormattedTeamsTable, + FormatTeamsTableOptions, +} from "./teams/listTeams"; export { - EnterpriseDataInclude, - EnterpriseDataManager, -} from './teams/enterpriseData' + EnterpriseDataInclude, + EnterpriseDataManager, +} from "./teams/enterpriseData"; export type { - EnterpriseDataManagerApi, - GetEnterpriseDataResponse, - EnterpriseTeamRecord, - EnterpriseTeamUserLink, - EnterpriseRoleUserLink, - EnterpriseRoleTeamLink, - EnterpriseRolePrivilegeLink, - EnterpriseRoleManagedNodeLink, - EnterpriseRoleEnforcementLink, - EnterpriseQueuedTeamRecord, - EnterpriseQueuedTeamUserLink, - EnterpriseUserAliasLink, - EnterpriseUser, - EnterpriseRole, - EnterpriseNode, - DecryptedNodeNames, - DecryptedRoleNames, - EnterpriseDisplayNames, - NodePathOptions, -} from './teams/enterpriseData' + EnterpriseDataManagerApi, + GetEnterpriseDataResponse, + EnterpriseTeamRecord, + EnterpriseTeamUserLink, + EnterpriseRoleUserLink, + EnterpriseRoleTeamLink, + EnterpriseRolePrivilegeLink, + EnterpriseRoleManagedNodeLink, + EnterpriseRoleEnforcementLink, + EnterpriseQueuedTeamRecord, + EnterpriseQueuedTeamUserLink, + EnterpriseUserAliasLink, + EnterpriseUser, + EnterpriseRole, + EnterpriseNode, + DecryptedNodeNames, + DecryptedRoleNames, + EnterpriseDisplayNames, + NodePathOptions, +} from "./teams/enterpriseData"; 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' + 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"; 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' - -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, +} 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' - -export { - aliasUser, - AliasOperation, -} from './users/aliasUser' + 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 { - addUsersToTeams, - removeUsersFromTeams, - formatTeamUserResult, - renderTeamUserAsciiTable, - TeamUserStatus, - TeamUserSkipReason, -} from './users/teamUser' + addUsersToTeams, + removeUsersFromTeams, + formatTeamUserResult, + renderTeamUserAsciiTable, + TeamUserStatus, + TeamUserSkipReason, +} from "./users/teamUser"; -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, - resolveNsfFolder, - resolveNsfRecord, - getNestedShareFolder, - formatNsfFolderDetail, - formatNsfRecordDetail, - formatNsfDetail, - linkNestedShareRecord, - NsfRemoveOperation, - removeNestedShareRecords, - formatRemoveNsfPreview, - NestedShareFolderManager, -} from './nestedShareFolders' + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + resolveKeeperDriveRootParentUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + ListNsfFormat, + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, + GetNsfFormat, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, + linkNestedShareRecord, + NsfRemoveOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, + NestedShareFolderManager, +} from "./nestedShareFolders"; export type { - ListNsfFormatInput, - ListNsfOptions, - ListNsfRow, - FormattedListNsfTable, - GetNsfFormatInput, - GetNsfOptions, - GetNsfResult, - NsfFolderView, - NsfRecordView, - NsfFolderPermission, - NsfFolderAccessRow, - NsfRecordPermission, - LinkNsfRecordResult, - NsfRemoveOperationInput, - RemoveNsfRecordInput, - NsfRemovePreviewItem, - RemoveNsfRecordResult, -} from './nestedShareFolders' + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, + LinkNsfRecordResult, + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, +} 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 4a21c74f..84b4feda 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -1,97 +1,129 @@ -import type { Auth } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { KeeperSdkError, ResultCodes } from '../utils' +import type { Auth } from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes } from "../utils"; import { - formatListNsfOutput, - formatListNsfTable, - listNestedShareFolders, - renderListNsfAsciiTable, - type FormattedListNsfTable, - type ListNsfFormatInput, - type ListNsfOptions, - type ListNsfRow, -} from './listNsf' + formatListNsfOutput, + formatListNsfTable, + listNestedShareFolders, + renderListNsfAsciiTable, + type FormattedListNsfTable, + type ListNsfFormatInput, + type ListNsfOptions, + type ListNsfRow, +} from "./listNsf"; import { - formatNsfDetail as renderNsfDetail, - formatNsfFolderDetail as renderNsfFolderDetail, - formatNsfRecordDetail as renderNsfRecordDetail, - getNestedShareFolder, - type GetNsfOptions, - type GetNsfResult, - type NsfFolderView, - type NsfRecordView, -} from './getNsf' -import { linkNestedShareRecord, type LinkNsfRecordResult } from './linkNsfRecord' + formatNsfDetail as renderNsfDetail, + formatNsfFolderDetail as renderNsfFolderDetail, + formatNsfRecordDetail as renderNsfRecordDetail, + getNestedShareFolder, + type GetNsfOptions, + type GetNsfResult, + type NsfFolderView, + type NsfRecordView, +} from "./getNsf"; import { - formatRemoveNsfPreview, - removeNestedShareRecords, - type RemoveNsfRecordInput, - type RemoveNsfRecordResult, -} from './removeNsfRecord' + linkNestedShareRecord, + type LinkNsfRecordResult, +} from "./linkNsfRecord"; +import { + formatRemoveNsfPreview, + removeNestedShareRecords, + type RemoveNsfRecordInput, + type RemoveNsfRecordResult, +} from "./removeNsfRecord"; -export type AuthProvider = () => Auth +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 formatListNsfTable(rows: ListNsfRow[], options: { columnWidth?: number } = {}): FormattedListNsfTable { - return formatListNsfTable(rows, 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 renderListNsfAsciiTable( + table: FormattedListNsfTable, + options: { minColWidth?: number } = {}, + ): string { + return renderListNsfAsciiTable(table, options); + } - public formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = 'table'): string { - return formatListNsfOutput(rows, format) - } + public formatListNsfOutput( + rows: ListNsfRow[], + format: ListNsfFormatInput = "table", + ): string { + return formatListNsfOutput(rows, format); + } - public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { - return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) - } + public async getNestedShareFolder( + identifier: string, + options: GetNsfOptions = {}, + ): Promise { + return getNestedShareFolder( + this.storage, + this.requireAuth(), + identifier, + options, + ); + } - public formatNsfDetail(result: GetNsfResult, verbose = false): string { - return renderNsfDetail(result, verbose) - } + public formatNsfDetail(result: GetNsfResult, verbose = false): string { + return renderNsfDetail(result, verbose); + } - public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { - return renderNsfFolderDetail(view, verbose) - } + public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { + return renderNsfFolderDetail(view, verbose); + } - public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { - return renderNsfRecordDetail(view, verbose) - } + public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { + return renderNsfRecordDetail(view, verbose); + } - public async linkNestedShareRecord( - recordIdentifier: string, - folderIdentifier: string - ): Promise { - return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) - } + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string, + ): Promise { + return linkNestedShareRecord( + this.storage, + this.requireAuth(), + recordIdentifier, + folderIdentifier, + ); + } - public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { - return removeNestedShareRecords(this.storage, this.requireAuth(), input) - } + public async removeNestedShareRecords( + input: RemoveNsfRecordInput, + ): Promise { + return removeNestedShareRecords(this.storage, this.requireAuth(), input); + } - public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { - return formatRemoveNsfPreview(preview) - } + public formatRemoveNsfPreview( + preview: RemoveNsfRecordResult["preview"], + ): string { + return formatRemoveNsfPreview(preview); + } } diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts index 25523668..5de5cc07 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -1,500 +1,580 @@ -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, extractErrorMessage } from '../utils' + getRecordFields, + getRecordLogin, + getRecordPassword, + getRecordTitle, + getRecordType, + getRecordUrl, +} from "../records/RecordUtils"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; import { - buildFolderPath, - collectRecordsInFolder, - findRecordFolderLocation, - folderAccessDisplayRole, - formatAccessType, - getFolderAccessEntries, - getKeeperDriveFolder, - getKeeperDriveRecord, - isFolderShareAdministrator, - isFolderUserPermission, - isSensitiveFieldType, - normalizeParentUid, - resolveAccessUsername, - resolveNsfFolderIdentifier, - resolveNsfRecordIdentifier, -} from './nsfHelpers' + buildFolderPath, + collectRecordsInFolder, + findRecordFolderLocation, + folderAccessDisplayRole, + formatAccessType, + getFolderAccessEntries, + getKeeperDriveFolder, + getKeeperDriveRecord, + isFolderShareAdministrator, + isFolderUserPermission, + isSensitiveFieldType, + normalizeParentUid, + resolveAccessUsername, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; import { - NSF_FOLDER_LABEL_WIDTH, - NSF_FOLDER_SHARE_ADMINS_HEADING, - NSF_FOLDER_USER_PERMISSIONS_HEADING, - NSF_MASKED_VALUE, - NSF_RECORD_LABEL_WIDTH, - NSF_RECORD_USER_PERMISSIONS_HEADING, - NSF_TOP_LEVEL_FIELD_TYPES, - NSF_UNKNOWN_RECORD_TITLES, -} from './nsfConstants' - -function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { - if (value == null) return undefined - return typeof value === 'number' ? value : value.toNumber() + NSF_FOLDER_LABEL_WIDTH, + NSF_FOLDER_SHARE_ADMINS_HEADING, + NSF_FOLDER_USER_PERMISSIONS_HEADING, + NSF_MASKED_VALUE, + NSF_RECORD_LABEL_WIDTH, + NSF_RECORD_USER_PERMISSIONS_HEADING, + NSF_TOP_LEVEL_FIELD_TYPES, + NSF_UNKNOWN_RECORD_TITLES, +} from "./nsfConstants"; + +function longToNumber( + value: number | { toNumber: () => number } | null | undefined, +): number | undefined { + if (value == null) return undefined; + return typeof value === "number" ? value : value.toNumber(); } 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); } 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; +}; export type NsfFolderAccessRow = { - username: string - role: string -} + username: string; + role: string; +}; export type NsfFolderPermission = { - accessTypeUid: string - accessType: string - accessRoleType: string - inherited?: boolean - hidden?: boolean - canAdd?: boolean - canRemove?: boolean - canDelete?: boolean - canListAccess?: boolean - canUpdateAccess?: boolean - canEditRecords?: boolean - canViewRecords?: boolean - canListRecords?: boolean -} + accessTypeUid: string; + accessType: string; + accessRoleType: string; + inherited?: boolean; + hidden?: boolean; + canAdd?: boolean; + canRemove?: boolean; + canDelete?: boolean; + canListAccess?: boolean; + canUpdateAccess?: boolean; + canEditRecords?: boolean; + canViewRecords?: boolean; + canListRecords?: boolean; +}; export type NsfRecordPermission = { - username: string - accountUid?: string - owner: boolean - shareAdmin: boolean - shareable: boolean - editable: boolean - awaitingApproval: boolean - expiration?: number -} + username: string; + accountUid?: string; + owner: boolean; + shareAdmin: boolean; + shareable: boolean; + editable: boolean; + awaitingApproval: boolean; + expiration?: number; +}; export type NsfFolderView = { - objectType: 'folder' - folderUid: string - name: string - parentUid: string - path: string - userPermissions: NsfFolderAccessRow[] - shareAdmins: NsfFolderAccessRow[] - teamPermissions: NsfFolderPermission[] - records: { uid: string; title: string; type: string }[] -} + objectType: "folder"; + folderUid: string; + name: string; + parentUid: string; + path: string; + userPermissions: NsfFolderAccessRow[]; + shareAdmins: NsfFolderAccessRow[]; + teamPermissions: NsfFolderPermission[]; + records: { uid: string; title: string; type: string }[]; +}; export type NsfRecordView = { - objectType: 'record' - recordUid: string - title: string - type: string - revision: number - version: number - folderLocation: string - login?: string - password?: string - url?: string - notes?: string - fields: { type: string; label?: string; value: string }[] - userPermissions: NsfRecordPermission[] - shareAdmins: string[] -} - -export type GetNsfResult = { kind: 'folder'; view: NsfFolderView } | { kind: 'record'; view: NsfRecordView } + objectType: "record"; + recordUid: string; + title: string; + type: string; + revision: number; + version: number; + folderLocation: string; + login?: string; + password?: string; + url?: string; + notes?: string; + fields: { type: string; label?: string; value: string }[]; + userPermissions: NsfRecordPermission[]; + shareAdmins: string[]; +}; + +export type GetNsfResult = + | { kind: "folder"; view: NsfFolderView } + | { kind: "record"; view: NsfRecordView }; 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 bytesToUid(bytes: Uint8Array | null | undefined): string | undefined { - return bytes?.length ? webSafe64FromBytes(bytes) : undefined + return bytes?.length ? webSafe64FromBytes(bytes) : undefined; } function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { - const permission = entry.permission - return { - accessTypeUid: entry.accessTypeUid, - accessType: formatAccessType(entry.accessType), - accessRoleType: folderAccessDisplayRole(entry), - inherited: entry.inherited, - hidden: entry.hidden, - canAdd: permission?.canAdd ?? undefined, - canRemove: permission?.canRemove ?? undefined, - canDelete: permission?.canDelete ?? undefined, - canListAccess: permission?.canListAccess ?? undefined, - canUpdateAccess: permission?.canUpdateAccess ?? undefined, - canEditRecords: permission?.canEditRecords ?? undefined, - canViewRecords: permission?.canViewRecords ?? undefined, - canListRecords: permission?.canListRecords ?? undefined, - } + const permission = entry.permission; + return { + accessTypeUid: entry.accessTypeUid, + accessType: formatAccessType(entry.accessType), + accessRoleType: folderAccessDisplayRole(entry), + inherited: entry.inherited, + hidden: entry.hidden, + canAdd: permission?.canAdd ?? undefined, + canRemove: permission?.canRemove ?? undefined, + canDelete: permission?.canDelete ?? undefined, + canListAccess: permission?.canListAccess ?? undefined, + canUpdateAccess: permission?.canUpdateAccess ?? undefined, + canEditRecords: permission?.canEditRecords ?? undefined, + canViewRecords: permission?.canViewRecords ?? undefined, + canListRecords: permission?.canListRecords ?? undefined, + }; } function buildFolderAccessRow( - storage: InMemoryStorage, - folder: DKdFolder, - entry: DKdFolderAccess + storage: InMemoryStorage, + folder: DKdFolder, + entry: DKdFolderAccess, ): NsfFolderAccessRow { - return { - username: resolveAccessUsername(storage, entry.accessTypeUid, folder), - role: folderAccessDisplayRole(entry), - } + return { + username: resolveAccessUsername(storage, entry.accessTypeUid, folder), + role: folderAccessDisplayRole(entry), + }; } function splitFolderPermissions(storage: InMemoryStorage, folder: DKdFolder) { - const entries = getFolderAccessEntries(storage, folder.uid) - const userPermissions: NsfFolderAccessRow[] = [] - const shareAdmins: NsfFolderAccessRow[] = [] - const teamPermissions: NsfFolderPermission[] = [] - for (const entry of entries) { - if (isFolderUserPermission(entry)) { - userPermissions.push(buildFolderAccessRow(storage, folder, entry)) - } - if (isFolderShareAdministrator(entry)) { - shareAdmins.push(buildFolderAccessRow(storage, folder, entry)) - } - if (entry.accessType === Folder.AccessType.AT_TEAM) { - teamPermissions.push(mapFolderPermission(entry)) - } + const entries = getFolderAccessEntries(storage, folder.uid); + const userPermissions: NsfFolderAccessRow[] = []; + const shareAdmins: NsfFolderAccessRow[] = []; + const teamPermissions: NsfFolderPermission[] = []; + for (const entry of entries) { + if (isFolderUserPermission(entry)) { + userPermissions.push(buildFolderAccessRow(storage, folder, entry)); } - return { userPermissions, shareAdmins, teamPermissions } -} - -function prependOwnerRow(rows: NsfFolderAccessRow[], ownerUsername: string): NsfFolderAccessRow[] { - if (rows.some((entry) => entry.username === ownerUsername)) return rows - return [{ username: ownerUsername, role: '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), + if (isFolderShareAdministrator(entry)) { + shareAdmins.push(buildFolderAccessRow(storage, folder, entry)); } -} - -function buildRecordFields(record: DRecord, unmask: boolean): NsfRecordView['fields'] { - return getRecordFields(record) - .filter((field) => !NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) - .map((field) => { - const rawValues = Array.isArray(field.value) ? field.value : [field.value] - const displayValue = formatNsfFieldParts(rawValues).join(', ') - return { field, displayValue } - }) - .filter(({ displayValue }) => displayValue.length > 0) - .map(({ field, displayValue }) => ({ - type: field.type, - label: field.label, - value: !unmask && isSensitiveFieldType(field.type) ? NSF_MASKED_VALUE : displayValue, - })) -} - -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') - lines.push(` Shareable: ${entry.shareable ? 'Yes' : 'No'}`) - lines.push(` Read-Only: ${entry.editable ? 'No' : 'Yes'}`) - return lines -} - -async function fetchRecordPermissions(auth: Auth, recordUid: string): Promise { - try { - const response = await auth.executeRest( - recordDetailsMessage(recordUid, Records.RecordDetailsInclude.SHARE_ONLY) - ) - const detail = response.recordDataWithAccessInfo?.[0] - return (detail?.userPermission ?? []).map((entry) => ({ - username: entry.username || '', - accountUid: bytesToUid(entry.accountUid), - owner: !!entry.owner, - shareAdmin: !!entry.shareAdmin, - shareable: !!entry.sharable, - editable: !!entry.editable, - awaitingApproval: !!entry.awaitingApproval, - expiration: longToNumber(entry.expiration as number | null | undefined), - })) - } catch (err) { - throw new KeeperSdkError( - `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}` - ) + if (entry.accessType === Folder.AccessType.AT_TEAM) { + teamPermissions.push(mapFolderPermission(entry)); } + } + return { userPermissions, shareAdmins, teamPermissions }; } -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 [] - } +function prependOwnerRow( + rows: NsfFolderAccessRow[], + ownerUsername: string, +): NsfFolderAccessRow[] { + if (rows.some((entry) => entry.username === ownerUsername)) return rows; + return [{ username: ownerUsername, role: "owner" }, ...rows]; } -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 - } +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), + }; +} + +function buildRecordFields( + record: DRecord, + unmask: boolean, +): NsfRecordView["fields"] { + return getRecordFields(record) + .filter((field) => !NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) + .map((field) => { + const rawValues = Array.isArray(field.value) + ? field.value + : [field.value]; + const displayValue = formatNsfFieldParts(rawValues).join(", "); + return { field, displayValue }; + }) + .filter(({ displayValue }) => displayValue.length > 0) + .map(({ field, displayValue }) => ({ + type: field.type, + label: field.label, + value: + !unmask && isSensitiveFieldType(field.type) + ? NSF_MASKED_VALUE + : displayValue, + })); } -function buildFolderView(storage: InMemoryStorage, folderUid: string): NsfFolderView { - const folder = getKeeperDriveFolder(storage, folderUid) - if (!folder) { - throw new KeeperSdkError(`Nested share folder not found: ${folderUid}`, ResultCodes.NSF_NOT_FOUND) - } - - const split = splitFolderPermissions(storage, folder) - const { userPermissions, shareAdmins } = ensureFolderOwnerListed( - folder, - split.userPermissions, - split.shareAdmins - ) - - return { - objectType: '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), - })), - } +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"); + lines.push(` Shareable: ${entry.shareable ? "Yes" : "No"}`); + lines.push(` Read-Only: ${entry.editable ? "No" : "Yes"}`); + return lines; +} + +async function fetchRecordPermissions( + auth: Auth, + recordUid: string, +): Promise { + try { + const response = await auth.executeRest( + recordDetailsMessage(recordUid, Records.RecordDetailsInclude.SHARE_ONLY), + ); + const detail = response.recordDataWithAccessInfo?.[0]; + return (detail?.userPermission ?? []).map((entry) => ({ + username: entry.username || "", + accountUid: bytesToUid(entry.accountUid), + owner: !!entry.owner, + shareAdmin: !!entry.shareAdmin, + shareable: !!entry.sharable, + editable: !!entry.editable, + awaitingApproval: !!entry.awaitingApproval, + expiration: longToNumber(entry.expiration as number | null | undefined), + })); + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}`, + ); + } +} + +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; + } +} + +function buildFolderView( + storage: InMemoryStorage, + folderUid: string, +): NsfFolderView { + const folder = getKeeperDriveFolder(storage, folderUid); + if (!folder) { + throw new KeeperSdkError( + `Nested share folder not found: ${folderUid}`, + ResultCodes.NSF_NOT_FOUND, + ); + } + + const split = splitFolderPermissions(storage, folder); + const { userPermissions, shareAdmins } = ensureFolderOwnerListed( + folder, + split.userPermissions, + split.shareAdmins, + ); + + return { + objectType: "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), + })), + }; } 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, recordUid), - fetchRecordShareAdmins(auth, recordUid), - ]) - const notes = - typeof record.data?.notes === 'string' && record.data.notes.trim() ? record.data.notes.trim() : undefined - - return { - objectType: 'record', - recordUid, - title, - type: getRecordType(record), - revision: record.revision, - version: record.version, - 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); } -} - -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 + } + + const password = getRecordPassword(record); + const [userPermissions, shareAdmins] = await Promise.all([ + fetchRecordPermissions(auth, recordUid), + fetchRecordShareAdmins(auth, recordUid), + ]); + const notes = + typeof record.data?.notes === "string" && record.data.notes.trim() + ? record.data.notes.trim() + : undefined; + + return { + objectType: "record", + recordUid, + title, + type: getRecordType(record), + revision: record.revision, + version: record.version, + 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 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: 'folder', view: buildFolderView(storage, folderUid) } - } - - const recordUid = resolveNsfRecord(storage, trimmed) - if (recordUid) { - const view = await buildRecordView(auth, storage, recordUid, options.unmask ?? false) - return { kind: '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 - ) -} - -export function formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { - const lines = [ - folderDetailRow('Nested Share Folder UID', view.folderUid), - folderDetailRow('Name', view.name), - '', - NSF_FOLDER_USER_PERMISSIONS_HEADING, - ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), - '', - 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)) - if (view.records.length > 0) { - lines.push('', 'Records:') - for (const record of view.records) { - lines.push(` ${record.uid} ${record.title} (${record.type})`) - } + "UID or title is required.", + ResultCodes.NSF_NOT_FOUND, + ); + } + + const folderUid = resolveNsfFolder(storage, trimmed); + if (folderUid) { + return { kind: "folder", view: buildFolderView(storage, folderUid) }; + } + + const recordUid = resolveNsfRecord(storage, trimmed); + if (recordUid) { + const view = await buildRecordView( + auth, + storage, + recordUid, + options.unmask ?? false, + ); + return { kind: "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), + "", + NSF_FOLDER_USER_PERMISSIONS_HEADING, + ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), + "", + 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), + ); + if (view.records.length > 0) { + 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}`) - } + } + if (view.teamPermissions.length > 0) { + 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), - ] - - 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) { - const label = field.label || field.type - lines.push(recordDetailRow(label.charAt(0).toUpperCase() + label.slice(1), field.value)) + } + 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), + ]; + + 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) { + const label = field.label || field.type; + lines.push( + recordDetailRow( + label.charAt(0).toUpperCase() + label.slice(1), + field.value, + ), + ); + } + + if (view.userPermissions.length > 0) { + lines.push("", NSF_RECORD_USER_PERMISSIONS_HEADING); + for (const entry of view.userPermissions) { + lines.push("", ...formatRecordUserPermissionBlock(entry)); } + } - if (view.userPermissions.length > 0) { - lines.push('', NSF_RECORD_USER_PERMISSIONS_HEADING) - for (const entry of view.userPermissions) { - lines.push('', ...formatRecordUserPermissionBlock(entry)) - } + if (view.shareAdmins.length > 0) { + lines.push("", `Share Admins (${view.shareAdmins.length}):`); + for (const admin of view.shareAdmins) { + lines.push(` ${admin}`); } + } - if (view.shareAdmins.length > 0) { - lines.push('', `Share Admins (${view.shareAdmins.length}):`) - for (const admin of view.shareAdmins) { - lines.push(` ${admin}`) - } - } - - if (verbose) { - lines.push( - '', - recordDetailRow('Folder', view.folderLocation), - recordDetailRow('Revision', String(view.revision)), - recordDetailRow('Version', String(view.version)) - ) - } + if (verbose) { + lines.push( + "", + recordDetailRow("Folder", view.folderLocation), + recordDetailRow("Revision", String(view.revision)), + recordDetailRow("Version", String(view.version)), + ); + } - return lines.join('\n') + return lines.join("\n"); } export function formatNsfDetail(result: GetNsfResult, verbose = false): string { - return result.kind === 'folder' - ? formatNsfFolderDetail(result.view, verbose) - : formatNsfRecordDetail(result.view, verbose) + return result.kind === "folder" + ? formatNsfFolderDetail(result.view, verbose) + : formatNsfRecordDetail(result.view, verbose); } diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index 2edc264a..6988791c 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -1,78 +1,78 @@ export { - KeeperDriveKind, - NsfItemType, - formatAccessRoleType, - formatAccessType, - normalizeParentUid, - isRootFolderUid, - resolveKeeperDriveRootParentUid, - getKeeperDriveFolders, - getKeeperDriveRecords, - findRecordFolderLocation, - buildFolderPath, - isSensitiveFieldType, - resolveAccessUsername, - folderAccessDisplayRole, - isNestedShareRecord, - isNestedShareFolder, - ensureNestedShareRecord, - ensureNestedShareFolder, - resolveNsfRecordIdentifier, - resolveNsfFolderIdentifier, - findNestedShareFoldersForRecord, - checkFolderRemovePermission, - checkRecordDeletePermission, -} from './nsfHelpers' + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + resolveKeeperDriveRootParentUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + resolveAccessUsername, + folderAccessDisplayRole, + isNestedShareRecord, + isNestedShareFolder, + ensureNestedShareRecord, + ensureNestedShareFolder, + resolveNsfRecordIdentifier, + resolveNsfFolderIdentifier, + findNestedShareFoldersForRecord, + checkFolderRemovePermission, + checkRecordDeletePermission, +} from "./nsfHelpers"; export { - ListNsfFormat, - listNestedShareFolders, - formatListNsfTable, - renderListNsfAsciiTable, - formatListNsfCsv, - formatListNsfJson, - formatListNsfOutput, -} from './listNsf' + ListNsfFormat, + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, +} from "./listNsf"; export type { - ListNsfFormatInput, - ListNsfOptions, - ListNsfRow, - FormattedListNsfTable, -} from './listNsf' + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, +} from "./listNsf"; export { - GetNsfFormat, - resolveNsfFolder, - resolveNsfRecord, - getNestedShareFolder, - formatNsfFolderDetail, - formatNsfRecordDetail, - formatNsfDetail, -} from './getNsf' + GetNsfFormat, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, +} from "./getNsf"; export type { - GetNsfFormatInput, - GetNsfOptions, - GetNsfResult, - NsfFolderView, - NsfRecordView, - NsfFolderPermission, - NsfFolderAccessRow, - NsfRecordPermission, -} from './getNsf' + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, +} from "./getNsf"; -export { linkNestedShareRecord } from './linkNsfRecord' -export type { LinkNsfRecordResult } from './linkNsfRecord' +export { linkNestedShareRecord } from "./linkNsfRecord"; +export type { LinkNsfRecordResult } from "./linkNsfRecord"; export { - NsfRemoveOperation, - removeNestedShareRecords, - formatRemoveNsfPreview, -} from './removeNsfRecord' + NsfRemoveOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, +} from "./removeNsfRecord"; export type { - NsfRemoveOperationInput, - RemoveNsfRecordInput, - NsfRemovePreviewItem, - RemoveNsfRecordResult, -} from './removeNsfRecord' + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, +} from "./removeNsfRecord"; -export { NestedShareFolderManager } from './NestedShareFolderManager' +export { NestedShareFolderManager } from "./NestedShareFolderManager"; diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts index e277ffad..fb3069ad 100644 --- a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -1,139 +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' + 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 -} + 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 e4ff8d2c..485bacb1 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -1,173 +1,185 @@ -import type { InMemoryStorage } from '../storage/InMemoryStorage' +import type { InMemoryStorage } from "../storage/InMemoryStorage"; import { - NsfItemType, - findRecordFolderLocation, - getKeeperDriveFolders, - getKeeperDriveRecords, - getRecordDescription, - normalizeParentUid, -} from './nsfHelpers' -import { getRecordTitle, getRecordType } from '../records/RecordUtils' + NsfItemType, + findRecordFolderLocation, + getKeeperDriveFolders, + getKeeperDriveRecords, + getRecordDescription, + normalizeParentUid, +} 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' + NSF_LIST_DEFAULT_COLUMN_WIDTH, + NSF_LIST_FULL_HEADERS, + NSF_LIST_MIN_TRUNCATE_PREFIX, + NSF_LIST_TABLE_HEADERS, +} from "./nsfConstants"; 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; +}; 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[][]; +}; 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' }) + 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: normalizeParentUid(storage, folder.parentUid), - })) + return getKeeperDriveFolders(storage).map((folder) => ({ + itemType: NsfItemType.Folder, + uid: folder.uid, + title: folder.data.name || "Unnamed", + type: "", + description: "", + parentOrFolder: normalizeParentUid(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: 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: findRecordFolderLocation(storage, record.uid) || "root", + })); } -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 rows: ListNsfRow[] = []; + if (showFolders) rows.push(...collectFolderRows(storage)); + if (showRecords) rows.push(...collectRecordRows(storage)); + 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') + 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((row) => ({ - item_type: row.itemType, - uid: row.uid, - title: row.title, - type: row.type, - description: row.description, - parent_or_folder: row.parentOrFolder, - })), - 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_or_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/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index ea667a20..769d8924 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -1,54 +1,76 @@ -import { Folder } from '@keeper-security/keeperapi' +import { Folder } from "@keeper-security/keeperapi"; 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_ACCESS_ROLE_LABELS: Record = { - [Folder.AccessRoleType.NAVIGATOR]: 'navigator', - [Folder.AccessRoleType.REQUESTOR]: 'requestor', - [Folder.AccessRoleType.VIEWER]: 'viewer', - [Folder.AccessRoleType.SHARED_MANAGER]: 'shared-manager', - [Folder.AccessRoleType.CONTENT_MANAGER]: 'content-manager', - [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: 'content-share-manager', - [Folder.AccessRoleType.MANAGER]: 'manager', - [Folder.AccessRoleType.UNRESOLVED]: 'unresolved', -} + [Folder.AccessRoleType.NAVIGATOR]: "navigator", + [Folder.AccessRoleType.REQUESTOR]: "requestor", + [Folder.AccessRoleType.VIEWER]: "viewer", + [Folder.AccessRoleType.SHARED_MANAGER]: "shared-manager", + [Folder.AccessRoleType.CONTENT_MANAGER]: "content-manager", + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: "content-share-manager", + [Folder.AccessRoleType.MANAGER]: "manager", + [Folder.AccessRoleType.UNRESOLVED]: "unresolved", +}; 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', -} - -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_FOLDER_USER_PERMISSIONS_HEADING = 'User Permissions:' -export const NSF_FOLDER_SHARE_ADMINS_HEADING = 'Share Administrators:' -export const NSF_RECORD_USER_PERMISSIONS_HEADING = 'User Permissions:' - -export const NSF_LIST_TABLE_HEADERS = ['#', 'Item Type', 'UID', 'Title', 'Type', 'Description'] as const + [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_MASKED_VALUE = "********"; +export const NSF_FOLDER_LABEL_WIDTH = 22; +export const NSF_RECORD_LABEL_WIDTH = 17; +export const NSF_FOLDER_USER_PERMISSIONS_HEADING = "User Permissions:"; +export const NSF_FOLDER_SHARE_ADMINS_HEADING = "Share Administrators:"; +export const NSF_RECORD_USER_PERMISSIONS_HEADING = "User Permissions:"; + +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_REMOVALS = 500 + "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_REMOVALS = 500; diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 67b5a171..f0b14db8 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,458 +1,554 @@ import type { - DRecord, - DUser, - DKdFolder, - DKdFolderAccess, - DKdFolderRecord, - DKdRecordAccess, -} from '@keeper-security/keeperapi' -import { Folder, webSafe64FromBytes } from '@keeper-security/keeperapi' -import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { VaultObjectKind } from '../folders/folderHelpers' -import { KeeperSdkError, ResultCodes } from '../utils' -import { getRecordTitle } from '../records/RecordUtils' + DRecord, + DUser, + DKdFolder, + DKdFolderAccess, + DKdFolderRecord, + DKdRecordAccess, +} from "@keeper-security/keeperapi"; +import { Folder, webSafe64FromBytes } from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { VaultObjectKind } from "../folders/folderHelpers"; +import { KeeperSdkError, ResultCodes } from "../utils"; +import { getRecordTitle } from "../records/RecordUtils"; import { - NSF_ACCESS_ROLE_LABELS, - NSF_ACCESS_TYPE_LABELS, - NSF_LEGACY_FOLDER_MSG, - NSF_LEGACY_RECORD_MSG, - NSF_NOTE_FIELD_TYPES, - NSF_RECORD_DESCRIPTION_MAX_LENGTH, - NSF_SENSITIVE_FIELD_TYPES, -} from './nsfConstants' + NSF_ACCESS_ROLE_LABELS, + NSF_ACCESS_TYPE_LABELS, + NSF_LEGACY_FOLDER_MSG, + NSF_LEGACY_RECORD_MSG, + NSF_NOTE_FIELD_TYPES, + NSF_RECORD_DESCRIPTION_MAX_LENGTH, + NSF_SENSITIVE_FIELD_TYPES, +} 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 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)); } /** Per-account drive root UID inferred from sync (parentUid of top-level folders). */ -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 normalizeParentUid( - storage: InMemoryStorage, - parentUid: string | undefined | null + storage: InMemoryStorage, + parentUid: string | undefined | null, ): string { - 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) + return isRootFolderUid(storage, parentUid) + ? "root" + : (parentUid ?? "").trim(); } -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 isNestedShareFolder( + storage: InMemoryStorage, + folderUid: string, +): boolean { + if (isRootFolderUid(storage, folderUid)) return true; + return !!getKeeperDriveFolder(storage, folderUid); } -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 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, + ); } 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 -} - -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 - } + 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; +} + +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 +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 + 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 + const byUidOrName = resolveByUidOrName( + getKeeperDriveFolders(storage), + trimmed, + (folder) => folder.uid, + (folder) => folder.data.name || "", + ); + if (byUidOrName) return byUidOrName.uid; - return resolveFolderByPath(storage, trimmed) + return resolveFolderByPath(storage, trimmed); } -export function findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { - return storage - .getAll(KeeperDriveKind.FolderRecord) - .filter((entry) => entry.recordUid === recordUid) - .map((entry) => entry.folderUid) +export function findNestedShareFoldersForRecord( + storage: InMemoryStorage, + recordUid: string, +): string[] { + return storage + .getAll(KeeperDriveKind.FolderRecord) + .filter((entry) => entry.recordUid === recordUid) + .map((entry) => entry.folderUid); } 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 - )) - ) + 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, + )) + ); } 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 - )) - ) -} - -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 + 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; } -type FolderPermissionFlag = 'canRemove' | 'canDelete' +type FolderPermissionFlag = "canRemove" | "canDelete"; 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; + } + return false; } -function getRecordAccessEntries(storage: InMemoryStorage, recordUid: string): DKdRecordAccess[] { - return storage - .getAll(KeeperDriveKind.RecordAccess) - .filter((entry) => entry.recordUid === recordUid) +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 + 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.canDelete) return true; + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, "canDelete") + ) { + return true; } + return false; + } - return ( - !!folderUid && - !isRootFolderUid(storage, folderUid) && - hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') - ) + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, "canDelete") + ); } 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 - // Folder-trash and unlink are less destructive than owner-trash. Allow when the user - // can permanently delete the record, or owns it without explicit record-access entries - // (common for records in a personal drive root with no folder-access sync data). - 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; + // Folder-trash and unlink are less destructive than owner-trash. Allow when the user + // can permanently delete the record, or owns it without explicit record-access entries + // (common for records in a personal drive root with no folder-access sync data). + 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 formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): string { - if (role == null) return 'unknown' - return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}` +export function formatAccessRoleType( + role: Folder.AccessRoleType | null | undefined, +): string { + if (role == null) return "unknown"; + return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}`; } -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 findRecordFolderLocation(storage: InMemoryStorage, recordUid: string): string { - const folderUids = findNestedShareFoldersForRecord(storage, recordUid) - if (folderUids.length === 0) return 'root' - return getFolderDisplayName(storage, folderUids[0]) +export function getFolderDisplayName( + storage: InMemoryStorage, + folderUid: string, +): string { + if (isRootFolderUid(storage, folderUid)) return "root"; + return getKeeperDriveFolder(storage, folderUid)?.data.name ?? folderUid; } -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 findRecordFolderLocation( + storage: InMemoryStorage, + recordUid: string, +): string { + const folderUids = findNestedShareFoldersForRecord(storage, recordUid); + if (folderUids.length === 0) return "root"; + return getFolderDisplayName(storage, folderUids[0]); } -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 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 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 + storage: InMemoryStorage, + accessTypeUid: string, + folder?: DKdFolder, ): string { - 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 function folderAccessDisplayRole(entry: DKdFolderAccess): string { - if (entry.accessType === Folder.AccessType.AT_OWNER) return 'owner' - return formatAccessRoleType(entry.accessRoleType) + if (entry.accessType === Folder.AccessType.AT_OWNER) return "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 - ) + 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 - ) + return ( + entry.accessType === Folder.AccessType.AT_USER || + entry.accessType === Folder.AccessType.AT_OWNER + ); } - diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts index 595aee66..11dec177 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -1,255 +1,330 @@ -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_REMOVALS } from './nsfConstants' - -const { RemoveAction, RecordOperationType, RemoveStatus } = folder.v3.remove -const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] + folder, + normal64Bytes, + removeRecordMessage, + webSafe64FromBytes, +} from "@keeper-security/keeperapi"; +import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; +import { + checkFolderRemovePermission, + checkRecordDeletePermission, + ensureNestedShareRecord, + findNestedShareFoldersForRecord, + isRootFolderUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from "./nsfHelpers"; +import { NSF_MAX_REMOVALS } 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', + 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; +}; -const OPERATION_MAP: Record = { - [NsfRemoveOperation.Unlink]: RecordOperationType.UNLINK_FROM_FOLDER, - [NsfRemoveOperation.FolderTrash]: RecordOperationType.MOVE_TO_FOLDER_TRASH, - [NsfRemoveOperation.OwnerTrash]: RecordOperationType.MOVE_TO_OWNER_TRASH, -} +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 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_REMOVALS) { - throw new KeeperSdkError(`Maximum ${NSF_MAX_REMOVALS} 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_REMOVALS) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_REMOVALS} 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 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}`) - } - } - 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}`); + } + } + if (item.error?.message) { + lines.push(` Error: ${item.error.message}`); } - return lines.join('\n').trimEnd() + 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) + 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) + try { + const previewResponse = await executeRemove( + auth, + removals, + RemoveAction.REMOVE_ACTION_PREVIEW, + ); + const preview = (previewResponse.results ?? []).map(mapPreviewItem); - if (hasPreviewErrors(preview)) { - throw new KeeperSdkError(formatRemoveNsfPreview(preview) || 'Removal preview failed.', ResultCodes.NSF_REMOVE_FAILED) - } - - 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/platform/browser/platform.ts b/KeeperSdk/src/platform/browser/platform.ts index 95938970..7e331185 100644 --- a/KeeperSdk/src/platform/browser/platform.ts +++ b/KeeperSdk/src/platform/browser/platform.ts @@ -1,76 +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' +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 -} + process(data: Uint8Array): void; + finish(): void; + result: Uint8Array; +}; const asm = asmCrypto as typeof asmCrypto & { - HmacSha1: HmacCtor - HmacSha256: HmacCtor - HmacSha512: HmacCtor -} + HmacSha1: HmacCtor; + HmacSha256: HmacCtor; + HmacSha512: HmacCtor; +}; -const HMAC_IMPL: Record<'sha1' | 'sha256' | 'sha512', HmacCtor> = { - sha1: asm.HmacSha1, - sha256: asm.HmacSha256, - sha512: asm.HmacSha512, -} +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.' + "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.' + "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 */ - } + question(_prompt: string): Promise { + return Promise.reject( + new KeeperSdkError(BROWSER_READLINE_MSG, ResultCodes.USER_CANCELLED), + ); + } + close(): void { + /* noop */ + } } export const browserSdkPlatform: SdkPlatform = { - runtime: 'browser', + runtime: "browser", - delay(ms: number): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, ms)) - }, + delay(ms: number): Promise { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); + }, - createReadline(): SdkReadline { - return new BrowserReadline() - }, + 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 - }, + 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) - }, + 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() - }, -} + 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 index 32bac290..6b47cbfd 100644 --- a/KeeperSdk/src/platform/index.ts +++ b/KeeperSdk/src/platform/index.ts @@ -1,20 +1,22 @@ -import type { SdkPlatform } from './types' +import type { SdkPlatform } from "./types"; -let active: SdkPlatform | undefined +let active: SdkPlatform | undefined; -export type { SdkPlatform, SdkReadline, SdkRuntime } from './types' +export type { SdkPlatform, SdkReadline, SdkRuntime } from "./types"; export function connectSdkPlatform(platform: SdkPlatform): void { - active = platform + 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 + 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 + return active !== undefined; } diff --git a/KeeperSdk/src/platform/node/platform.ts b/KeeperSdk/src/platform/node/platform.ts index 9a3a9b60..15b0d6cb 100644 --- a/KeeperSdk/src/platform/node/platform.ts +++ b/KeeperSdk/src/platform/node/platform.ts @@ -1,42 +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' +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(), - } + 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', + runtime: "node", - delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) - }, + delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, - createReadline: nodeReadline, + createReadline: nodeReadline, - hmac(algorithm, key, data) { - const algo = algorithm.toLowerCase() - return new Uint8Array(createHmac(algo, Buffer.from(key)).update(data).digest()) - }, + 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) - }, + createFileConfigLoader(configDir?: string): ConfigLoader { + return new FileConfigLoader(configDir); + }, - createAuthUI(useConsoleAuth: boolean): AuthUI3 { - return useConsoleAuth ? new ConsoleAuthUI() : new UnavailableAuthUI() - }, -} + createAuthUI(useConsoleAuth: boolean): AuthUI3 { + return useConsoleAuth ? new ConsoleAuthUI() : new UnavailableAuthUI(); + }, +}; diff --git a/KeeperSdk/src/platform/types.ts b/KeeperSdk/src/platform/types.ts index 7816e006..a9087a04 100644 --- a/KeeperSdk/src/platform/types.ts +++ b/KeeperSdk/src/platform/types.ts @@ -1,25 +1,29 @@ -import type { AuthUI3 } from '@keeper-security/keeperapi' -import type { ConfigLoader } from '../auth/config' +import type { AuthUI3 } from "@keeper-security/keeperapi"; +import type { ConfigLoader } from "../auth/config"; -export type SdkRuntime = 'node' | 'browser' +export type SdkRuntime = "node" | "browser"; export interface SdkReadline { - question(prompt: string): Promise - close(): void + question(prompt: string): Promise; + close(): void; } /** Small platform surface for KeeperSdk (auth UI, config, TOTP, timers). */ export interface SdkPlatform { - readonly runtime: SdkRuntime + readonly runtime: SdkRuntime; - delay(ms: number): Promise + delay(ms: number): Promise; - createReadline(input?: unknown, output?: unknown): SdkReadline + createReadline(input?: unknown, output?: unknown): SdkReadline; - /** HMAC digest (e.g. SHA-1 for TOTP). */ - hmac(algorithm: 'sha1' | 'sha256' | 'sha512', key: Uint8Array, data: Uint8Array): Uint8Array + /** HMAC digest (e.g. SHA-1 for TOTP). */ + hmac( + algorithm: "sha1" | "sha256" | "sha512", + key: Uint8Array, + data: Uint8Array, + ): Uint8Array; - createFileConfigLoader(configDir?: string): ConfigLoader + createFileConfigLoader(configDir?: string): ConfigLoader; - createAuthUI(useConsoleAuth: boolean): AuthUI3 + createAuthUI(useConsoleAuth: boolean): AuthUI3; } diff --git a/KeeperSdk/src/records/RecordOperations.ts b/KeeperSdk/src/records/RecordOperations.ts index 00bf737e..98375c70 100644 --- a/KeeperSdk/src/records/RecordOperations.ts +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -1,589 +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, - storage: InMemoryStorage, - recordUid: string + auth: Auth, + storage: InMemoryStorage, + recordUid: string, ): Promise { - 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 } + 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 (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 (!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, + }; + } - return { uid, folderType: FolderKind.UserFolder, scopeUid: '' } + return { uid, folderType: FolderKind.UserFolder, scopeUid: "" }; } async function findAllRecordFolderLinks( - recordUid: string, - storage: InMemoryStorage + 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(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 sharedFolderRecord = storage - .getAll(VaultObjectKind.SharedFolderRecord) - .find((candidate) => candidate.recordUid === recordUid) - if (sharedFolderRecord) { - const info = resolveFolder(sharedFolderRecord.sharedFolderUid, storage) - const key = `${info.folderType}:${info.uid}` + 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) + seen.add(key); + links.push(info); } + } } + } - if (links.length === 0) { - links.push(resolveFolder('', storage)) + 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 links + if (links.length === 0) { + links.push(resolveFolder("", storage)); + } + + return links; } -async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorage): Promise { - const links = await findAllRecordFolderLinks(recordUid, storage) - return links[0]?.uid ?? '' +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 fd2f6439..d296d1c5 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -1,435 +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 || v === '') return [] - return Array.isArray(v) ? v : [v] + if (v == null || v === "") return []; + return Array.isArray(v) ? v : [v]; } function normalizeTypedFieldValue(value: unknown): any[] { - if (value == null || value === '') return [] - if (Array.isArray(value)) return value - return [value] + 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) + 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) + 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 (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(); } - 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 + } + 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 - } + 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; } - return undefined + } + 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 [] - } - } 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, - }) + 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: normalizeTypedFieldValue(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: 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: 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, - } - } - - 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 - } + 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; } + } - 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) { - const url = extractTotpUrlFromValue(v) - if (url) return url - } + 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 getExtraTotpUrl(record) + } + 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; } export function getRecordDescription(record: DRecord): string { - if (record.version === 6) return 'PAM Configuration' + if (record.version === 6) return "PAM Configuration"; - 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 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(" @ ") : ""; } -export function getRecordCategory(record: DRecord): 'Classic' | 'Nested' { - return record.isKeeperDriveData ? 'Nested' : 'Classic' +export function getRecordCategory(record: DRecord): "Classic" | "Nested" { + return record.isKeeperDriveData ? "Nested" : "Classic"; } -const wordCache = new WeakMap() +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 - } + 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))) - }) + 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.toLowerCase()) - return words + words.push(record.uid.toLowerCase()); + return words; } export type FormatRecordOptions = { - showDetails?: boolean - unmask?: boolean -} - -function resolveFormatRecordOptions(showDetailsOrOptions?: boolean | FormatRecordOptions): { - showDetails: boolean - unmask: boolean + 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, - } + if (typeof showDetailsOrOptions === "boolean") { + return { showDetails: showDetailsOrOptions, unmask: false }; + } + return { + showDetails: showDetailsOrOptions?.showDetails ?? false, + unmask: showDetailsOrOptions?.unmask ?? false, + }; } 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 + 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 + 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)`, - }) - } + 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 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}`) +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}`); } - 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`) - } - } + 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 92980757..acd9c99a 100644 --- a/KeeperSdk/src/records/Totp.ts +++ b/KeeperSdk/src/records/Totp.ts @@ -1,116 +1,123 @@ -import { getSdkPlatform } from '../platform' +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 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) - } + 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): 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 + 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 algo = params.algorithm.toLowerCase() as 'sha1' | 'sha256' | 'sha512' - const digest = getSdkPlatform().hmac(algo, key, counterToBuffer(counter)) + 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 index 17977855..866e5196 100644 --- a/KeeperSdk/src/records/listRecordsTable.ts +++ b/KeeperSdk/src/records/listRecordsTable.ts @@ -1,95 +1,103 @@ -import type { DRecord } from '@keeper-security/keeperapi' +import type { DRecord } from "@keeper-security/keeperapi"; import { - getRecordCategory, - getRecordDescription, - getRecordTitle, - getRecordType, -} from './RecordUtils' + getRecordCategory, + getRecordDescription, + getRecordTitle, + getRecordType, +} from "./RecordUtils"; -const DEFAULT_COLUMN_WIDTH = 40 -const MIN_TRUNCATE_PREFIX = 3 +const DEFAULT_COLUMN_WIDTH = 40; +const MIN_TRUNCATE_PREFIX = 3; export type FormattedRecordsListTable = { - headers: string[] - rows: string[][] -} + 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)}...` + 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' }) + 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 } = {} + 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 } + 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 } = {} + 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) + 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"); } export function renderRecordsListTable( - records: DRecord[], - options: { verbose?: boolean; columnWidth?: number } = {} + records: DRecord[], + options: { verbose?: boolean; columnWidth?: number } = {}, ): string { - return renderRecordsListAsciiTable(formatRecordsListTable(records, options)) + return renderRecordsListAsciiTable(formatRecordsListTable(records, options)); } diff --git a/KeeperSdk/src/roles/RoleManager.ts b/KeeperSdk/src/roles/RoleManager.ts index 673bb2c7..932f16e2 100644 --- a/KeeperSdk/src/roles/RoleManager.ts +++ b/KeeperSdk/src/roles/RoleManager.ts @@ -1,144 +1,163 @@ -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' + formatRoleView, + roleViewTable, + viewRole, + type FormatRoleViewOptions, + type FormattedRoleViewTable, + type RoleView, +} from "./viewRole"; import { - addRoles, - formatAddRoleResult, - renderAddRoleAsciiTable, - type AddRoleInput, - type AddRoleResult, - type FormattedAddRoleTable, -} from './addRole' + addRoles, + formatAddRoleResult, + renderAddRoleAsciiTable, + type AddRoleInput, + type AddRoleResult, + type FormattedAddRoleTable, +} from "./addRole"; import { - updateRoles, - formatUpdateRoleResult, - renderUpdateRoleAsciiTable, - type UpdateRoleInput, - type UpdateRoleResult, - type FormattedUpdateRoleTable, -} from './updateRole' + updateRoles, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + type UpdateRoleInput, + type UpdateRoleResult, + type FormattedUpdateRoleTable, +} from "./updateRole"; import { - deleteRoles, - formatDeleteRoleResult, - renderDeleteRoleAsciiTable, - type DeleteRoleInput, - type DeleteRoleResult, - type FormattedDeleteRoleTable, -} from './deleteRole' + deleteRoles, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + type DeleteRoleInput, + type DeleteRoleResult, + type FormattedDeleteRoleTable, +} from "./deleteRole"; -export type AuthProvider = () => Auth +export type AuthProvider = () => Auth; export class RoleManager { - private readonly authProvider: AuthProvider - private enterpriseData: EnterpriseDataManager | null = null - - constructor(authProvider: AuthProvider) { - this.authProvider = authProvider - } - - 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 renderRolesAsciiTable( - table: FormattedRolesTable, - options: { minColWidth?: number } = {} - ): string { - return renderRolesAsciiTable(table, options) - } - - public async viewRole(identifier: string): Promise { - return viewRole(this.requireAuth(), identifier) - } - - public formatRoleView(view: RoleView, options: FormatRoleViewOptions = {}): FormattedRoleViewTable { - return formatRoleView(view, options) - } - - 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 formatAddRoleResult(result: AddRoleResult): FormattedAddRoleTable { - return formatAddRoleResult(result) - } - - 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 formatUpdateRoleResult(result: UpdateRoleResult): FormattedUpdateRoleTable { - return formatUpdateRoleResult(result) - } - - 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 formatDeleteRoleResult(result: DeleteRoleResult): FormattedDeleteRoleTable { - return formatDeleteRoleResult(result) - } - - public renderDeleteRoleAsciiTable(table: FormattedDeleteRoleTable): string { - return renderDeleteRoleAsciiTable(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 readonly authProvider: AuthProvider; + private enterpriseData: EnterpriseDataManager | null = null; + + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider; + } + + 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 renderRolesAsciiTable( + table: FormattedRolesTable, + options: { minColWidth?: number } = {}, + ): string { + return renderRolesAsciiTable(table, options); + } + + public async viewRole(identifier: string): Promise { + return viewRole(this.requireAuth(), identifier); + } + + public formatRoleView( + view: RoleView, + options: FormatRoleViewOptions = {}, + ): FormattedRoleViewTable { + return formatRoleView(view, options); + } + + 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 formatAddRoleResult(result: AddRoleResult): FormattedAddRoleTable { + return formatAddRoleResult(result); + } + + 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 formatUpdateRoleResult( + result: UpdateRoleResult, + ): FormattedUpdateRoleTable { + return formatUpdateRoleResult(result); + } + + 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 formatDeleteRoleResult( + result: DeleteRoleResult, + ): FormattedDeleteRoleTable { + return formatDeleteRoleResult(result); + } + + public renderDeleteRoleAsciiTable(table: FormattedDeleteRoleTable): string { + return renderDeleteRoleAsciiTable(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; + } } 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/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..9d951a94 100644 --- a/KeeperSdk/src/roles/index.ts +++ b/KeeperSdk/src/roles/index.ts @@ -1,74 +1,78 @@ -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' + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + DEFAULT_ROLE_COLUMNS, + ALL_COLUMNS_WILDCARD, +} from "./roleTypes"; export type { - ListRolesOptions, - ListRoleRow, - RoleColumnInput, - FormattedRolesTable, - FormatRolesTableOptions, -} from './roleTypes' + ListRolesOptions, + ListRoleRow, + RoleColumnInput, + FormattedRolesTable, + FormatRolesTableOptions, +} from "./roleTypes"; -export { viewRole, formatRoleView, roleViewTable } from './viewRole' +export { viewRole, formatRoleView, roleViewTable } from "./viewRole"; export type { - RoleView, - RoleTeamInfo, - RoleUserInfo, - RoleManagedNodeInfo, - RoleEnforcementInfo, - FormatRoleViewOptions, - FormattedRoleViewTable, - FormattedManagedNodePrivilegeTable, - RoleViewTableRow, -} from './viewRole' + RoleView, + RoleTeamInfo, + RoleUserInfo, + RoleManagedNodeInfo, + RoleEnforcementInfo, + FormatRoleViewOptions, + FormattedRoleViewTable, + FormattedManagedNodePrivilegeTable, + RoleViewTableRow, +} from "./viewRole"; -export { RoleManager } from './RoleManager' +export { RoleManager } from "./RoleManager"; export { - addRoles, - formatAddRoleResult, - renderAddRoleAsciiTable, - AddRoleStatus, - AddRoleSkipReason, -} from './addRole' + addRoles, + formatAddRoleResult, + renderAddRoleAsciiTable, + AddRoleStatus, + AddRoleSkipReason, +} from "./addRole"; export type { - AddRoleInput, - AddRoleResult, - AddRoleItemResult, - AddRoleConfirm, - AddRoleConflictPrompt, - FormattedAddRoleTable, -} from './addRole' + AddRoleInput, + AddRoleResult, + AddRoleItemResult, + AddRoleConfirm, + AddRoleConflictPrompt, + FormattedAddRoleTable, +} from "./addRole"; export { - updateRoles, - formatUpdateRoleResult, - renderUpdateRoleAsciiTable, - UpdateRoleStatus, -} from './updateRole' + updateRoles, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + UpdateRoleStatus, +} from "./updateRole"; export type { - UpdateRoleInput, - UpdateRoleResult, - UpdateRoleItemResult, - FormattedUpdateRoleTable, -} from './updateRole' + UpdateRoleInput, + UpdateRoleResult, + UpdateRoleItemResult, + FormattedUpdateRoleTable, +} from "./updateRole"; export { - deleteRoles, - formatDeleteRoleResult, - renderDeleteRoleAsciiTable, - DeleteRoleStatus, -} from './deleteRole' + deleteRoles, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + DeleteRoleStatus, +} from "./deleteRole"; export type { - DeleteRoleInput, - DeleteRoleResult, - DeleteRoleItemResult, - FormattedDeleteRoleTable, -} from './deleteRole' + DeleteRoleInput, + DeleteRoleResult, + DeleteRoleItemResult, + FormattedDeleteRoleTable, +} from "./deleteRole"; -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/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/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..f088145d 100644 --- a/KeeperSdk/src/sharedFolders/SharedFolderManager.ts +++ b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts @@ -1,57 +1,64 @@ -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"; -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); + } } diff --git a/KeeperSdk/src/sharedFolders/listSharedFolders.ts b/KeeperSdk/src/sharedFolders/listSharedFolders.ts index f5e123a2..b674a116 100644 --- a/KeeperSdk/src/sharedFolders/listSharedFolders.ts +++ b/KeeperSdk/src/sharedFolders/listSharedFolders.ts @@ -1,201 +1,256 @@ import type { - 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' + 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 - roeEligible?: 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 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 countUsersForFolder( + storage: InMemoryStorage, + sharedFolderUid: string, +): number { + return countBySharedFolderUid( + storage.getAll(VaultObjectKind.SharedFolderUser), + sharedFolderUid, + ); } -function countRecordsForFolder(storage: InMemoryStorage, sharedFolderUid: string): number { - return countBySharedFolderUid( - storage.getAll(VaultObjectKind.SharedFolderRecord), - sharedFolderUid - ) +function countRecordsForFolder( + storage: InMemoryStorage, + sharedFolderUid: string, +): number { + return countBySharedFolderUid( + storage.getAll(VaultObjectKind.SharedFolderRecord), + sharedFolderUid, + ); } -function recordHasRotationConfigured(storage: InMemoryStorage, recordUid: string): boolean { - const rotation = storage.getByUid('record_rotation', recordUid) - return rotation != null && rotation.disabled !== true +function recordHasRotationConfigured( + storage: InMemoryStorage, + recordUid: string, +): boolean { + const rotation = storage.getByUid( + "record_rotation", + recordUid, + ); + return rotation != null && rotation.disabled !== true; } -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 +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, roeEligible = false } = options - let sharedFolders: DSharedFolder[] = pattern - ? findSharedFolders(storage, pattern) - : storage.getAll(FolderKind.SharedFolder) - - if (roeEligible) { - sharedFolders = sharedFolders.filter((folder) => - sharedFolderHasPamUserWithRotation(storage, folder.uid) - ) - } + 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' })) + 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..d229390b 100644 --- a/KeeperSdk/src/sharedFolders/shareFolder.ts +++ b/KeeperSdk/src/sharedFolders/shareFolder.ts @@ -1,457 +1,546 @@ import type { - Auth, - Authentication, - DSharedFolder, - DSharedFolderFolder, - DSharedFolderUser, - DUserFolder, -} from '@keeper-security/keeperapi' + Auth, + Authentication, + DSharedFolder, + DSharedFolderFolder, + DSharedFolderUser, + 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, +} from "@keeper-security/keeperapi"; +import { InMemoryStorage } from "../storage/InMemoryStorage"; +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 -} + rsaPublicKey: Uint8Array | null; + eccPublicKey: Uint8Array | null; + errorCode?: string; + message?: string; + username: string; +}; 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, - } - } - 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, - } - } +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, + }; } - 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, - } - } + } + 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, + }; } - 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, - } - } + } + 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, + }; } + } - return undefined + 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 +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; } 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 + 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 removeFromSharedFolder( - auth: Auth, - sharedFolder: DSharedFolder, - resolved: Extract, - emails: string[] + auth: Auth, + sharedFolder: DSharedFolder, + resolved: Extract< + ResolvedFolder, + { kind: FolderKind.SharedFolder | FolderKind.SharedFolderFolder } + >, + emails: string[], ): Promise { - const updateRequest: Folder.ISharedFolderUpdateV3Request = { - sharedFolderUid: normal64Bytes(sharedFolder.uid), - revision: sharedFolder.revision, - forceUpdate: false, - sharedFolderRemoveUser: emails, - } - - 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 innerResponse = (response.sharedFoldersUpdateV3Response || [])[0] - const requestOk = !innerResponse?.status || innerResponse.status === FolderResultStatus.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, - }) - } - - const allUsersOk = userResults.length > 0 && userResults.every((userResult) => userResult.success) - - const failureReason = !requestOk - ? innerResponse?.status || - `shared_folder_update_v3 (remove) 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, + sharedFolderRemoveUser: emails, + }; + + 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 (remove) failed for "${resolved.displayName}" (uid=${sharedFolder.uid}): ${extractErrorMessage(err)}`, + results: [], + }; + } + + const innerResponse = (response.sharedFoldersUpdateV3Response || [])[0]; + const requestOk = + !innerResponse?.status || + innerResponse.status === FolderResultStatus.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, + }); + } + + const allUsersOk = + userResults.length > 0 && + userResults.every((userResult) => userResult.success); + + 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 emails = dedupeEmails(input.emails) - if (emails.length === 0) { - throw new KeeperSdkError('Provide at least one user email.', 'no_emails') - } - - const invalidEmails = emails.filter((email) => !isValidEmail(email)) - if (invalidEmails.length > 0) { - throw new KeeperSdkError(`Invalid email(s): ${invalidEmails.join(', ')}`, 'invalid_email') - } - - if (input.action === ShareFolderAction.Remove) { - return removeFromSharedFolder(auth, sharedFolder, resolved, emails) - } - - 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 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 invalidEmails = emails.filter((email) => !isValidEmail(email)); + if (invalidEmails.length > 0) { + throw new KeeperSdkError( + `Invalid email(s): ${invalidEmails.join(", ")}`, + "invalid_email", + ); + } + + if (input.action === ShareFolderAction.Remove) { + return removeFromSharedFolder(auth, sharedFolder, resolved, emails); + } + + const existingMembers = new Set(); + for (const sharedFolderUser of storage.getAll( + VaultObjectKind.SharedFolderUser, + )) { + if ( + sharedFolderUser.sharedFolderUid === sharedFolder.uid && + sharedFolderUser.accountUsername + ) { + existingMembers.add(sharedFolderUser.accountUsername.toLowerCase()); } - - 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 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 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 (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, - } + if (publicKeys.errorCode) { + userResults.push({ + email, + success: false, + status: publicKeys.errorCode, + message: publicKeys.message || publicKeys.errorCode, + }); + continue; } - 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, - }) + 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 allUsersOk = userResults.length > 0 && 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 + usersToAdd.push({ + username: email, + manageRecords: toSetBoolean(newUserManageRecords), + manageUsers: toSetBoolean(newUserManageUsers), + typedSharedFolderKey: { encryptedKey, encryptedKeyType }, + }); + } + if (usersToAdd.length === 0 && usersToUpdate.length === 0) { 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: `No users could be processed for shared folder "${resolved.displayName}" (uid=${sharedFolder.uid}).`, + results: userResults, + }; + } + + 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; + + 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, + }; + } + + 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, + }); + } + + const allUsersOk = + userResults.length > 0 && + 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') - } - - 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) + 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); } diff --git a/KeeperSdk/src/sharing/Sharing.ts b/KeeperSdk/src/sharing/Sharing.ts index 9046f369..6ca20049 100644 --- a/KeeperSdk/src/sharing/Sharing.ts +++ b/KeeperSdk/src/sharing/Sharing.ts @@ -1,282 +1,310 @@ import { - Auth, - Records, - Authentication, - platform, - getPublicKeysMessage, - getRecordsDetailsMessage, - webSafe64FromBytes, - recordsShareUpdateMessage, - normal64Bytes, -} from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError } from '../utils/errors' + Auth, + Records, + Authentication, + platform, + getPublicKeysMessage, + getRecordsDetailsMessage, + webSafe64FromBytes, + recordsShareUpdateMessage, + normal64Bytes, +} from "@keeper-security/keeperapi"; +import { extractErrorMessage, KeeperSdkError } from "../utils/errors"; enum ShareStatus { - Success = 'success', - PendingAccept = 'pending_accept', - MissingPublicKey = 'missing_public_key', - Error = 'error', - Unknown = 'unknown', + Success = "success", + PendingAccept = "pending_accept", + MissingPublicKey = "missing_public_key", + Error = "error", + Unknown = "unknown", } export type ShareRecordInput = { - recordUid: string - email: string - canEdit?: boolean - canShare?: boolean -} + recordUid: string; + email: string; + canEdit?: boolean; + canShare?: boolean; +}; export type ShareRecordResult = { - recordUid: string - email: string - success: boolean - status: string - message: string -} + recordUid: string; + email: string; + success: boolean; + status: string; + message: string; +}; export type RemoveShareInput = { - recordUid: string - email: string -} + recordUid: string; + email: string; +}; export type RemoveShareResult = { - recordUid: string - email: string - success: boolean - status: string - message: string -} + recordUid: string; + email: string; + success: boolean; + status: string; + message: string; +}; type UserKeys = { - username: string - rsaPublicKey: Uint8Array | null - eccPublicKey: Uint8Array | null - errorCode: string | null -} + username: string; + rsaPublicKey: Uint8Array | null; + eccPublicKey: Uint8Array | null; + errorCode: string | null; +}; async function loadUserPublicKey(auth: Auth, email: string): Promise { - const msg = getPublicKeysMessage({ usernames: [email] }) - let response: Authentication.IGetPublicKeysResponse - - try { - response = await auth.executeRest(msg) - } catch (err) { - throw new KeeperSdkError(`Failed to fetch public key for ${email}: ${extractErrorMessage(err)}`) - } - - const keyResponses = response.keyResponses || [] - if (keyResponses.length === 0) { - throw new KeeperSdkError(`No public key returned for ${email}`, 'missing_public_key') - } - - const entry = keyResponses[0] - if (entry.errorCode) { - throw new KeeperSdkError( - `Public key lookup failed for ${email}: ${entry.errorCode} - ${entry.message || ''}`, - entry.errorCode - ) - } - - return { - username: entry.username || email, - 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, - } + const msg = getPublicKeysMessage({ usernames: [email] }); + let response: Authentication.IGetPublicKeysResponse; + + try { + response = await auth.executeRest(msg); + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch public key for ${email}: ${extractErrorMessage(err)}`, + ); + } + + const keyResponses = response.keyResponses || []; + if (keyResponses.length === 0) { + throw new KeeperSdkError( + `No public key returned for ${email}`, + "missing_public_key", + ); + } + + const entry = keyResponses[0]; + if (entry.errorCode) { + throw new KeeperSdkError( + `Public key lookup failed for ${email}: ${entry.errorCode} - ${entry.message || ""}`, + entry.errorCode, + ); + } + + return { + username: entry.username || email, + 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 shareRecord( - auth: Auth, - recordKey: Uint8Array, - input: ShareRecordInput + auth: Auth, + recordKey: Uint8Array, + input: ShareRecordInput, ): Promise { - const { recordUid, email, canEdit = false, canShare = false } = input - - const userKeys = await loadUserPublicKey(auth, email) - - let encryptedRecordKey: Uint8Array - let useEccKey = false - - if (userKeys.eccPublicKey) { - encryptedRecordKey = await platform.publicEncryptEC(recordKey, userKeys.eccPublicKey) - useEccKey = true - } else if (userKeys.rsaPublicKey) { - const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey) - encryptedRecordKey = platform.publicEncrypt(recordKey, rsaKeyBase64) - useEccKey = false - } else { - return { - recordUid, - email, - success: false, - status: ShareStatus.MissingPublicKey, - message: `No usable public key available for ${email}`, - } - } - - const sharedRecord: Records.ISharedRecord = { - toUsername: email, - recordUid: normal64Bytes(recordUid), - recordKey: encryptedRecordKey, - editable: canEdit, - shareable: canShare, - useEccKey, - } - - const msg = recordsShareUpdateMessage({ addSharedRecord: [sharedRecord] }) - - let response: Records.IRecordShareUpdateResponse - try { - response = await auth.executeRest(msg) - } catch (err) { - return { - recordUid, - email, - success: false, - status: ShareStatus.Error, - message: extractErrorMessage(err), - } - } - - const addStatuses = response.addSharedRecordStatus || [] - if (addStatuses.length > 0) { - const st = addStatuses[0] - const isSuccess = st.status === ShareStatus.Success || st.status === ShareStatus.PendingAccept - return { - recordUid, - email: st.username || email, - success: isSuccess, - status: st.status || ShareStatus.Unknown, - message: st.message || st.status || '', - } - } - + const { recordUid, email, canEdit = false, canShare = false } = input; + + const userKeys = await loadUserPublicKey(auth, email); + + let encryptedRecordKey: Uint8Array; + let useEccKey = false; + + if (userKeys.eccPublicKey) { + encryptedRecordKey = await platform.publicEncryptEC( + recordKey, + userKeys.eccPublicKey, + ); + useEccKey = true; + } else if (userKeys.rsaPublicKey) { + const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey); + encryptedRecordKey = platform.publicEncrypt(recordKey, rsaKeyBase64); + useEccKey = false; + } else { + return { + recordUid, + email, + success: false, + status: ShareStatus.MissingPublicKey, + message: `No usable public key available for ${email}`, + }; + } + + const sharedRecord: Records.ISharedRecord = { + toUsername: email, + recordUid: normal64Bytes(recordUid), + recordKey: encryptedRecordKey, + editable: canEdit, + shareable: canShare, + useEccKey, + }; + + const msg = recordsShareUpdateMessage({ addSharedRecord: [sharedRecord] }); + + let response: Records.IRecordShareUpdateResponse; + try { + response = await auth.executeRest(msg); + } catch (err) { + return { + recordUid, + email, + success: false, + status: ShareStatus.Error, + message: extractErrorMessage(err), + }; + } + + const addStatuses = response.addSharedRecordStatus || []; + if (addStatuses.length > 0) { + const st = addStatuses[0]; + const isSuccess = + st.status === ShareStatus.Success || + st.status === ShareStatus.PendingAccept; return { - recordUid, - email, - success: true, - status: ShareStatus.Success, - message: 'Record shared successfully', - } + recordUid, + email: st.username || email, + success: isSuccess, + status: st.status || ShareStatus.Unknown, + message: st.message || st.status || "", + }; + } + + return { + recordUid, + email, + success: true, + status: ShareStatus.Success, + message: "Record shared successfully", + }; } -export async function removeRecordShare(auth: Auth, input: RemoveShareInput): Promise { - const { recordUid, email } = input - - const msg = recordsShareUpdateMessage({ - removeSharedRecord: [ - { - toUsername: email, - recordUid: normal64Bytes(recordUid), - }, - ], - }) - - let response: Records.IRecordShareUpdateResponse - try { - response = await auth.executeRest(msg) - } catch (err) { - return { - recordUid, - email, - success: false, - status: ShareStatus.Error, - message: extractErrorMessage(err), - } - } - - const removeStatuses = response.removeSharedRecordStatus || [] - if (removeStatuses.length > 0) { - const st = removeStatuses[0] - return { - recordUid, - email: st.username || email, - success: st.status === ShareStatus.Success, - status: st.status || ShareStatus.Unknown, - message: st.message || st.status || '', - } - } +export async function removeRecordShare( + auth: Auth, + input: RemoveShareInput, +): Promise { + const { recordUid, email } = input; + const msg = recordsShareUpdateMessage({ + removeSharedRecord: [ + { + toUsername: email, + recordUid: normal64Bytes(recordUid), + }, + ], + }); + + let response: Records.IRecordShareUpdateResponse; + try { + response = await auth.executeRest(msg); + } catch (err) { return { - recordUid, - email, - success: true, - status: ShareStatus.Success, - message: 'Share removed successfully', - } + recordUid, + email, + success: false, + status: ShareStatus.Error, + message: extractErrorMessage(err), + }; + } + + const removeStatuses = response.removeSharedRecordStatus || []; + if (removeStatuses.length > 0) { + const st = removeStatuses[0]; + return { + recordUid, + email: st.username || email, + success: st.status === ShareStatus.Success, + status: st.status || ShareStatus.Unknown, + message: st.message || st.status || "", + }; + } + + return { + recordUid, + email, + success: true, + status: ShareStatus.Success, + message: "Share removed successfully", + }; } export type RecordUserPermission = { - username: string - accountUid?: string - owner: boolean - shareAdmin: boolean - shareable: boolean - editable: boolean - awaitingApproval: boolean - expiration?: number -} + username: string; + accountUid?: string; + owner: boolean; + shareAdmin: boolean; + shareable: boolean; + editable: boolean; + awaitingApproval: boolean; + expiration?: number; +}; export type RecordSharedFolderPermission = { - sharedFolderUid: string - resharable: boolean - editable: boolean - revision?: number - expiration?: number -} + sharedFolderUid: string; + resharable: boolean; + editable: boolean; + revision?: number; + expiration?: number; +}; export type RecordShareInfo = { - recordUid: string - userPermissions: RecordUserPermission[] - sharedFolderPermissions: RecordSharedFolderPermission[] -} + recordUid: string; + userPermissions: RecordUserPermission[]; + sharedFolderPermissions: RecordSharedFolderPermission[]; +}; function bytesToUid(bytes: Uint8Array | null | undefined): string | undefined { - return bytes && bytes.length > 0 ? webSafe64FromBytes(bytes) : undefined + return bytes && bytes.length > 0 ? webSafe64FromBytes(bytes) : undefined; } -function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { - if (value == null) return undefined - return typeof value === 'number' ? value : value.toNumber() +function longToNumber( + value: number | { toNumber: () => number } | null | undefined, +): number | undefined { + if (value == null) return undefined; + return typeof value === "number" ? value : value.toNumber(); } -export async function getRecordShareInfo(auth: Auth, recordUid: string): Promise { - const msg = getRecordsDetailsMessage({ - clientTime: Date.now(), - recordUid: [normal64Bytes(recordUid)], - recordDetailsInclude: Records.RecordDetailsInclude.SHARE_ONLY, - }) - - let response: Records.IGetRecordDataWithAccessInfoResponse - try { - response = await auth.executeRest(msg) - } catch (err) { - throw new KeeperSdkError( - `Failed to fetch share info for ${recordUid}: ${extractErrorMessage(err)}` - ) - } - - const detail = response.recordDataWithAccessInfo?.[0] - if (!detail) return null - - const userPermissions: RecordUserPermission[] = (detail.userPermission ?? []).map((u) => ({ - username: u.username || '', - accountUid: bytesToUid(u.accountUid), - owner: !!u.owner, - shareAdmin: !!u.shareAdmin, - shareable: !!u.sharable, - editable: !!u.editable, - awaitingApproval: !!u.awaitingApproval, - expiration: longToNumber(u.expiration as number | null | undefined), - })) - - const sharedFolderPermissions: RecordSharedFolderPermission[] = (detail.sharedFolderPermission ?? []).map((s) => ({ - sharedFolderUid: bytesToUid(s.sharedFolderUid) ?? '', - resharable: !!s.resharable, - editable: !!s.editable, - revision: longToNumber(s.revision as number | null | undefined), - expiration: longToNumber(s.expiration as number | null | undefined), - })) - - return { recordUid, userPermissions, sharedFolderPermissions } +export async function getRecordShareInfo( + auth: Auth, + recordUid: string, +): Promise { + const msg = getRecordsDetailsMessage({ + clientTime: Date.now(), + recordUid: [normal64Bytes(recordUid)], + recordDetailsInclude: Records.RecordDetailsInclude.SHARE_ONLY, + }); + + let response: Records.IGetRecordDataWithAccessInfoResponse; + try { + response = await auth.executeRest(msg); + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch share info for ${recordUid}: ${extractErrorMessage(err)}`, + ); + } + + const detail = response.recordDataWithAccessInfo?.[0]; + if (!detail) return null; + + const userPermissions: RecordUserPermission[] = ( + detail.userPermission ?? [] + ).map((u) => ({ + username: u.username || "", + accountUid: bytesToUid(u.accountUid), + owner: !!u.owner, + shareAdmin: !!u.shareAdmin, + shareable: !!u.sharable, + editable: !!u.editable, + awaitingApproval: !!u.awaitingApproval, + expiration: longToNumber(u.expiration as number | null | undefined), + })); + + const sharedFolderPermissions: RecordSharedFolderPermission[] = ( + detail.sharedFolderPermission ?? [] + ).map((s) => ({ + sharedFolderUid: bytesToUid(s.sharedFolderUid) ?? "", + resharable: !!s.resharable, + editable: !!s.editable, + revision: longToNumber(s.revision as number | null | undefined), + expiration: longToNumber(s.expiration as number | null | undefined), + })); + + return { recordUid, userPermissions, sharedFolderPermissions }; } diff --git a/KeeperSdk/src/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 0672fe21..24eb7450 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -1,170 +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 (!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) - } - } + 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); } - } - - 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 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..699320d8 100644 --- a/KeeperSdk/src/teams/listTeams.ts +++ b/KeeperSdk/src/teams/listTeams.ts @@ -1,373 +1,426 @@ -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, -] + TeamColumn.Restricts, + TeamColumn.Node, + TeamColumn.UserCount, + TeamColumn.RoleCount, +]; -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; + 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"]; +}; 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 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 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 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 }; } 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()); + 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..aee8b44a 100644 --- a/KeeperSdk/src/users/UserManager.ts +++ b/KeeperSdk/src/users/UserManager.ts @@ -1,149 +1,201 @@ -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 { + 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 + 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) - } - - public renderTeamUserAsciiTable(table: FormattedTeamUserTable): string { - return renderTeamUserAsciiTable(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 - } -} \ No newline at end of file + 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); + } + + 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/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 40847e5f..b362575d 100644 --- a/KeeperSdk/src/utils/Logger.ts +++ b/KeeperSdk/src/utils/Logger.ts @@ -1,78 +1,82 @@ 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 sanitizeArg(arg: unknown): unknown { - if (typeof arg !== 'string') return arg - return arg.replace(CREDENTIAL_PATTERN, (_, key: string) => `${key}=[REDACTED]`) + if (typeof arg !== "string") return arg; + return arg.replace( + CREDENTIAL_PATTERN, + (_, key: string) => `${key}=[REDACTED]`, + ); } 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 6678a70b..d89d2c5a 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,194 +1,194 @@ -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", } 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', - MissingKey = 'nsf_missing_key', + 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", + MissingKey = "nsf_missing_key", } 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 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", } export enum SyncErrorCode { - SyncFailed = 'sync_failed', + 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_MISSING_KEY: NsfErrorCode.MissingKey, - 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, - SYNC_FAILED: SyncErrorCode.SyncFailed, -} 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, + 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_MISSING_KEY: NsfErrorCode.MissingKey, + 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, + SYNC_FAILED: SyncErrorCode.SyncFailed, +} 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 6f20150a..ae56daf6 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -1,47 +1,67 @@ export { - SdkDefaults, - AuthDefaults, - ResultCodes, - AuthErrorCode, - SessionErrorCode, - ValidationErrorCode, - RoleErrorCode, - TeamErrorCode, - UserErrorCode, - NsfErrorCode, - KEEPER_PUBLIC_HOSTS, -} from './constants' -export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger } 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, + NsfErrorCode, + KEEPER_PUBLIC_HOSTS, +} from "./constants"; export { - EMAIL_PATTERN, - EMAIL_LIST_SEPARATOR_PATTERN, - TOKEN_SEPARATOR_PATTERN, - REGEX_ESCAPE_PATTERN, - TRAILING_EQUALS_PATTERN, - WHITESPACE_PATTERN, - isValidEmail, - escapeRegExp, - resolveSearchPattern, -} from './patterns' + Logger, + ConsoleLogger, + LogLevel, + logger, + setLogger, + getLogger, + resetLogger, +} from "./Logger"; +export type { ILogger } from "./Logger"; export { - DEFAULT_PASSWORD_LENGTH, - PW_SPECIAL_CHARACTERS, - GEN_PASSWORD_ALGORITHMS, - KeeperPasswordGenerator, - generatePasswordFromOptions, - resolveGenPasswordAlgorithm, - generatePassword, - parseGenParametersFromValue, - isGenerateFieldValue, - parseGeneratePasswordFlag, -} from './passwordGenerator' + 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' + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, +} from "./passwordGenerator"; diff --git a/KeeperSdk/src/utils/passwordGenerator.ts b/KeeperSdk/src/utils/passwordGenerator.ts index 26ebcd4a..947b1603 100644 --- a/KeeperSdk/src/utils/passwordGenerator.ts +++ b/KeeperSdk/src/utils/passwordGenerator.ts @@ -4,542 +4,624 @@ * Passphrase/dice/crypto/$GEN helpers align with Commander `generator.py`. */ -import { BIP39_WORDS, DICEWARE_WORDS } from './resources/wordlists' +import { BIP39_WORDS, DICEWARE_WORDS } from "./resources/wordlists"; /** Matches `CryptoUtils.SpecialCharacters` in keeper-sdk-dotnet. */ -export const PW_SPECIAL_CHARACTERS = '!@#$%()+;<>=?[]{}^.,' +export const PW_SPECIAL_CHARACTERS = "!@#$%()+;<>=?[]{}^.,"; -export const DEFAULT_PASSWORD_LENGTH = 20 -export const GEN_PASSWORD_ALGORITHMS = ['rand', 'dice', 'crypto', 'passphrase'] as const +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] +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 -} + 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 + 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 -} + 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 + 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 + 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 - } + 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 +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 (upper < 0 && lower < 0 && digit < 0 && special < 0) { - lower = length + if (left > 0 && upper > 0) { + let toSubtract = Math.ceil((upper / required) * extra); + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract); + upper -= toSubtract; + left -= toSubtract; + } } - - 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 - } - } + if (left > 0 && digit > 0) { + let toSubtract = Math.ceil((digit / required) * extra); + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract); + digit -= 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-- - } + if (left > 0 && special > 0) { + let toSubtract = Math.ceil((special / required) * extra); + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract); + special -= toSubtract; + left -= toSubtract; + } } - - 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)) - } + } + + 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('') + shuffleInPlace(buffer); + return buffer.join(""); } function policyToGenerationOptions( - policy: PasswordComplexityPolicy, - lengthOverride?: number | null + 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 + 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)) - } + 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 +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) + 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) - } + 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(' ') + 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)') + 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 + 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(', ')}.`, - } + 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 + 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() === '')) { + 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: - 'Incomplete $GEN:passphrase parameters: missing value after comma. ' + - 'Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number]', - } + options: empty, + error: `Passphrase word count must be between ${MIN_PASSPHRASE_WORD_COUNT} and ${MAX_PASSPHRASE_WORD_COUNT} (got ${wordCount}).`, + }; + } + idx++; } - 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 ")}.`, + }; } - 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++ + 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.`, + }; } - 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++ + 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.`, + }; } - 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 } + 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) + const numeric = parameters.find((p) => /^\d+$/.test(p.trim())); + if (!numeric) return null; + return Number(numeric); } function randomPasswordOptions( - length: number | null, - policy?: PasswordComplexityPolicy | null + 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 + 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 + 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 } - } - + 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: generatePasswordFromOptions(randomPasswordOptions(length, policy)), - error: null, - } - } catch (e) { - return { password: null, error: e instanceof Error ? e.message : String(e) } + 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()) + 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') + 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()) +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 fe15b54c..a4468bf4 100644 --- a/KeeperSdk/src/utils/patterns.ts +++ b/KeeperSdk/src/utils/patterns.ts @@ -1,36 +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; /** Sequence of one or more `=` characters at end of string (Base32 padding). */ -export const TRAILING_EQUALS_PATTERN = /=+$/g +export const TRAILING_EQUALS_PATTERN = /=+$/g; /** Any whitespace run. */ -export const WHITESPACE_PATTERN = /\s+/g +export const WHITESPACE_PATTERN = /\s+/g; export function isValidEmail(value: string): boolean { - return 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 index 340f887f..564e26f1 100644 --- a/KeeperSdk/src/utils/resources/wordlists.ts +++ b/KeeperSdk/src/utils/resources/wordlists.ts @@ -1,4 +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 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"] +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 af1a0092..e95bd8dc 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -1,967 +1,1157 @@ import { - Auth, - KeeperEnvironment, - syncDown, - DRecord, - DRecordMetadata, - DSharedFolder, - DTeam, - DUserFolder, - Authentication, - normal64Bytes, - platform, -} 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 { getSdkPlatform } from '../platform' + Auth, + KeeperEnvironment, + syncDown, + DRecord, + DRecordMetadata, + DSharedFolder, + DTeam, + DUserFolder, + Authentication, + normal64Bytes, + platform, +} 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 { getSdkPlatform } from "../platform"; +import { + toSessionParams, + type SessionRestoreInput, +} from "../auth/sessionRestore"; import { - toSessionParams, - type SessionRestoreInput, -} from '../auth/sessionRestore' -import { searchRecords, formatRecord, getRecordTitle, getRecordType } from '../records/RecordUtils' + 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' + addRecord as addRecordOp, + updateRecord as updateRecordOp, + deleteRecord as deleteRecordOp, + getRecordHistory as getRecordHistoryOp, + moveRecord as moveRecordOp, +} from "../records/RecordOperations"; import type { - NewRecordInput, - TypedRecordData, - AddRecordResult, - UpdateRecordResult, - DeleteRecordResult, - RecordHistoryResult, - MoveRecordInput, - MoveRecordResult, -} from '../records/RecordOperations' + NewRecordInput, + TypedRecordData, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from "../records/RecordOperations"; import { - shareRecord as shareRecordOp, - removeRecordShare as removeRecordShareOp, - getRecordShareInfo as getRecordShareInfoOp, -} from '../sharing/Sharing' + 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 { - 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 { 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' + 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"; +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, -} from '../roles' -import { UserManager } from '../users/UserManager' -import { NestedShareFolderManager } from '../nestedShareFolders/NestedShareFolderManager' -import type { ListNsfOptions, ListNsfRow, ListNsfFormatInput, FormattedListNsfTable } from '../nestedShareFolders/listNsf' -import type { GetNsfOptions, GetNsfResult } from '../nestedShareFolders/getNsf' -import type { LinkNsfRecordResult } from '../nestedShareFolders/linkNsfRecord' -import type { RemoveNsfRecordInput, RemoveNsfRecordResult } from '../nestedShareFolders/removeNsfRecord' + RoleManager, + type AddRoleInput, + type AddRoleResult, + type DeleteRoleInput, + type DeleteRoleResult, + type ListRoleRow, + type ListRolesOptions, + type RoleView, + type UpdateRoleInput, + type UpdateRoleResult, +} from "../roles"; +import { UserManager } from "../users/UserManager"; +import { NestedShareFolderManager } from "../nestedShareFolders/NestedShareFolderManager"; +import type { + ListNsfOptions, + ListNsfRow, + ListNsfFormatInput, + FormattedListNsfTable, +} from "../nestedShareFolders/listNsf"; +import type { GetNsfOptions, GetNsfResult } from "../nestedShareFolders/getNsf"; +import type { LinkNsfRecordResult } from "../nestedShareFolders/linkNsfRecord"; 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 { buildWhoamiInfo, type WhoamiInfo } from '../account/whoamiInfo' + RemoveNsfRecordInput, + RemoveNsfRecordResult, +} from "../nestedShareFolders/removeNsfRecord"; +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 { buildWhoamiInfo, type WhoamiInfo } from "../account/whoamiInfo"; import { - ConsoleLogger, - LogLevel, - KeeperSdkError, - extractErrorMessage, - extractResultCode, - SdkDefaults, - ResultCodes, -} from '../utils' -import type { ILogger } from '../utils' + 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 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 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.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 - } - - 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 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 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 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 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.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; + } + + 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 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 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 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; + } } diff --git a/KeeperSdk/tsconfig.json b/KeeperSdk/tsconfig.json index 15eafc2b..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": "src", - "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/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) { From f1c391fd67c0a0c349a67136c2c3f65921db1c9c Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Mon, 13 Jul 2026 14:37:51 +0530 Subject: [PATCH 22/48] added team key parsing while sharing record. --- KeeperSdk/src/sharedFolders/shareFolder.ts | 465 +++++++++++++++++++-- KeeperSdk/src/teams/listTeams.ts | 26 +- keeperapi/src/commands.ts | 18 + 3 files changed, 463 insertions(+), 46 deletions(-) diff --git a/KeeperSdk/src/sharedFolders/shareFolder.ts b/KeeperSdk/src/sharedFolders/shareFolder.ts index d229390b..fd947c74 100644 --- a/KeeperSdk/src/sharedFolders/shareFolder.ts +++ b/KeeperSdk/src/sharedFolders/shareFolder.ts @@ -3,7 +3,9 @@ import type { Authentication, DSharedFolder, DSharedFolderFolder, + DSharedFolderTeam, DSharedFolderUser, + DTeam, DUserFolder, } from "@keeper-security/keeperapi"; import { @@ -12,8 +14,15 @@ import { 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, @@ -80,14 +89,32 @@ type ResolvedFolder = } | { kind: FolderKind.UserFolder; folderUid: string; displayName: string }; -type UserPublicKeys = { +type TeamPublicKeys = { rsaPublicKey: Uint8Array | null; eccPublicKey: Uint8Array | null; - errorCode?: string; - message?: string; - username: string; + 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; @@ -226,17 +253,211 @@ async function fetchUserPublicKeys( return usernameToKeys; } -function dedupeEmails(emails: string[]): string[] { +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 dedupedEmails: string[] = []; - for (const rawEmail of emails) { - const normalized = (rawEmail || "").trim().toLowerCase(); + const deduped: string[] = []; + for (const rawTarget of targets) { + const normalized = (rawTarget || "").trim(); if (!normalized) continue; - if (seen.has(normalized)) continue; - seen.add(normalized); - dedupedEmails.push(normalized); + 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; + } + 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 [...new Set(resolved)]; +} + +async function loadTeamKeys( + auth: Auth, + teamUids: string[], +): Promise> { + const keysByTeam = new Map(); + if (teamUids.length === 0) return keysByTeam; + + 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.executeRestCommand( + teamGetKeysCommand(batch), + ); + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch team keys: ${extractErrorMessage(err)}`, + ); + } + + 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 keysByTeam; +} + +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 dedupedEmails; + return undefined; +} + +function teamUidFromStatus(teamUid: Uint8Array | null | undefined): string { + return teamUid && teamUid.length > 0 ? webSafe64FromBytes(teamUid) : ""; } async function removeFromSharedFolder( @@ -246,14 +467,30 @@ async function removeFromSharedFolder( ResolvedFolder, { kind: FolderKind.SharedFolder | FolderKind.SharedFolderFolder } >, - emails: string[], + userEmails: string[], + teamUids: string[], ): Promise { const updateRequest: Folder.ISharedFolderUpdateV3Request = { sharedFolderUid: normal64Bytes(sharedFolder.uid), revision: sharedFolder.revision, forceUpdate: false, - sharedFolderRemoveUser: emails, }; + if (userEmails.length > 0) updateRequest.sharedFolderRemoveUser = userEmails; + if (teamUids.length > 0) { + updateRequest.sharedFolderRemoveTeam = teamUids.map((teamUid) => + normal64Bytes(teamUid), + ); + } + + if ( + (updateRequest.sharedFolderRemoveUser || []).length === 0 && + (updateRequest.sharedFolderRemoveTeam || []).length === 0 + ) { + throw new KeeperSdkError( + "Provide at least one user or team to remove.", + "no_targets", + ); + } let response: Folder.ISharedFolderUpdateV3ResponseV2; try { @@ -285,10 +522,19 @@ async function removeFromSharedFolder( 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 allUsersOk = - userResults.length > 0 && - userResults.every((userResult) => userResult.success); + userResults.length === 0 + ? requestOk + : userResults.every((userResult) => userResult.success); const failureReason = !requestOk ? innerResponse?.status || @@ -332,21 +578,25 @@ async function shareWithSharedFolder( ); } - const emails = dedupeEmails(input.emails); - if (emails.length === 0) { - throw new KeeperSdkError("Provide at least one user email.", "no_emails"); - } - - const invalidEmails = emails.filter((email) => !isValidEmail(email)); - if (invalidEmails.length > 0) { + const targets = dedupeTargets(input.emails); + if (targets.length === 0) { throw new KeeperSdkError( - `Invalid email(s): ${invalidEmails.join(", ")}`, - "invalid_email", + "Provide at least one user email or team.", + "no_targets", ); } + const { userEmails, teamIdentifiers } = splitShareTargets(targets); + const teamUids = await resolveTeamUids(auth, storage, teamIdentifiers); + if (input.action === ShareFolderAction.Remove) { - return removeFromSharedFolder(auth, sharedFolder, resolved, emails); + return removeFromSharedFolder( + auth, + sharedFolder, + resolved, + userEmails, + teamUids, + ); } const existingMembers = new Set(); @@ -361,11 +611,29 @@ async function shareWithSharedFolder( } } - const newEmails = emails.filter((email) => !existingMembers.has(email)); + 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); const usersToAdd: Folder.ISharedFolderUpdateUser[] = []; const usersToUpdate: Folder.ISharedFolderUpdateUser[] = []; + const teamsToAdd: Folder.ISharedFolderUpdateTeam[] = []; + const teamsToUpdate: Folder.ISharedFolderUpdateTeam[] = []; const userResults: ShareFolderUserStatus[] = []; const newUserManageRecords = isBoolean(input.manageRecords) @@ -374,14 +642,30 @@ async function shareWithSharedFolder( 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 emails) { + for (const email of userEmails) { if (existingMembers.has(email)) { - usersToUpdate.push({ - username: email, - manageRecords: toSetBoolean(input.manageRecords), - manageUsers: toSetBoolean(input.manageUsers), - }); + if (hasPermissionChange) { + usersToUpdate.push({ + username: email, + manageRecords: toSetBoolean(input.manageRecords), + manageUsers: toSetBoolean(input.manageUsers), + }); + } else { + userResults.push({ + email, + success: true, + status: FolderResultStatus.Success, + }); + } continue; } @@ -440,13 +724,68 @@ async function shareWithSharedFolder( }); } - if (usersToAdd.length === 0 && usersToUpdate.length === 0) { + 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: teamUid, + success: true, + status: FolderResultStatus.Success, + }); + } + continue; + } + + 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; + } + + 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: false, + success: allOk, folderUid: resolved.folderUid, sharedFolderUid: sharedFolder.uid, folderKind: FolderKind.SharedFolder, - message: `No users could be processed for shared folder "${resolved.displayName}" (uid=${sharedFolder.uid}).`, + message: allOk + ? undefined + : `No users or teams could be processed for shared folder "${resolved.displayName}" (uid=${sharedFolder.uid}).`, results: userResults, }; } @@ -459,6 +798,9 @@ async function shareWithSharedFolder( 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 { @@ -502,10 +844,57 @@ async function shareWithSharedFolder( 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 && - userResults.every((userResult) => userResult.success); + userResults.length === 0 + ? requestOk + : userResults.every((userResult) => userResult.success); const failureReason = !requestOk ? innerResponse?.status || diff --git a/KeeperSdk/src/teams/listTeams.ts b/KeeperSdk/src/teams/listTeams.ts index 699320d8..929bebd0 100644 --- a/KeeperSdk/src/teams/listTeams.ts +++ b/KeeperSdk/src/teams/listTeams.ts @@ -27,12 +27,7 @@ export type TeamColumnInput = TeamColumn | `${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; @@ -55,6 +50,8 @@ export type ListTeamsOptions = { export type ListTeamRow = { team_uid: string; name: string; + /** Enterprise / company name (Commander list-team "Company" column). */ + company?: string; restricts?: string; node?: string; user_count?: number; @@ -70,6 +67,7 @@ export type FormattedTeamsTable = { export type FormatTeamsTableOptions = { columns?: ListTeamsOptions["columns"]; + usersColumnTitle?: string; }; type DecorateContext = { @@ -123,11 +121,13 @@ export async function listTeams( }; 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; @@ -145,15 +145,24 @@ export function formatTeamsTable( options: FormatTeamsTableOptions = {}, ): FormattedTeamsTable { 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) => HEADER_BY_COLUMN[column]), + ...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.team_uid, row.name]; + 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; }); @@ -266,6 +275,7 @@ function rowMatchesPattern(row: ListTeamRow, pattern: string): boolean { 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())); diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index 4b9bff28..96296d9c 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -179,6 +179,24 @@ export const syncDownCommand = (request: SyncDownRequest): RestCommand => createCommand(request, 'team_get_keys') + export type RoleEnforcementAddRequest = { role_id: number enforcement: string From 1c09853f8cb2a43efbc93ce5efda74c4459ea244 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Mon, 13 Jul 2026 14:40:10 +0530 Subject: [PATCH 23/48] added formatting changes --- KeeperSdk/src/api.ts | 5 +---- KeeperSdk/src/sharedFolders/shareFolder.ts | 9 +++++---- KeeperSdk/src/teams/listTeams.ts | 3 ++- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts index 7de9e954..341f0b98 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -283,10 +283,7 @@ export type { TeamViewTableRow, } from "./teams/viewTeam"; -export { - changeTeamRoles, - TeamRoleStatus, -} from "./teams/teamRole"; +export { changeTeamRoles, TeamRoleStatus } from "./teams/teamRole"; export type { ChangeTeamRolesInput, TeamRoleResult, diff --git a/KeeperSdk/src/sharedFolders/shareFolder.ts b/KeeperSdk/src/sharedFolders/shareFolder.ts index fd947c74..0c4d8a9a 100644 --- a/KeeperSdk/src/sharedFolders/shareFolder.ts +++ b/KeeperSdk/src/sharedFolders/shareFolder.ts @@ -350,9 +350,7 @@ async function loadTeamKeys( const batch = pending.splice(0, TEAM_GET_KEYS_BATCH_SIZE); let response; try { - response = await auth.executeRestCommand( - teamGetKeysCommand(batch), - ); + response = await auth.executeRestCommand(teamGetKeysCommand(batch)); } catch (err) { throw new KeeperSdkError( `Failed to fetch team keys: ${extractErrorMessage(err)}`, @@ -364,7 +362,10 @@ async function loadTeamKeys( if (!teamUid || !entry.key) continue; try { const encryptedKey = normal64Bytes(entry.key); - keysByTeam.set(teamUid, await decryptTeamKeyEntry(auth, encryptedKey, entry.type)); + keysByTeam.set( + teamUid, + await decryptTeamKeyEntry(auth, encryptedKey, entry.type), + ); } catch (err) { throw new KeeperSdkError( `Failed to decrypt team key for "${teamUid}": ${extractErrorMessage(err)}`, diff --git a/KeeperSdk/src/teams/listTeams.ts b/KeeperSdk/src/teams/listTeams.ts index 929bebd0..21a660b4 100644 --- a/KeeperSdk/src/teams/listTeams.ts +++ b/KeeperSdk/src/teams/listTeams.ts @@ -145,7 +145,8 @@ export function formatTeamsTable( options: FormatTeamsTableOptions = {}, ): FormattedTeamsTable { const columns = resolveColumns(options.columns); - const usersTitle = options.usersColumnTitle?.trim() || HEADER_BY_COLUMN[TeamColumn.Users]; + const usersTitle = + options.usersColumnTitle?.trim() || HEADER_BY_COLUMN[TeamColumn.Users]; const headers: string[] = [ "#", "Company", From dc5a17bdee1ea6d73be8836044ddb7c5e46e2402 Mon Sep 17 00:00:00 2001 From: Tyler Carson Date: Wed, 1 Jul 2026 12:13:31 -0700 Subject: [PATCH 24/48] Add get_configuration_controller and workflow API messages (#195) * Add get_configuration_controller and workflow API messages - Extended executeRouterRest to handle RestInMessage (action-only) callers - Remove unnecessary `export { $root as default }` in last proto file * format * don't need that --- keeperapi/package-lock.json | 4 +- keeperapi/package.json | 2 +- keeperapi/scripts/generate-proto.mjs | 6 +- keeperapi/src/__tests__/endpoint.test.ts | 14 +- keeperapi/src/auth.ts | 4 + keeperapi/src/endpoint.ts | 12 +- keeperapi/src/proto.d.ts | 1523 +++++++ keeperapi/src/proto/Remove.js | 2 - keeperapi/src/proto/Workflow.js | 4597 ++++++++++++++++++++++ keeperapi/src/proto/index.js | 1 + keeperapi/src/restMessages.ts | 65 + 11 files changed, 6221 insertions(+), 9 deletions(-) create mode 100644 keeperapi/src/proto/Workflow.js diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index 0f897012..48920dce 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1,12 +1,12 @@ { "name": "@keeper-security/keeperapi", - "version": "18.0.2", + "version": "18.0.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@keeper-security/keeperapi", - "version": "18.0.2", + "version": "18.0.3", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", diff --git a/keeperapi/package.json b/keeperapi/package.json index c3d59e74..f06bfe56 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.2", + "version": "18.0.3", "browser": "dist/browser/index.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", diff --git a/keeperapi/scripts/generate-proto.mjs b/keeperapi/scripts/generate-proto.mjs index 321a7d08..a1060d0a 100644 --- a/keeperapi/scripts/generate-proto.mjs +++ b/keeperapi/scripts/generate-proto.mjs @@ -26,6 +26,7 @@ const PROTO_FILES = [ 'router.proto', 'record_endpoints.proto', 'remove.proto', + 'workflow.proto', ] // Explicit filename overrides. 'folder' (lowercase) collides with 'Folder' on case-insensitive @@ -94,7 +95,10 @@ async function main() { for (let i = 0; i < splits.length; i++) { const { name, startLine } = splits[i] const endLine = i + 1 < splits.length ? splits[i + 1].startLine : lines.length - const body = lines.slice(startLine, endLine).join('\n') + const body = lines + .slice(startLine, endLine) + .filter((l) => l !== 'export { $root as default };') + .join('\n') const filename = FILENAME_OVERRIDES[name] ?? name writeFileSync( diff --git a/keeperapi/src/__tests__/endpoint.test.ts b/keeperapi/src/__tests__/endpoint.test.ts index a3dc50f2..de5ed0dc 100644 --- a/keeperapi/src/__tests__/endpoint.test.ts +++ b/keeperapi/src/__tests__/endpoint.test.ts @@ -9,7 +9,7 @@ import { ClientConfigurationInternal, TransmissionKey } from '../configuration' import { AllowedMlKemKeyIds, isAllowedEcKeyId, isAllowedMlKemKeyId } from '../transmissionKeys' import { KeeperError } from '../configuration' import { Authentication, Router, GraphSync, PAM } from '../proto' -import { startLoginMessage, pamGetLeafsMessage, getControllers } from '../restMessages' +import { startLoginMessage, pamGetLeafsMessage, getControllers, requestWorkflowAccessMessage } from '../restMessages' import { HPKE_ECDH_KYBER, Ciphersuite, MlKemVariant, mlKemKeygen, encodeMlKemPublicKeyToPem } from '../qrc' import { getKeeperMlKemKeyVariant } from '../transmissionKeys' import { KeeperHttpResponse } from '../commands' @@ -605,6 +605,18 @@ describe('executeRouterRest', () => { const message = getControllers() expect(message.path).toBe('pam/get_controllers') }) + + it('returns void for RestInMessage with empty 200 response', async () => { + jest.spyOn(platform, 'post').mockResolvedValue({ + data: new Uint8Array(0), + statusCode: 200, + headers: new Headers(), + }) + + const message = requestWorkflowAccessMessage({ resource: { value: new Uint8Array([1, 2, 3]) } }) + const result = await endpoint.executeRouterRest(message, sessionToken) + expect(result).toBeUndefined() + }) }) async function mockPlatformPost(_, requestBody): Promise { diff --git a/keeperapi/src/auth.ts b/keeperapi/src/auth.ts index d6e5616c..6723b3b5 100644 --- a/keeperapi/src/auth.ts +++ b/keeperapi/src/auth.ts @@ -1300,6 +1300,10 @@ export class Auth { return this.endpoint.executeRouterRest(message, this._sessionToken) } + async executeRouterRestAction(message: RestInMessage): Promise { + return this.endpoint.executeRouterRest(message, this._sessionToken) + } + async executeRestCommand(command: RestCommand): Promise { if (!command.baseRequest.username) { command.baseRequest.username = this._username diff --git a/keeperapi/src/endpoint.ts b/keeperapi/src/endpoint.ts index 26253004..2196eb25 100644 --- a/keeperapi/src/endpoint.ts +++ b/keeperapi/src/endpoint.ts @@ -193,7 +193,13 @@ export class KeeperEndpoint { async executeRouterRest( message: RestMessage | RestOutMessage, sessionToken: string - ): Promise { + ): Promise + async executeRouterRest(message: RestInMessage, sessionToken: string): Promise + async executeRouterRest(message: RestOutMessage, sessionToken: string): Promise + async executeRouterRest( + message: RestMessage | RestOutMessage | RestInMessage, + sessionToken: string + ): Promise { const transmissionKey = await this.getTransmissionKey() const sessionTokenBytes = normal64Bytes(sessionToken) const encryptedSessionToken = await platform.aesGcmEncrypt(sessionTokenBytes, transmissionKey.key) @@ -211,7 +217,8 @@ export class KeeperEndpoint { const startTime = Date.now() const response = await platform.post(url, encryptedPayload, headers) if (!response.data || response.data.length === 0) { - throw new Error(`Empty response from router for ${message.path}`) + if ('fromBytes' in message) throw new Error(`Empty response from router for ${message.path}`) + return } if (response.statusCode !== 200) { logger.debug(`← ${requestId} Response code:`, response.statusCode) @@ -233,6 +240,7 @@ export class KeeperEndpoint { } as KeeperError } const decryptedPayload = await platform.aesGcmDecrypt(routerResponse.encryptedPayload, transmissionKey.key) + if (!('fromBytes' in message)) return const result = message.fromBytes(decryptedPayload) if (isLevelEnabled('debug')) { logger.debug(...formatProto(`← ${requestId} ${formatTimeDiff(new Date(Date.now() - startTime))}s`, result)) diff --git a/keeperapi/src/proto.d.ts b/keeperapi/src/proto.d.ts index 3767a683..91690ea5 100644 --- a/keeperapi/src/proto.d.ts +++ b/keeperapi/src/proto.d.ts @@ -91277,3 +91277,1526 @@ export namespace folder { } } } + +/** Namespace Workflow. */ +export namespace Workflow { + + /** Properties of a WorkflowApprover. */ + interface IWorkflowApprover { + + /** WorkflowApprover user */ + user?: (string|null); + + /** WorkflowApprover userId */ + userId?: (number|null); + + /** WorkflowApprover teamUid */ + teamUid?: (Uint8Array|null); + + /** WorkflowApprover escalation */ + escalation?: (boolean|null); + + /** WorkflowApprover escalationAfterMs */ + escalationAfterMs?: (number|null); + } + + /** Represents a WorkflowApprover. */ + class WorkflowApprover implements IWorkflowApprover { + + /** + * Constructs a new WorkflowApprover. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowApprover); + + /** WorkflowApprover user. */ + public user?: (string|null); + + /** WorkflowApprover userId. */ + public userId?: (number|null); + + /** WorkflowApprover teamUid. */ + public teamUid?: (Uint8Array|null); + + /** WorkflowApprover escalation. */ + public escalation: boolean; + + /** WorkflowApprover escalationAfterMs. */ + public escalationAfterMs: number; + + /** WorkflowApprover approver. */ + public approver?: ("user"|"userId"|"teamUid"); + + /** + * Creates a new WorkflowApprover instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowApprover instance + */ + public static create(properties?: Workflow.IWorkflowApprover): Workflow.WorkflowApprover; + + /** + * Encodes the specified WorkflowApprover message. Does not implicitly {@link Workflow.WorkflowApprover.verify|verify} messages. + * @param message WorkflowApprover message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowApprover, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowApprover message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowApprover + * @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): Workflow.WorkflowApprover; + + /** + * Creates a WorkflowApprover message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowApprover + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowApprover; + + /** + * Creates a plain object from a WorkflowApprover message. Also converts values to other types if specified. + * @param message WorkflowApprover + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowApprover, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowApprover to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowApprover + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a WorkflowParameters. */ + interface IWorkflowParameters { + + /** WorkflowParameters resource */ + resource?: (GraphSync.IGraphSyncRef|null); + + /** WorkflowParameters approvalsNeeded */ + approvalsNeeded?: (number|null); + + /** WorkflowParameters checkoutNeeded */ + checkoutNeeded?: (boolean|null); + + /** WorkflowParameters startAccessOnApproval */ + startAccessOnApproval?: (boolean|null); + + /** WorkflowParameters requireReason */ + requireReason?: (boolean|null); + + /** WorkflowParameters requireTicket */ + requireTicket?: (boolean|null); + + /** WorkflowParameters requireMFA */ + requireMFA?: (boolean|null); + + /** WorkflowParameters accessLength */ + accessLength?: (number|null); + + /** WorkflowParameters allowedTimes */ + allowedTimes?: (Workflow.ITemporalAccessFilter|null); + } + + /** Represents a WorkflowParameters. */ + class WorkflowParameters implements IWorkflowParameters { + + /** + * Constructs a new WorkflowParameters. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowParameters); + + /** WorkflowParameters resource. */ + public resource?: (GraphSync.IGraphSyncRef|null); + + /** WorkflowParameters approvalsNeeded. */ + public approvalsNeeded: number; + + /** WorkflowParameters checkoutNeeded. */ + public checkoutNeeded: boolean; + + /** WorkflowParameters startAccessOnApproval. */ + public startAccessOnApproval: boolean; + + /** WorkflowParameters requireReason. */ + public requireReason: boolean; + + /** WorkflowParameters requireTicket. */ + public requireTicket: boolean; + + /** WorkflowParameters requireMFA. */ + public requireMFA: boolean; + + /** WorkflowParameters accessLength. */ + public accessLength: number; + + /** WorkflowParameters allowedTimes. */ + public allowedTimes?: (Workflow.ITemporalAccessFilter|null); + + /** + * Creates a new WorkflowParameters instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowParameters instance + */ + public static create(properties?: Workflow.IWorkflowParameters): Workflow.WorkflowParameters; + + /** + * Encodes the specified WorkflowParameters message. Does not implicitly {@link Workflow.WorkflowParameters.verify|verify} messages. + * @param message WorkflowParameters message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowParameters, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowParameters message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowParameters + * @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): Workflow.WorkflowParameters; + + /** + * Creates a WorkflowParameters message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowParameters + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowParameters; + + /** + * Creates a plain object from a WorkflowParameters message. Also converts values to other types if specified. + * @param message WorkflowParameters + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowParameters, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowParameters to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowParameters + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a WorkflowConfig. */ + interface IWorkflowConfig { + + /** WorkflowConfig parameters */ + parameters?: (Workflow.IWorkflowParameters|null); + + /** WorkflowConfig approvers */ + approvers?: (Workflow.IWorkflowApprover[]|null); + + /** WorkflowConfig createdOn */ + createdOn?: (number|null); + } + + /** Represents a WorkflowConfig. */ + class WorkflowConfig implements IWorkflowConfig { + + /** + * Constructs a new WorkflowConfig. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowConfig); + + /** WorkflowConfig parameters. */ + public parameters?: (Workflow.IWorkflowParameters|null); + + /** WorkflowConfig approvers. */ + public approvers: Workflow.IWorkflowApprover[]; + + /** WorkflowConfig createdOn. */ + public createdOn: number; + + /** + * Creates a new WorkflowConfig instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowConfig instance + */ + public static create(properties?: Workflow.IWorkflowConfig): Workflow.WorkflowConfig; + + /** + * Encodes the specified WorkflowConfig message. Does not implicitly {@link Workflow.WorkflowConfig.verify|verify} messages. + * @param message WorkflowConfig message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowConfig, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowConfig message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowConfig + * @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): Workflow.WorkflowConfig; + + /** + * Creates a WorkflowConfig message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowConfig + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowConfig; + + /** + * Creates a plain object from a WorkflowConfig message. Also converts values to other types if specified. + * @param message WorkflowConfig + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowConfig, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowConfig to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowConfig + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** WorkflowStage enum. */ + enum WorkflowStage { + WS_READY_TO_START = 0, + WS_STARTED = 1, + WS_NEEDS_ACTION = 2, + WS_WAITING = 3 + } + + /** AccessCondition enum. */ + enum AccessCondition { + AC_APPROVAL = 0, + AC_CHECKIN = 1, + AC_MFA = 2, + AC_TIME = 3, + AC_REASON = 4, + AC_TICKET = 5 + } + + /** Properties of a WorkflowStatus. */ + interface IWorkflowStatus { + + /** WorkflowStatus stage */ + stage?: (Workflow.WorkflowStage|null); + + /** WorkflowStatus conditions */ + conditions?: (Workflow.AccessCondition[]|null); + + /** WorkflowStatus approvedBy */ + approvedBy?: (Workflow.IWorkflowApproval[]|null); + + /** WorkflowStatus startedOn */ + startedOn?: (number|null); + + /** WorkflowStatus expiresOn */ + expiresOn?: (number|null); + + /** WorkflowStatus escalated */ + escalated?: (boolean|null); + + /** WorkflowStatus checkedOutBy */ + checkedOutBy?: (string|null); + + /** WorkflowStatus canForceCheckIn */ + canForceCheckIn?: (boolean|null); + } + + /** Represents a WorkflowStatus. */ + class WorkflowStatus implements IWorkflowStatus { + + /** + * Constructs a new WorkflowStatus. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowStatus); + + /** WorkflowStatus stage. */ + public stage: Workflow.WorkflowStage; + + /** WorkflowStatus conditions. */ + public conditions: Workflow.AccessCondition[]; + + /** WorkflowStatus approvedBy. */ + public approvedBy: Workflow.IWorkflowApproval[]; + + /** WorkflowStatus startedOn. */ + public startedOn: number; + + /** WorkflowStatus expiresOn. */ + public expiresOn: number; + + /** WorkflowStatus escalated. */ + public escalated: boolean; + + /** WorkflowStatus checkedOutBy. */ + public checkedOutBy: string; + + /** WorkflowStatus canForceCheckIn. */ + public canForceCheckIn: boolean; + + /** + * Creates a new WorkflowStatus instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowStatus instance + */ + public static create(properties?: Workflow.IWorkflowStatus): Workflow.WorkflowStatus; + + /** + * Encodes the specified WorkflowStatus message. Does not implicitly {@link Workflow.WorkflowStatus.verify|verify} messages. + * @param message WorkflowStatus message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowStatus, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowStatus message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowStatus + * @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): Workflow.WorkflowStatus; + + /** + * Creates a WorkflowStatus message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowStatus + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowStatus; + + /** + * Creates a plain object from a WorkflowStatus message. Also converts values to other types if specified. + * @param message WorkflowStatus + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowStatus, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowStatus to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowStatus + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a WorkflowProcess. */ + interface IWorkflowProcess { + + /** WorkflowProcess flowUid */ + flowUid?: (Uint8Array|null); + + /** WorkflowProcess userId */ + userId?: (number|null); + + /** WorkflowProcess resource */ + resource?: (GraphSync.IGraphSyncRef|null); + + /** WorkflowProcess startedOn */ + startedOn?: (number|null); + + /** WorkflowProcess expiresOn */ + expiresOn?: (number|null); + + /** WorkflowProcess reason */ + reason?: (Uint8Array|null); + + /** WorkflowProcess mfaVerified */ + mfaVerified?: (boolean|null); + + /** WorkflowProcess externalRef */ + externalRef?: (Uint8Array|null); + + /** WorkflowProcess user */ + user?: (string|null); + + /** WorkflowProcess workflowParameters */ + workflowParameters?: (NotificationCenter.INotificationParameter[]|null); + + /** WorkflowProcess escalated */ + escalated?: (boolean|null); + + /** WorkflowProcess ephemeral */ + ephemeral?: (boolean|null); + } + + /** Represents a WorkflowProcess. */ + class WorkflowProcess implements IWorkflowProcess { + + /** + * Constructs a new WorkflowProcess. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowProcess); + + /** WorkflowProcess flowUid. */ + public flowUid: Uint8Array; + + /** WorkflowProcess userId. */ + public userId: number; + + /** WorkflowProcess resource. */ + public resource?: (GraphSync.IGraphSyncRef|null); + + /** WorkflowProcess startedOn. */ + public startedOn: number; + + /** WorkflowProcess expiresOn. */ + public expiresOn: number; + + /** WorkflowProcess reason. */ + public reason: Uint8Array; + + /** WorkflowProcess mfaVerified. */ + public mfaVerified: boolean; + + /** WorkflowProcess externalRef. */ + public externalRef: Uint8Array; + + /** WorkflowProcess user. */ + public user: string; + + /** WorkflowProcess workflowParameters. */ + public workflowParameters: NotificationCenter.INotificationParameter[]; + + /** WorkflowProcess escalated. */ + public escalated: boolean; + + /** WorkflowProcess ephemeral. */ + public ephemeral: boolean; + + /** + * Creates a new WorkflowProcess instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowProcess instance + */ + public static create(properties?: Workflow.IWorkflowProcess): Workflow.WorkflowProcess; + + /** + * Encodes the specified WorkflowProcess message. Does not implicitly {@link Workflow.WorkflowProcess.verify|verify} messages. + * @param message WorkflowProcess message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowProcess, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowProcess message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowProcess + * @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): Workflow.WorkflowProcess; + + /** + * Creates a WorkflowProcess message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowProcess + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowProcess; + + /** + * Creates a plain object from a WorkflowProcess message. Also converts values to other types if specified. + * @param message WorkflowProcess + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowProcess, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowProcess to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowProcess + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a WorkflowApproval. */ + interface IWorkflowApproval { + + /** WorkflowApproval userId */ + userId?: (number|null); + + /** WorkflowApproval user */ + user?: (string|null); + + /** WorkflowApproval flowUid */ + flowUid?: (Uint8Array|null); + + /** WorkflowApproval approvedOn */ + approvedOn?: (number|null); + } + + /** Represents a WorkflowApproval. */ + class WorkflowApproval implements IWorkflowApproval { + + /** + * Constructs a new WorkflowApproval. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowApproval); + + /** WorkflowApproval userId. */ + public userId: number; + + /** WorkflowApproval user. */ + public user: string; + + /** WorkflowApproval flowUid. */ + public flowUid: Uint8Array; + + /** WorkflowApproval approvedOn. */ + public approvedOn: number; + + /** + * Creates a new WorkflowApproval instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowApproval instance + */ + public static create(properties?: Workflow.IWorkflowApproval): Workflow.WorkflowApproval; + + /** + * Encodes the specified WorkflowApproval message. Does not implicitly {@link Workflow.WorkflowApproval.verify|verify} messages. + * @param message WorkflowApproval message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowApproval, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowApproval message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowApproval + * @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): Workflow.WorkflowApproval; + + /** + * Creates a WorkflowApproval message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowApproval + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowApproval; + + /** + * Creates a plain object from a WorkflowApproval message. Also converts values to other types if specified. + * @param message WorkflowApproval + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowApproval, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowApproval to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowApproval + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a WorkflowContext. */ + interface IWorkflowContext { + + /** WorkflowContext workflowConfig */ + workflowConfig?: (Workflow.IWorkflowConfig|null); + + /** WorkflowContext workflow */ + workflow?: (Workflow.IWorkflowProcess|null); + + /** WorkflowContext approvals */ + approvals?: (Workflow.IWorkflowApproval[]|null); + + /** WorkflowContext blocker */ + blocker?: (Workflow.IWorkflowProcess|null); + } + + /** Represents a WorkflowContext. */ + class WorkflowContext implements IWorkflowContext { + + /** + * Constructs a new WorkflowContext. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowContext); + + /** WorkflowContext workflowConfig. */ + public workflowConfig?: (Workflow.IWorkflowConfig|null); + + /** WorkflowContext workflow. */ + public workflow?: (Workflow.IWorkflowProcess|null); + + /** WorkflowContext approvals. */ + public approvals: Workflow.IWorkflowApproval[]; + + /** WorkflowContext blocker. */ + public blocker?: (Workflow.IWorkflowProcess|null); + + /** + * Creates a new WorkflowContext instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowContext instance + */ + public static create(properties?: Workflow.IWorkflowContext): Workflow.WorkflowContext; + + /** + * Encodes the specified WorkflowContext message. Does not implicitly {@link Workflow.WorkflowContext.verify|verify} messages. + * @param message WorkflowContext message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowContext, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowContext message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowContext + * @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): Workflow.WorkflowContext; + + /** + * Creates a WorkflowContext message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowContext + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowContext; + + /** + * Creates a plain object from a WorkflowContext message. Also converts values to other types if specified. + * @param message WorkflowContext + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowContext, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowContext to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowContext + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a WorkflowState. */ + interface IWorkflowState { + + /** WorkflowState flowUid */ + flowUid?: (Uint8Array|null); + + /** WorkflowState resource */ + resource?: (GraphSync.IGraphSyncRef|null); + + /** WorkflowState status */ + status?: (Workflow.IWorkflowStatus|null); + } + + /** Represents a WorkflowState. */ + class WorkflowState implements IWorkflowState { + + /** + * Constructs a new WorkflowState. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowState); + + /** WorkflowState flowUid. */ + public flowUid: Uint8Array; + + /** WorkflowState resource. */ + public resource?: (GraphSync.IGraphSyncRef|null); + + /** WorkflowState status. */ + public status?: (Workflow.IWorkflowStatus|null); + + /** + * Creates a new WorkflowState instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowState instance + */ + public static create(properties?: Workflow.IWorkflowState): Workflow.WorkflowState; + + /** + * Encodes the specified WorkflowState message. Does not implicitly {@link Workflow.WorkflowState.verify|verify} messages. + * @param message WorkflowState message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowState, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowState message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowState + * @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): Workflow.WorkflowState; + + /** + * Creates a WorkflowState message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowState + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowState; + + /** + * Creates a plain object from a WorkflowState message. Also converts values to other types if specified. + * @param message WorkflowState + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowState, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowState to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowState + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a WorkflowAccessRequest. */ + interface IWorkflowAccessRequest { + + /** WorkflowAccessRequest resource */ + resource?: (GraphSync.IGraphSyncRef|null); + + /** WorkflowAccessRequest reason */ + reason?: (Uint8Array|null); + + /** WorkflowAccessRequest ticket */ + ticket?: (Uint8Array|null); + } + + /** Represents a WorkflowAccessRequest. */ + class WorkflowAccessRequest implements IWorkflowAccessRequest { + + /** + * Constructs a new WorkflowAccessRequest. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowAccessRequest); + + /** WorkflowAccessRequest resource. */ + public resource?: (GraphSync.IGraphSyncRef|null); + + /** WorkflowAccessRequest reason. */ + public reason: Uint8Array; + + /** WorkflowAccessRequest ticket. */ + public ticket: Uint8Array; + + /** + * Creates a new WorkflowAccessRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowAccessRequest instance + */ + public static create(properties?: Workflow.IWorkflowAccessRequest): Workflow.WorkflowAccessRequest; + + /** + * Encodes the specified WorkflowAccessRequest message. Does not implicitly {@link Workflow.WorkflowAccessRequest.verify|verify} messages. + * @param message WorkflowAccessRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowAccessRequest, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowAccessRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowAccessRequest + * @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): Workflow.WorkflowAccessRequest; + + /** + * Creates a WorkflowAccessRequest message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowAccessRequest + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowAccessRequest; + + /** + * Creates a plain object from a WorkflowAccessRequest message. Also converts values to other types if specified. + * @param message WorkflowAccessRequest + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowAccessRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowAccessRequest to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowAccessRequest + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a WorkflowApprovalOrDenial. */ + interface IWorkflowApprovalOrDenial { + + /** WorkflowApprovalOrDenial flowUid */ + flowUid?: (Uint8Array|null); + + /** WorkflowApprovalOrDenial deny */ + deny?: (boolean|null); + + /** WorkflowApprovalOrDenial denialReason */ + denialReason?: (Uint8Array|null); + } + + /** Represents a WorkflowApprovalOrDenial. */ + class WorkflowApprovalOrDenial implements IWorkflowApprovalOrDenial { + + /** + * Constructs a new WorkflowApprovalOrDenial. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IWorkflowApprovalOrDenial); + + /** WorkflowApprovalOrDenial flowUid. */ + public flowUid: Uint8Array; + + /** WorkflowApprovalOrDenial deny. */ + public deny: boolean; + + /** WorkflowApprovalOrDenial denialReason. */ + public denialReason: Uint8Array; + + /** + * Creates a new WorkflowApprovalOrDenial instance using the specified properties. + * @param [properties] Properties to set + * @returns WorkflowApprovalOrDenial instance + */ + public static create(properties?: Workflow.IWorkflowApprovalOrDenial): Workflow.WorkflowApprovalOrDenial; + + /** + * Encodes the specified WorkflowApprovalOrDenial message. Does not implicitly {@link Workflow.WorkflowApprovalOrDenial.verify|verify} messages. + * @param message WorkflowApprovalOrDenial message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IWorkflowApprovalOrDenial, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WorkflowApprovalOrDenial message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WorkflowApprovalOrDenial + * @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): Workflow.WorkflowApprovalOrDenial; + + /** + * Creates a WorkflowApprovalOrDenial message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WorkflowApprovalOrDenial + */ + public static fromObject(object: { [k: string]: any }): Workflow.WorkflowApprovalOrDenial; + + /** + * Creates a plain object from a WorkflowApprovalOrDenial message. Also converts values to other types if specified. + * @param message WorkflowApprovalOrDenial + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.WorkflowApprovalOrDenial, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WorkflowApprovalOrDenial to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for WorkflowApprovalOrDenial + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a UserAccessState. */ + interface IUserAccessState { + + /** UserAccessState workflows */ + workflows?: (Workflow.IWorkflowState[]|null); + } + + /** Represents a UserAccessState. */ + class UserAccessState implements IUserAccessState { + + /** + * Constructs a new UserAccessState. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IUserAccessState); + + /** UserAccessState workflows. */ + public workflows: Workflow.IWorkflowState[]; + + /** + * Creates a new UserAccessState instance using the specified properties. + * @param [properties] Properties to set + * @returns UserAccessState instance + */ + public static create(properties?: Workflow.IUserAccessState): Workflow.UserAccessState; + + /** + * Encodes the specified UserAccessState message. Does not implicitly {@link Workflow.UserAccessState.verify|verify} messages. + * @param message UserAccessState message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IUserAccessState, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a UserAccessState message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns UserAccessState + * @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): Workflow.UserAccessState; + + /** + * Creates a UserAccessState message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns UserAccessState + */ + public static fromObject(object: { [k: string]: any }): Workflow.UserAccessState; + + /** + * Creates a plain object from a UserAccessState message. Also converts values to other types if specified. + * @param message UserAccessState + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.UserAccessState, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this UserAccessState to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for UserAccessState + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of an ApprovalRequests. */ + interface IApprovalRequests { + + /** ApprovalRequests workflows */ + workflows?: (Workflow.IWorkflowProcess[]|null); + } + + /** Represents an ApprovalRequests. */ + class ApprovalRequests implements IApprovalRequests { + + /** + * Constructs a new ApprovalRequests. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IApprovalRequests); + + /** ApprovalRequests workflows. */ + public workflows: Workflow.IWorkflowProcess[]; + + /** + * Creates a new ApprovalRequests instance using the specified properties. + * @param [properties] Properties to set + * @returns ApprovalRequests instance + */ + public static create(properties?: Workflow.IApprovalRequests): Workflow.ApprovalRequests; + + /** + * Encodes the specified ApprovalRequests message. Does not implicitly {@link Workflow.ApprovalRequests.verify|verify} messages. + * @param message ApprovalRequests message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IApprovalRequests, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes an ApprovalRequests message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns ApprovalRequests + * @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): Workflow.ApprovalRequests; + + /** + * Creates an ApprovalRequests message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns ApprovalRequests + */ + public static fromObject(object: { [k: string]: any }): Workflow.ApprovalRequests; + + /** + * Creates a plain object from an ApprovalRequests message. Also converts values to other types if specified. + * @param message ApprovalRequests + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.ApprovalRequests, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this ApprovalRequests to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for ApprovalRequests + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a TimeOfDayRange. */ + interface ITimeOfDayRange { + + /** TimeOfDayRange startTime */ + startTime?: (number|null); + + /** TimeOfDayRange endTime */ + endTime?: (number|null); + } + + /** Represents a TimeOfDayRange. */ + class TimeOfDayRange implements ITimeOfDayRange { + + /** + * Constructs a new TimeOfDayRange. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.ITimeOfDayRange); + + /** TimeOfDayRange startTime. */ + public startTime: number; + + /** TimeOfDayRange endTime. */ + public endTime: number; + + /** + * Creates a new TimeOfDayRange instance using the specified properties. + * @param [properties] Properties to set + * @returns TimeOfDayRange instance + */ + public static create(properties?: Workflow.ITimeOfDayRange): Workflow.TimeOfDayRange; + + /** + * Encodes the specified TimeOfDayRange message. Does not implicitly {@link Workflow.TimeOfDayRange.verify|verify} messages. + * @param message TimeOfDayRange message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.ITimeOfDayRange, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a TimeOfDayRange message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns TimeOfDayRange + * @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): Workflow.TimeOfDayRange; + + /** + * Creates a TimeOfDayRange message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns TimeOfDayRange + */ + public static fromObject(object: { [k: string]: any }): Workflow.TimeOfDayRange; + + /** + * Creates a plain object from a TimeOfDayRange message. Also converts values to other types if specified. + * @param message TimeOfDayRange + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.TimeOfDayRange, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this TimeOfDayRange to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for TimeOfDayRange + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** DayOfWeek enum. */ + enum DayOfWeek { + DAY_OF_WEEK_UNSPECIFIED = 0, + MONDAY = 1, + TUESDAY = 2, + WEDNESDAY = 3, + THURSDAY = 4, + FRIDAY = 5, + SATURDAY = 6, + SUNDAY = 7 + } + + /** ApprovalQueueKind enum. */ + enum ApprovalQueueKind { + AQK_APPROVAL = 0, + AQK_ESCALATION = 1 + } + + /** Properties of an ApprovalQueueEntry. */ + interface IApprovalQueueEntry { + + /** ApprovalQueueEntry flowRef */ + flowRef?: (GraphSync.IGraphSyncRef|null); + + /** ApprovalQueueEntry approverRef */ + approverRef?: (GraphSync.IGraphSyncRef|null); + + /** ApprovalQueueEntry kind */ + kind?: (Workflow.ApprovalQueueKind|null); + + /** ApprovalQueueEntry notifyAtMs */ + notifyAtMs?: (number|null); + + /** ApprovalQueueEntry requesterUserId */ + requesterUserId?: (number|null); + + /** ApprovalQueueEntry predefinedNotificationUid */ + predefinedNotificationUid?: (Uint8Array|null); + } + + /** Represents an ApprovalQueueEntry. */ + class ApprovalQueueEntry implements IApprovalQueueEntry { + + /** + * Constructs a new ApprovalQueueEntry. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IApprovalQueueEntry); + + /** ApprovalQueueEntry flowRef. */ + public flowRef?: (GraphSync.IGraphSyncRef|null); + + /** ApprovalQueueEntry approverRef. */ + public approverRef?: (GraphSync.IGraphSyncRef|null); + + /** ApprovalQueueEntry kind. */ + public kind: Workflow.ApprovalQueueKind; + + /** ApprovalQueueEntry notifyAtMs. */ + public notifyAtMs: number; + + /** ApprovalQueueEntry requesterUserId. */ + public requesterUserId?: (number|null); + + /** ApprovalQueueEntry predefinedNotificationUid. */ + public predefinedNotificationUid?: (Uint8Array|null); + + /** + * Creates a new ApprovalQueueEntry instance using the specified properties. + * @param [properties] Properties to set + * @returns ApprovalQueueEntry instance + */ + public static create(properties?: Workflow.IApprovalQueueEntry): Workflow.ApprovalQueueEntry; + + /** + * Encodes the specified ApprovalQueueEntry message. Does not implicitly {@link Workflow.ApprovalQueueEntry.verify|verify} messages. + * @param message ApprovalQueueEntry message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IApprovalQueueEntry, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes an ApprovalQueueEntry message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns ApprovalQueueEntry + * @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): Workflow.ApprovalQueueEntry; + + /** + * Creates an ApprovalQueueEntry message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns ApprovalQueueEntry + */ + public static fromObject(object: { [k: string]: any }): Workflow.ApprovalQueueEntry; + + /** + * Creates a plain object from an ApprovalQueueEntry message. Also converts values to other types if specified. + * @param message ApprovalQueueEntry + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.ApprovalQueueEntry, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this ApprovalQueueEntry to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for ApprovalQueueEntry + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a TemporalAccessFilter. */ + interface ITemporalAccessFilter { + + /** TemporalAccessFilter timeRanges */ + timeRanges?: (Workflow.ITimeOfDayRange[]|null); + + /** TemporalAccessFilter allowedDays */ + allowedDays?: (Workflow.DayOfWeek[]|null); + + /** TemporalAccessFilter timeZone */ + timeZone?: (string|null); + } + + /** Represents a TemporalAccessFilter. */ + class TemporalAccessFilter implements ITemporalAccessFilter { + + /** + * Constructs a new TemporalAccessFilter. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.ITemporalAccessFilter); + + /** TemporalAccessFilter timeRanges. */ + public timeRanges: Workflow.ITimeOfDayRange[]; + + /** TemporalAccessFilter allowedDays. */ + public allowedDays: Workflow.DayOfWeek[]; + + /** TemporalAccessFilter timeZone. */ + public timeZone: string; + + /** + * Creates a new TemporalAccessFilter instance using the specified properties. + * @param [properties] Properties to set + * @returns TemporalAccessFilter instance + */ + public static create(properties?: Workflow.ITemporalAccessFilter): Workflow.TemporalAccessFilter; + + /** + * Encodes the specified TemporalAccessFilter message. Does not implicitly {@link Workflow.TemporalAccessFilter.verify|verify} messages. + * @param message TemporalAccessFilter message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.ITemporalAccessFilter, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a TemporalAccessFilter message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns TemporalAccessFilter + * @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): Workflow.TemporalAccessFilter; + + /** + * Creates a TemporalAccessFilter message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns TemporalAccessFilter + */ + public static fromObject(object: { [k: string]: any }): Workflow.TemporalAccessFilter; + + /** + * Creates a plain object from a TemporalAccessFilter message. Also converts values to other types if specified. + * @param message TemporalAccessFilter + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.TemporalAccessFilter, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this TemporalAccessFilter to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for TemporalAccessFilter + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of an AuthorizedUsers. */ + interface IAuthorizedUsers { + + /** AuthorizedUsers username */ + username?: (string[]|null); + } + + /** Represents an AuthorizedUsers. */ + class AuthorizedUsers implements IAuthorizedUsers { + + /** + * Constructs a new AuthorizedUsers. + * @param [properties] Properties to set + */ + constructor(properties?: Workflow.IAuthorizedUsers); + + /** AuthorizedUsers username. */ + public username: string[]; + + /** + * Creates a new AuthorizedUsers instance using the specified properties. + * @param [properties] Properties to set + * @returns AuthorizedUsers instance + */ + public static create(properties?: Workflow.IAuthorizedUsers): Workflow.AuthorizedUsers; + + /** + * Encodes the specified AuthorizedUsers message. Does not implicitly {@link Workflow.AuthorizedUsers.verify|verify} messages. + * @param message AuthorizedUsers message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: Workflow.IAuthorizedUsers, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes an AuthorizedUsers message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns AuthorizedUsers + * @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): Workflow.AuthorizedUsers; + + /** + * Creates an AuthorizedUsers message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns AuthorizedUsers + */ + public static fromObject(object: { [k: string]: any }): Workflow.AuthorizedUsers; + + /** + * Creates a plain object from an AuthorizedUsers message. Also converts values to other types if specified. + * @param message AuthorizedUsers + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: Workflow.AuthorizedUsers, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this AuthorizedUsers to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for AuthorizedUsers + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } +} diff --git a/keeperapi/src/proto/Remove.js b/keeperapi/src/proto/Remove.js index 8ef56a7f..82045ec2 100644 --- a/keeperapi/src/proto/Remove.js +++ b/keeperapi/src/proto/Remove.js @@ -4943,5 +4943,3 @@ export const folder = $root.folder = (() => { return folder; })(); - -export { $root as default }; diff --git a/keeperapi/src/proto/Workflow.js b/keeperapi/src/proto/Workflow.js new file mode 100644 index 00000000..5cb05206 --- /dev/null +++ b/keeperapi/src/proto/Workflow.js @@ -0,0 +1,4597 @@ +/*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars*/ +import { $protobuf, $Reader, $Writer, $util, $root } from './root.js'; + +export const Workflow = $root.Workflow = (() => { + + /** + * Namespace Workflow. + * @exports Workflow + * @namespace + */ + const Workflow = {}; + + Workflow.WorkflowApprover = (function() { + + /** + * Properties of a WorkflowApprover. + * @memberof Workflow + * @interface IWorkflowApprover + * @property {string|null} [user] WorkflowApprover user + * @property {number|null} [userId] WorkflowApprover userId + * @property {Uint8Array|null} [teamUid] WorkflowApprover teamUid + * @property {boolean|null} [escalation] WorkflowApprover escalation + * @property {number|null} [escalationAfterMs] WorkflowApprover escalationAfterMs + */ + + /** + * Constructs a new WorkflowApprover. + * @memberof Workflow + * @classdesc Represents a WorkflowApprover. + * @implements IWorkflowApprover + * @constructor + * @param {Workflow.IWorkflowApprover=} [properties] Properties to set + */ + function WorkflowApprover(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]]; + } + + /** + * WorkflowApprover user. + * @member {string|null|undefined} user + * @memberof Workflow.WorkflowApprover + * @instance + */ + WorkflowApprover.prototype.user = null; + + /** + * WorkflowApprover userId. + * @member {number|null|undefined} userId + * @memberof Workflow.WorkflowApprover + * @instance + */ + WorkflowApprover.prototype.userId = null; + + /** + * WorkflowApprover teamUid. + * @member {Uint8Array|null|undefined} teamUid + * @memberof Workflow.WorkflowApprover + * @instance + */ + WorkflowApprover.prototype.teamUid = null; + + /** + * WorkflowApprover escalation. + * @member {boolean} escalation + * @memberof Workflow.WorkflowApprover + * @instance + */ + WorkflowApprover.prototype.escalation = false; + + /** + * WorkflowApprover escalationAfterMs. + * @member {number} escalationAfterMs + * @memberof Workflow.WorkflowApprover + * @instance + */ + WorkflowApprover.prototype.escalationAfterMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + /** + * WorkflowApprover approver. + * @member {"user"|"userId"|"teamUid"|undefined} approver + * @memberof Workflow.WorkflowApprover + * @instance + */ + Object.defineProperty(WorkflowApprover.prototype, "approver", { + get: $util.oneOfGetter($oneOfFields = ["user", "userId", "teamUid"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Creates a new WorkflowApprover instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowApprover + * @static + * @param {Workflow.IWorkflowApprover=} [properties] Properties to set + * @returns {Workflow.WorkflowApprover} WorkflowApprover instance + */ + WorkflowApprover.create = function create(properties) { + return new WorkflowApprover(properties); + }; + + /** + * Encodes the specified WorkflowApprover message. Does not implicitly {@link Workflow.WorkflowApprover.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowApprover + * @static + * @param {Workflow.IWorkflowApprover} message WorkflowApprover message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowApprover.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.user != null && Object.hasOwnProperty.call(message, "user")) + writer.uint32(/* id 1, wireType 2 =*/10).string(message.user); + if (message.userId != null && Object.hasOwnProperty.call(message, "userId")) + writer.uint32(/* id 2, wireType 0 =*/16).int32(message.userId); + if (message.teamUid != null && Object.hasOwnProperty.call(message, "teamUid")) + writer.uint32(/* id 3, wireType 2 =*/26).bytes(message.teamUid); + if (message.escalation != null && Object.hasOwnProperty.call(message, "escalation")) + writer.uint32(/* id 4, wireType 0 =*/32).bool(message.escalation); + if (message.escalationAfterMs != null && Object.hasOwnProperty.call(message, "escalationAfterMs")) + writer.uint32(/* id 5, wireType 0 =*/40).int64(message.escalationAfterMs); + return writer; + }; + + /** + * Decodes a WorkflowApprover message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowApprover + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowApprover} WorkflowApprover + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowApprover.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.Workflow.WorkflowApprover(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.user = reader.string(); + break; + } + case 2: { + message.userId = reader.int32(); + break; + } + case 3: { + message.teamUid = reader.bytes(); + break; + } + case 4: { + message.escalation = reader.bool(); + break; + } + case 5: { + message.escalationAfterMs = reader.int64(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowApprover message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowApprover + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowApprover} WorkflowApprover + */ + WorkflowApprover.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowApprover) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowApprover: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowApprover(); + if (object.user != null) + message.user = String(object.user); + if (object.userId != null) + message.userId = object.userId | 0; + if (object.teamUid != null) + if (typeof object.teamUid === "string") + $util.base64.decode(object.teamUid, message.teamUid = $util.newBuffer($util.base64.length(object.teamUid)), 0); + else if (object.teamUid.length >= 0) + message.teamUid = object.teamUid; + if (object.escalation != null) + message.escalation = Boolean(object.escalation); + if (object.escalationAfterMs != null) + if ($util.Long) + message.escalationAfterMs = $util.Long.fromValue(object.escalationAfterMs, false); + else if (typeof object.escalationAfterMs === "string") + message.escalationAfterMs = parseInt(object.escalationAfterMs, 10); + else if (typeof object.escalationAfterMs === "number") + message.escalationAfterMs = object.escalationAfterMs; + else if (typeof object.escalationAfterMs === "object") + message.escalationAfterMs = new $util.LongBits(object.escalationAfterMs.low >>> 0, object.escalationAfterMs.high >>> 0).toNumber(); + return message; + }; + + /** + * Creates a plain object from a WorkflowApprover message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowApprover + * @static + * @param {Workflow.WorkflowApprover} message WorkflowApprover + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowApprover.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.escalation = false; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.escalationAfterMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.escalationAfterMs = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + } + if (message.user != null && Object.hasOwnProperty.call(message, "user")) { + object.user = message.user; + if (options.oneofs) + object.approver = "user"; + } + if (message.userId != null && Object.hasOwnProperty.call(message, "userId")) { + object.userId = message.userId; + if (options.oneofs) + object.approver = "userId"; + } + if (message.teamUid != null && Object.hasOwnProperty.call(message, "teamUid")) { + object.teamUid = options.bytes === String ? $util.base64.encode(message.teamUid, 0, message.teamUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.teamUid) : message.teamUid; + if (options.oneofs) + object.approver = "teamUid"; + } + if (message.escalation != null && Object.hasOwnProperty.call(message, "escalation")) + object.escalation = message.escalation; + if (message.escalationAfterMs != null && Object.hasOwnProperty.call(message, "escalationAfterMs")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.escalationAfterMs = typeof message.escalationAfterMs === "number" ? BigInt(message.escalationAfterMs) : $util.Long.fromBits(message.escalationAfterMs.low >>> 0, message.escalationAfterMs.high >>> 0, false).toBigInt(); + else if (typeof message.escalationAfterMs === "number") + object.escalationAfterMs = options.longs === String ? String(message.escalationAfterMs) : message.escalationAfterMs; + else + object.escalationAfterMs = options.longs === String ? $util.Long.prototype.toString.call(message.escalationAfterMs) : options.longs === Number ? new $util.LongBits(message.escalationAfterMs.low >>> 0, message.escalationAfterMs.high >>> 0).toNumber() : message.escalationAfterMs; + return object; + }; + + /** + * Converts this WorkflowApprover to JSON. + * @function toJSON + * @memberof Workflow.WorkflowApprover + * @instance + * @returns {Object.} JSON object + */ + WorkflowApprover.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowApprover + * @function getTypeUrl + * @memberof Workflow.WorkflowApprover + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowApprover.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowApprover"; + }; + + return WorkflowApprover; + })(); + + Workflow.WorkflowParameters = (function() { + + /** + * Properties of a WorkflowParameters. + * @memberof Workflow + * @interface IWorkflowParameters + * @property {GraphSync.IGraphSyncRef|null} [resource] WorkflowParameters resource + * @property {number|null} [approvalsNeeded] WorkflowParameters approvalsNeeded + * @property {boolean|null} [checkoutNeeded] WorkflowParameters checkoutNeeded + * @property {boolean|null} [startAccessOnApproval] WorkflowParameters startAccessOnApproval + * @property {boolean|null} [requireReason] WorkflowParameters requireReason + * @property {boolean|null} [requireTicket] WorkflowParameters requireTicket + * @property {boolean|null} [requireMFA] WorkflowParameters requireMFA + * @property {number|null} [accessLength] WorkflowParameters accessLength + * @property {Workflow.ITemporalAccessFilter|null} [allowedTimes] WorkflowParameters allowedTimes + */ + + /** + * Constructs a new WorkflowParameters. + * @memberof Workflow + * @classdesc Represents a WorkflowParameters. + * @implements IWorkflowParameters + * @constructor + * @param {Workflow.IWorkflowParameters=} [properties] Properties to set + */ + function WorkflowParameters(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]]; + } + + /** + * WorkflowParameters resource. + * @member {GraphSync.IGraphSyncRef|null|undefined} resource + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.resource = null; + + /** + * WorkflowParameters approvalsNeeded. + * @member {number} approvalsNeeded + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.approvalsNeeded = 0; + + /** + * WorkflowParameters checkoutNeeded. + * @member {boolean} checkoutNeeded + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.checkoutNeeded = false; + + /** + * WorkflowParameters startAccessOnApproval. + * @member {boolean} startAccessOnApproval + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.startAccessOnApproval = false; + + /** + * WorkflowParameters requireReason. + * @member {boolean} requireReason + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.requireReason = false; + + /** + * WorkflowParameters requireTicket. + * @member {boolean} requireTicket + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.requireTicket = false; + + /** + * WorkflowParameters requireMFA. + * @member {boolean} requireMFA + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.requireMFA = false; + + /** + * WorkflowParameters accessLength. + * @member {number} accessLength + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.accessLength = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * WorkflowParameters allowedTimes. + * @member {Workflow.ITemporalAccessFilter|null|undefined} allowedTimes + * @memberof Workflow.WorkflowParameters + * @instance + */ + WorkflowParameters.prototype.allowedTimes = null; + + /** + * Creates a new WorkflowParameters instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowParameters + * @static + * @param {Workflow.IWorkflowParameters=} [properties] Properties to set + * @returns {Workflow.WorkflowParameters} WorkflowParameters instance + */ + WorkflowParameters.create = function create(properties) { + return new WorkflowParameters(properties); + }; + + /** + * Encodes the specified WorkflowParameters message. Does not implicitly {@link Workflow.WorkflowParameters.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowParameters + * @static + * @param {Workflow.IWorkflowParameters} message WorkflowParameters message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowParameters.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.resource != null && Object.hasOwnProperty.call(message, "resource")) + $root.GraphSync.GraphSyncRef.encode(message.resource, writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.approvalsNeeded != null && Object.hasOwnProperty.call(message, "approvalsNeeded")) + writer.uint32(/* id 2, wireType 0 =*/16).int32(message.approvalsNeeded); + if (message.checkoutNeeded != null && Object.hasOwnProperty.call(message, "checkoutNeeded")) + writer.uint32(/* id 3, wireType 0 =*/24).bool(message.checkoutNeeded); + if (message.startAccessOnApproval != null && Object.hasOwnProperty.call(message, "startAccessOnApproval")) + writer.uint32(/* id 4, wireType 0 =*/32).bool(message.startAccessOnApproval); + if (message.requireReason != null && Object.hasOwnProperty.call(message, "requireReason")) + writer.uint32(/* id 5, wireType 0 =*/40).bool(message.requireReason); + if (message.requireTicket != null && Object.hasOwnProperty.call(message, "requireTicket")) + writer.uint32(/* id 6, wireType 0 =*/48).bool(message.requireTicket); + if (message.requireMFA != null && Object.hasOwnProperty.call(message, "requireMFA")) + writer.uint32(/* id 7, wireType 0 =*/56).bool(message.requireMFA); + if (message.accessLength != null && Object.hasOwnProperty.call(message, "accessLength")) + writer.uint32(/* id 8, wireType 0 =*/64).int64(message.accessLength); + if (message.allowedTimes != null && Object.hasOwnProperty.call(message, "allowedTimes")) + $root.Workflow.TemporalAccessFilter.encode(message.allowedTimes, writer.uint32(/* id 9, wireType 2 =*/74).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a WorkflowParameters message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowParameters + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowParameters} WorkflowParameters + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowParameters.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.Workflow.WorkflowParameters(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.resource = $root.GraphSync.GraphSyncRef.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 2: { + message.approvalsNeeded = reader.int32(); + break; + } + case 3: { + message.checkoutNeeded = reader.bool(); + break; + } + case 4: { + message.startAccessOnApproval = reader.bool(); + break; + } + case 5: { + message.requireReason = reader.bool(); + break; + } + case 6: { + message.requireTicket = reader.bool(); + break; + } + case 7: { + message.requireMFA = reader.bool(); + break; + } + case 8: { + message.accessLength = reader.int64(); + break; + } + case 9: { + message.allowedTimes = $root.Workflow.TemporalAccessFilter.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowParameters message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowParameters + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowParameters} WorkflowParameters + */ + WorkflowParameters.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowParameters) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowParameters: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowParameters(); + if (object.resource != null) { + if (!$util.isObject(object.resource)) + throw TypeError(".Workflow.WorkflowParameters.resource: object expected"); + message.resource = $root.GraphSync.GraphSyncRef.fromObject(object.resource, long + 1); + } + if (object.approvalsNeeded != null) + message.approvalsNeeded = object.approvalsNeeded | 0; + if (object.checkoutNeeded != null) + message.checkoutNeeded = Boolean(object.checkoutNeeded); + if (object.startAccessOnApproval != null) + message.startAccessOnApproval = Boolean(object.startAccessOnApproval); + if (object.requireReason != null) + message.requireReason = Boolean(object.requireReason); + if (object.requireTicket != null) + message.requireTicket = Boolean(object.requireTicket); + if (object.requireMFA != null) + message.requireMFA = Boolean(object.requireMFA); + if (object.accessLength != null) + if ($util.Long) + message.accessLength = $util.Long.fromValue(object.accessLength, false); + else if (typeof object.accessLength === "string") + message.accessLength = parseInt(object.accessLength, 10); + else if (typeof object.accessLength === "number") + message.accessLength = object.accessLength; + else if (typeof object.accessLength === "object") + message.accessLength = new $util.LongBits(object.accessLength.low >>> 0, object.accessLength.high >>> 0).toNumber(); + if (object.allowedTimes != null) { + if (!$util.isObject(object.allowedTimes)) + throw TypeError(".Workflow.WorkflowParameters.allowedTimes: object expected"); + message.allowedTimes = $root.Workflow.TemporalAccessFilter.fromObject(object.allowedTimes, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a WorkflowParameters message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowParameters + * @static + * @param {Workflow.WorkflowParameters} message WorkflowParameters + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowParameters.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.resource = null; + object.approvalsNeeded = 0; + object.checkoutNeeded = false; + object.startAccessOnApproval = false; + object.requireReason = false; + object.requireTicket = false; + object.requireMFA = false; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.accessLength = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.accessLength = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + object.allowedTimes = null; + } + if (message.resource != null && Object.hasOwnProperty.call(message, "resource")) + object.resource = $root.GraphSync.GraphSyncRef.toObject(message.resource, options, q + 1); + if (message.approvalsNeeded != null && Object.hasOwnProperty.call(message, "approvalsNeeded")) + object.approvalsNeeded = message.approvalsNeeded; + if (message.checkoutNeeded != null && Object.hasOwnProperty.call(message, "checkoutNeeded")) + object.checkoutNeeded = message.checkoutNeeded; + if (message.startAccessOnApproval != null && Object.hasOwnProperty.call(message, "startAccessOnApproval")) + object.startAccessOnApproval = message.startAccessOnApproval; + if (message.requireReason != null && Object.hasOwnProperty.call(message, "requireReason")) + object.requireReason = message.requireReason; + if (message.requireTicket != null && Object.hasOwnProperty.call(message, "requireTicket")) + object.requireTicket = message.requireTicket; + if (message.requireMFA != null && Object.hasOwnProperty.call(message, "requireMFA")) + object.requireMFA = message.requireMFA; + if (message.accessLength != null && Object.hasOwnProperty.call(message, "accessLength")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.accessLength = typeof message.accessLength === "number" ? BigInt(message.accessLength) : $util.Long.fromBits(message.accessLength.low >>> 0, message.accessLength.high >>> 0, false).toBigInt(); + else if (typeof message.accessLength === "number") + object.accessLength = options.longs === String ? String(message.accessLength) : message.accessLength; + else + object.accessLength = options.longs === String ? $util.Long.prototype.toString.call(message.accessLength) : options.longs === Number ? new $util.LongBits(message.accessLength.low >>> 0, message.accessLength.high >>> 0).toNumber() : message.accessLength; + if (message.allowedTimes != null && Object.hasOwnProperty.call(message, "allowedTimes")) + object.allowedTimes = $root.Workflow.TemporalAccessFilter.toObject(message.allowedTimes, options, q + 1); + return object; + }; + + /** + * Converts this WorkflowParameters to JSON. + * @function toJSON + * @memberof Workflow.WorkflowParameters + * @instance + * @returns {Object.} JSON object + */ + WorkflowParameters.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowParameters + * @function getTypeUrl + * @memberof Workflow.WorkflowParameters + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowParameters.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowParameters"; + }; + + return WorkflowParameters; + })(); + + Workflow.WorkflowConfig = (function() { + + /** + * Properties of a WorkflowConfig. + * @memberof Workflow + * @interface IWorkflowConfig + * @property {Workflow.IWorkflowParameters|null} [parameters] WorkflowConfig parameters + * @property {Array.|null} [approvers] WorkflowConfig approvers + * @property {number|null} [createdOn] WorkflowConfig createdOn + */ + + /** + * Constructs a new WorkflowConfig. + * @memberof Workflow + * @classdesc Represents a WorkflowConfig. + * @implements IWorkflowConfig + * @constructor + * @param {Workflow.IWorkflowConfig=} [properties] Properties to set + */ + function WorkflowConfig(properties) { + this.approvers = []; + 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]]; + } + + /** + * WorkflowConfig parameters. + * @member {Workflow.IWorkflowParameters|null|undefined} parameters + * @memberof Workflow.WorkflowConfig + * @instance + */ + WorkflowConfig.prototype.parameters = null; + + /** + * WorkflowConfig approvers. + * @member {Array.} approvers + * @memberof Workflow.WorkflowConfig + * @instance + */ + WorkflowConfig.prototype.approvers = $util.emptyArray; + + /** + * WorkflowConfig createdOn. + * @member {number} createdOn + * @memberof Workflow.WorkflowConfig + * @instance + */ + WorkflowConfig.prototype.createdOn = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * Creates a new WorkflowConfig instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowConfig + * @static + * @param {Workflow.IWorkflowConfig=} [properties] Properties to set + * @returns {Workflow.WorkflowConfig} WorkflowConfig instance + */ + WorkflowConfig.create = function create(properties) { + return new WorkflowConfig(properties); + }; + + /** + * Encodes the specified WorkflowConfig message. Does not implicitly {@link Workflow.WorkflowConfig.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowConfig + * @static + * @param {Workflow.IWorkflowConfig} message WorkflowConfig message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowConfig.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.parameters != null && Object.hasOwnProperty.call(message, "parameters")) + $root.Workflow.WorkflowParameters.encode(message.parameters, writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.approvers != null && message.approvers.length) + for (let i = 0; i < message.approvers.length; ++i) + $root.Workflow.WorkflowApprover.encode(message.approvers[i], writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.createdOn != null && Object.hasOwnProperty.call(message, "createdOn")) + writer.uint32(/* id 3, wireType 0 =*/24).int64(message.createdOn); + return writer; + }; + + /** + * Decodes a WorkflowConfig message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowConfig + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowConfig} WorkflowConfig + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowConfig.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.Workflow.WorkflowConfig(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.parameters = $root.Workflow.WorkflowParameters.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 2: { + if (!(message.approvers && message.approvers.length)) + message.approvers = []; + message.approvers.push($root.Workflow.WorkflowApprover.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 3: { + message.createdOn = reader.int64(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowConfig message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowConfig + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowConfig} WorkflowConfig + */ + WorkflowConfig.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowConfig) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowConfig: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowConfig(); + if (object.parameters != null) { + if (!$util.isObject(object.parameters)) + throw TypeError(".Workflow.WorkflowConfig.parameters: object expected"); + message.parameters = $root.Workflow.WorkflowParameters.fromObject(object.parameters, long + 1); + } + if (object.approvers) { + if (!Array.isArray(object.approvers)) + throw TypeError(".Workflow.WorkflowConfig.approvers: array expected"); + message.approvers = []; + for (let i = 0; i < object.approvers.length; ++i) { + if (!$util.isObject(object.approvers[i])) + throw TypeError(".Workflow.WorkflowConfig.approvers: object expected"); + message.approvers[i] = $root.Workflow.WorkflowApprover.fromObject(object.approvers[i], long + 1); + } + } + if (object.createdOn != null) + if ($util.Long) + message.createdOn = $util.Long.fromValue(object.createdOn, false); + else if (typeof object.createdOn === "string") + message.createdOn = parseInt(object.createdOn, 10); + else if (typeof object.createdOn === "number") + message.createdOn = object.createdOn; + else if (typeof object.createdOn === "object") + message.createdOn = new $util.LongBits(object.createdOn.low >>> 0, object.createdOn.high >>> 0).toNumber(); + return message; + }; + + /** + * Creates a plain object from a WorkflowConfig message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowConfig + * @static + * @param {Workflow.WorkflowConfig} message WorkflowConfig + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowConfig.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.arrays || options.defaults) + object.approvers = []; + if (options.defaults) { + object.parameters = null; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.createdOn = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.createdOn = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + } + if (message.parameters != null && Object.hasOwnProperty.call(message, "parameters")) + object.parameters = $root.Workflow.WorkflowParameters.toObject(message.parameters, options, q + 1); + if (message.approvers && message.approvers.length) { + object.approvers = []; + for (let j = 0; j < message.approvers.length; ++j) + object.approvers[j] = $root.Workflow.WorkflowApprover.toObject(message.approvers[j], options, q + 1); + } + if (message.createdOn != null && Object.hasOwnProperty.call(message, "createdOn")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.createdOn = typeof message.createdOn === "number" ? BigInt(message.createdOn) : $util.Long.fromBits(message.createdOn.low >>> 0, message.createdOn.high >>> 0, false).toBigInt(); + else if (typeof message.createdOn === "number") + object.createdOn = options.longs === String ? String(message.createdOn) : message.createdOn; + else + 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; + return object; + }; + + /** + * Converts this WorkflowConfig to JSON. + * @function toJSON + * @memberof Workflow.WorkflowConfig + * @instance + * @returns {Object.} JSON object + */ + WorkflowConfig.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowConfig + * @function getTypeUrl + * @memberof Workflow.WorkflowConfig + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowConfig"; + }; + + return WorkflowConfig; + })(); + + /** + * WorkflowStage enum. + * @name Workflow.WorkflowStage + * @enum {number} + * @property {number} WS_READY_TO_START=0 WS_READY_TO_START value + * @property {number} WS_STARTED=1 WS_STARTED value + * @property {number} WS_NEEDS_ACTION=2 WS_NEEDS_ACTION value + * @property {number} WS_WAITING=3 WS_WAITING value + */ + Workflow.WorkflowStage = (function() { + const valuesById = {}, values = Object.create(valuesById); + values[valuesById[0] = "WS_READY_TO_START"] = 0; + values[valuesById[1] = "WS_STARTED"] = 1; + values[valuesById[2] = "WS_NEEDS_ACTION"] = 2; + values[valuesById[3] = "WS_WAITING"] = 3; + return values; + })(); + + /** + * AccessCondition enum. + * @name Workflow.AccessCondition + * @enum {number} + * @property {number} AC_APPROVAL=0 AC_APPROVAL value + * @property {number} AC_CHECKIN=1 AC_CHECKIN value + * @property {number} AC_MFA=2 AC_MFA value + * @property {number} AC_TIME=3 AC_TIME value + * @property {number} AC_REASON=4 AC_REASON value + * @property {number} AC_TICKET=5 AC_TICKET value + */ + Workflow.AccessCondition = (function() { + const valuesById = {}, values = Object.create(valuesById); + values[valuesById[0] = "AC_APPROVAL"] = 0; + values[valuesById[1] = "AC_CHECKIN"] = 1; + values[valuesById[2] = "AC_MFA"] = 2; + values[valuesById[3] = "AC_TIME"] = 3; + values[valuesById[4] = "AC_REASON"] = 4; + values[valuesById[5] = "AC_TICKET"] = 5; + return values; + })(); + + Workflow.WorkflowStatus = (function() { + + /** + * Properties of a WorkflowStatus. + * @memberof Workflow + * @interface IWorkflowStatus + * @property {Workflow.WorkflowStage|null} [stage] WorkflowStatus stage + * @property {Array.|null} [conditions] WorkflowStatus conditions + * @property {Array.|null} [approvedBy] WorkflowStatus approvedBy + * @property {number|null} [startedOn] WorkflowStatus startedOn + * @property {number|null} [expiresOn] WorkflowStatus expiresOn + * @property {boolean|null} [escalated] WorkflowStatus escalated + * @property {string|null} [checkedOutBy] WorkflowStatus checkedOutBy + * @property {boolean|null} [canForceCheckIn] WorkflowStatus canForceCheckIn + */ + + /** + * Constructs a new WorkflowStatus. + * @memberof Workflow + * @classdesc Represents a WorkflowStatus. + * @implements IWorkflowStatus + * @constructor + * @param {Workflow.IWorkflowStatus=} [properties] Properties to set + */ + function WorkflowStatus(properties) { + this.conditions = []; + this.approvedBy = []; + 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]]; + } + + /** + * WorkflowStatus stage. + * @member {Workflow.WorkflowStage} stage + * @memberof Workflow.WorkflowStatus + * @instance + */ + WorkflowStatus.prototype.stage = 0; + + /** + * WorkflowStatus conditions. + * @member {Array.} conditions + * @memberof Workflow.WorkflowStatus + * @instance + */ + WorkflowStatus.prototype.conditions = $util.emptyArray; + + /** + * WorkflowStatus approvedBy. + * @member {Array.} approvedBy + * @memberof Workflow.WorkflowStatus + * @instance + */ + WorkflowStatus.prototype.approvedBy = $util.emptyArray; + + /** + * WorkflowStatus startedOn. + * @member {number} startedOn + * @memberof Workflow.WorkflowStatus + * @instance + */ + WorkflowStatus.prototype.startedOn = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * WorkflowStatus expiresOn. + * @member {number} expiresOn + * @memberof Workflow.WorkflowStatus + * @instance + */ + WorkflowStatus.prototype.expiresOn = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * WorkflowStatus escalated. + * @member {boolean} escalated + * @memberof Workflow.WorkflowStatus + * @instance + */ + WorkflowStatus.prototype.escalated = false; + + /** + * WorkflowStatus checkedOutBy. + * @member {string} checkedOutBy + * @memberof Workflow.WorkflowStatus + * @instance + */ + WorkflowStatus.prototype.checkedOutBy = ""; + + /** + * WorkflowStatus canForceCheckIn. + * @member {boolean} canForceCheckIn + * @memberof Workflow.WorkflowStatus + * @instance + */ + WorkflowStatus.prototype.canForceCheckIn = false; + + /** + * Creates a new WorkflowStatus instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowStatus + * @static + * @param {Workflow.IWorkflowStatus=} [properties] Properties to set + * @returns {Workflow.WorkflowStatus} WorkflowStatus instance + */ + WorkflowStatus.create = function create(properties) { + return new WorkflowStatus(properties); + }; + + /** + * Encodes the specified WorkflowStatus message. Does not implicitly {@link Workflow.WorkflowStatus.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowStatus + * @static + * @param {Workflow.IWorkflowStatus} message WorkflowStatus message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowStatus.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.stage != null && Object.hasOwnProperty.call(message, "stage")) + writer.uint32(/* id 1, wireType 0 =*/8).int32(message.stage); + if (message.conditions != null && message.conditions.length) { + writer.uint32(/* id 2, wireType 2 =*/18).fork(); + for (let i = 0; i < message.conditions.length; ++i) + writer.int32(message.conditions[i]); + writer.ldelim(); + } + if (message.approvedBy != null && message.approvedBy.length) + for (let i = 0; i < message.approvedBy.length; ++i) + $root.Workflow.WorkflowApproval.encode(message.approvedBy[i], writer.uint32(/* id 3, wireType 2 =*/26).fork(), q + 1).ldelim(); + if (message.startedOn != null && Object.hasOwnProperty.call(message, "startedOn")) + writer.uint32(/* id 4, wireType 0 =*/32).int64(message.startedOn); + if (message.expiresOn != null && Object.hasOwnProperty.call(message, "expiresOn")) + writer.uint32(/* id 5, wireType 0 =*/40).int64(message.expiresOn); + if (message.escalated != null && Object.hasOwnProperty.call(message, "escalated")) + writer.uint32(/* id 6, wireType 0 =*/48).bool(message.escalated); + if (message.checkedOutBy != null && Object.hasOwnProperty.call(message, "checkedOutBy")) + writer.uint32(/* id 7, wireType 2 =*/58).string(message.checkedOutBy); + if (message.canForceCheckIn != null && Object.hasOwnProperty.call(message, "canForceCheckIn")) + writer.uint32(/* id 8, wireType 0 =*/64).bool(message.canForceCheckIn); + return writer; + }; + + /** + * Decodes a WorkflowStatus message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowStatus + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowStatus} WorkflowStatus + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowStatus.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.Workflow.WorkflowStatus(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.stage = reader.int32(); + break; + } + case 2: { + if (!(message.conditions && message.conditions.length)) + message.conditions = []; + if ((tag & 7) === 2) { + let end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) + message.conditions.push(reader.int32()); + } else + message.conditions.push(reader.int32()); + break; + } + case 3: { + if (!(message.approvedBy && message.approvedBy.length)) + message.approvedBy = []; + message.approvedBy.push($root.Workflow.WorkflowApproval.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 4: { + message.startedOn = reader.int64(); + break; + } + case 5: { + message.expiresOn = reader.int64(); + break; + } + case 6: { + message.escalated = reader.bool(); + break; + } + case 7: { + message.checkedOutBy = reader.string(); + break; + } + case 8: { + message.canForceCheckIn = reader.bool(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowStatus message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowStatus + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowStatus} WorkflowStatus + */ + WorkflowStatus.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowStatus) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowStatus: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowStatus(); + switch (object.stage) { + default: + if (typeof object.stage === "number") { + message.stage = object.stage; + break; + } + break; + case "WS_READY_TO_START": + case 0: + message.stage = 0; + break; + case "WS_STARTED": + case 1: + message.stage = 1; + break; + case "WS_NEEDS_ACTION": + case 2: + message.stage = 2; + break; + case "WS_WAITING": + case 3: + message.stage = 3; + break; + } + if (object.conditions) { + if (!Array.isArray(object.conditions)) + throw TypeError(".Workflow.WorkflowStatus.conditions: array expected"); + message.conditions = []; + for (let i = 0; i < object.conditions.length; ++i) + switch (object.conditions[i]) { + default: + if (typeof object.conditions[i] === "number") { + message.conditions[i] = object.conditions[i]; + break; + } + case "AC_APPROVAL": + case 0: + message.conditions[i] = 0; + break; + case "AC_CHECKIN": + case 1: + message.conditions[i] = 1; + break; + case "AC_MFA": + case 2: + message.conditions[i] = 2; + break; + case "AC_TIME": + case 3: + message.conditions[i] = 3; + break; + case "AC_REASON": + case 4: + message.conditions[i] = 4; + break; + case "AC_TICKET": + case 5: + message.conditions[i] = 5; + break; + } + } + if (object.approvedBy) { + if (!Array.isArray(object.approvedBy)) + throw TypeError(".Workflow.WorkflowStatus.approvedBy: array expected"); + message.approvedBy = []; + for (let i = 0; i < object.approvedBy.length; ++i) { + if (!$util.isObject(object.approvedBy[i])) + throw TypeError(".Workflow.WorkflowStatus.approvedBy: object expected"); + message.approvedBy[i] = $root.Workflow.WorkflowApproval.fromObject(object.approvedBy[i], long + 1); + } + } + if (object.startedOn != null) + if ($util.Long) + message.startedOn = $util.Long.fromValue(object.startedOn, false); + else if (typeof object.startedOn === "string") + message.startedOn = parseInt(object.startedOn, 10); + else if (typeof object.startedOn === "number") + message.startedOn = object.startedOn; + else if (typeof object.startedOn === "object") + message.startedOn = new $util.LongBits(object.startedOn.low >>> 0, object.startedOn.high >>> 0).toNumber(); + if (object.expiresOn != null) + if ($util.Long) + message.expiresOn = $util.Long.fromValue(object.expiresOn, false); + else if (typeof object.expiresOn === "string") + message.expiresOn = parseInt(object.expiresOn, 10); + else if (typeof object.expiresOn === "number") + message.expiresOn = object.expiresOn; + else if (typeof object.expiresOn === "object") + message.expiresOn = new $util.LongBits(object.expiresOn.low >>> 0, object.expiresOn.high >>> 0).toNumber(); + if (object.escalated != null) + message.escalated = Boolean(object.escalated); + if (object.checkedOutBy != null) + message.checkedOutBy = String(object.checkedOutBy); + if (object.canForceCheckIn != null) + message.canForceCheckIn = Boolean(object.canForceCheckIn); + return message; + }; + + /** + * Creates a plain object from a WorkflowStatus message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowStatus + * @static + * @param {Workflow.WorkflowStatus} message WorkflowStatus + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowStatus.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.arrays || options.defaults) { + object.conditions = []; + object.approvedBy = []; + } + if (options.defaults) { + object.stage = options.enums === String ? "WS_READY_TO_START" : 0; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.startedOn = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.startedOn = 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.expiresOn = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.expiresOn = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + object.escalated = false; + object.checkedOutBy = ""; + object.canForceCheckIn = false; + } + if (message.stage != null && Object.hasOwnProperty.call(message, "stage")) + object.stage = options.enums === String ? $root.Workflow.WorkflowStage[message.stage] === undefined ? message.stage : $root.Workflow.WorkflowStage[message.stage] : message.stage; + if (message.conditions && message.conditions.length) { + object.conditions = []; + for (let j = 0; j < message.conditions.length; ++j) + object.conditions[j] = options.enums === String ? $root.Workflow.AccessCondition[message.conditions[j]] === undefined ? message.conditions[j] : $root.Workflow.AccessCondition[message.conditions[j]] : message.conditions[j]; + } + if (message.approvedBy && message.approvedBy.length) { + object.approvedBy = []; + for (let j = 0; j < message.approvedBy.length; ++j) + object.approvedBy[j] = $root.Workflow.WorkflowApproval.toObject(message.approvedBy[j], options, q + 1); + } + if (message.startedOn != null && Object.hasOwnProperty.call(message, "startedOn")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.startedOn = typeof message.startedOn === "number" ? BigInt(message.startedOn) : $util.Long.fromBits(message.startedOn.low >>> 0, message.startedOn.high >>> 0, false).toBigInt(); + else if (typeof message.startedOn === "number") + object.startedOn = options.longs === String ? String(message.startedOn) : message.startedOn; + else + object.startedOn = options.longs === String ? $util.Long.prototype.toString.call(message.startedOn) : options.longs === Number ? new $util.LongBits(message.startedOn.low >>> 0, message.startedOn.high >>> 0).toNumber() : message.startedOn; + if (message.expiresOn != null && Object.hasOwnProperty.call(message, "expiresOn")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.expiresOn = typeof message.expiresOn === "number" ? BigInt(message.expiresOn) : $util.Long.fromBits(message.expiresOn.low >>> 0, message.expiresOn.high >>> 0, false).toBigInt(); + else if (typeof message.expiresOn === "number") + object.expiresOn = options.longs === String ? String(message.expiresOn) : message.expiresOn; + else + object.expiresOn = options.longs === String ? $util.Long.prototype.toString.call(message.expiresOn) : options.longs === Number ? new $util.LongBits(message.expiresOn.low >>> 0, message.expiresOn.high >>> 0).toNumber() : message.expiresOn; + if (message.escalated != null && Object.hasOwnProperty.call(message, "escalated")) + object.escalated = message.escalated; + if (message.checkedOutBy != null && Object.hasOwnProperty.call(message, "checkedOutBy")) + object.checkedOutBy = message.checkedOutBy; + if (message.canForceCheckIn != null && Object.hasOwnProperty.call(message, "canForceCheckIn")) + object.canForceCheckIn = message.canForceCheckIn; + return object; + }; + + /** + * Converts this WorkflowStatus to JSON. + * @function toJSON + * @memberof Workflow.WorkflowStatus + * @instance + * @returns {Object.} JSON object + */ + WorkflowStatus.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowStatus + * @function getTypeUrl + * @memberof Workflow.WorkflowStatus + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowStatus.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowStatus"; + }; + + return WorkflowStatus; + })(); + + Workflow.WorkflowProcess = (function() { + + /** + * Properties of a WorkflowProcess. + * @memberof Workflow + * @interface IWorkflowProcess + * @property {Uint8Array|null} [flowUid] WorkflowProcess flowUid + * @property {number|null} [userId] WorkflowProcess userId + * @property {GraphSync.IGraphSyncRef|null} [resource] WorkflowProcess resource + * @property {number|null} [startedOn] WorkflowProcess startedOn + * @property {number|null} [expiresOn] WorkflowProcess expiresOn + * @property {Uint8Array|null} [reason] WorkflowProcess reason + * @property {boolean|null} [mfaVerified] WorkflowProcess mfaVerified + * @property {Uint8Array|null} [externalRef] WorkflowProcess externalRef + * @property {string|null} [user] WorkflowProcess user + * @property {Array.|null} [workflowParameters] WorkflowProcess workflowParameters + * @property {boolean|null} [escalated] WorkflowProcess escalated + * @property {boolean|null} [ephemeral] WorkflowProcess ephemeral + */ + + /** + * Constructs a new WorkflowProcess. + * @memberof Workflow + * @classdesc Represents a WorkflowProcess. + * @implements IWorkflowProcess + * @constructor + * @param {Workflow.IWorkflowProcess=} [properties] Properties to set + */ + function WorkflowProcess(properties) { + this.workflowParameters = []; + 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]]; + } + + /** + * WorkflowProcess flowUid. + * @member {Uint8Array} flowUid + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.flowUid = $util.newBuffer([]); + + /** + * WorkflowProcess userId. + * @member {number} userId + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.userId = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * WorkflowProcess resource. + * @member {GraphSync.IGraphSyncRef|null|undefined} resource + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.resource = null; + + /** + * WorkflowProcess startedOn. + * @member {number} startedOn + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.startedOn = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * WorkflowProcess expiresOn. + * @member {number} expiresOn + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.expiresOn = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * WorkflowProcess reason. + * @member {Uint8Array} reason + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.reason = $util.newBuffer([]); + + /** + * WorkflowProcess mfaVerified. + * @member {boolean} mfaVerified + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.mfaVerified = false; + + /** + * WorkflowProcess externalRef. + * @member {Uint8Array} externalRef + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.externalRef = $util.newBuffer([]); + + /** + * WorkflowProcess user. + * @member {string} user + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.user = ""; + + /** + * WorkflowProcess workflowParameters. + * @member {Array.} workflowParameters + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.workflowParameters = $util.emptyArray; + + /** + * WorkflowProcess escalated. + * @member {boolean} escalated + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.escalated = false; + + /** + * WorkflowProcess ephemeral. + * @member {boolean} ephemeral + * @memberof Workflow.WorkflowProcess + * @instance + */ + WorkflowProcess.prototype.ephemeral = false; + + /** + * Creates a new WorkflowProcess instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowProcess + * @static + * @param {Workflow.IWorkflowProcess=} [properties] Properties to set + * @returns {Workflow.WorkflowProcess} WorkflowProcess instance + */ + WorkflowProcess.create = function create(properties) { + return new WorkflowProcess(properties); + }; + + /** + * Encodes the specified WorkflowProcess message. Does not implicitly {@link Workflow.WorkflowProcess.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowProcess + * @static + * @param {Workflow.IWorkflowProcess} message WorkflowProcess message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowProcess.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.flowUid != null && Object.hasOwnProperty.call(message, "flowUid")) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.flowUid); + if (message.userId != null && Object.hasOwnProperty.call(message, "userId")) + writer.uint32(/* id 2, wireType 0 =*/16).int64(message.userId); + if (message.resource != null && Object.hasOwnProperty.call(message, "resource")) + $root.GraphSync.GraphSyncRef.encode(message.resource, writer.uint32(/* id 3, wireType 2 =*/26).fork(), q + 1).ldelim(); + if (message.startedOn != null && Object.hasOwnProperty.call(message, "startedOn")) + writer.uint32(/* id 4, wireType 0 =*/32).int64(message.startedOn); + if (message.expiresOn != null && Object.hasOwnProperty.call(message, "expiresOn")) + writer.uint32(/* id 5, wireType 0 =*/40).int64(message.expiresOn); + if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) + writer.uint32(/* id 6, wireType 2 =*/50).bytes(message.reason); + if (message.mfaVerified != null && Object.hasOwnProperty.call(message, "mfaVerified")) + writer.uint32(/* id 7, wireType 0 =*/56).bool(message.mfaVerified); + if (message.externalRef != null && Object.hasOwnProperty.call(message, "externalRef")) + writer.uint32(/* id 8, wireType 2 =*/66).bytes(message.externalRef); + if (message.user != null && Object.hasOwnProperty.call(message, "user")) + writer.uint32(/* id 9, wireType 2 =*/74).string(message.user); + if (message.workflowParameters != null && message.workflowParameters.length) + for (let i = 0; i < message.workflowParameters.length; ++i) + $root.NotificationCenter.NotificationParameter.encode(message.workflowParameters[i], writer.uint32(/* id 10, wireType 2 =*/82).fork(), q + 1).ldelim(); + if (message.escalated != null && Object.hasOwnProperty.call(message, "escalated")) + writer.uint32(/* id 11, wireType 0 =*/88).bool(message.escalated); + if (message.ephemeral != null && Object.hasOwnProperty.call(message, "ephemeral")) + writer.uint32(/* id 12, wireType 0 =*/96).bool(message.ephemeral); + return writer; + }; + + /** + * Decodes a WorkflowProcess message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowProcess + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowProcess} WorkflowProcess + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowProcess.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.Workflow.WorkflowProcess(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.flowUid = reader.bytes(); + break; + } + case 2: { + message.userId = reader.int64(); + break; + } + case 3: { + message.resource = $root.GraphSync.GraphSyncRef.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 4: { + message.startedOn = reader.int64(); + break; + } + case 5: { + message.expiresOn = reader.int64(); + break; + } + case 6: { + message.reason = reader.bytes(); + break; + } + case 7: { + message.mfaVerified = reader.bool(); + break; + } + case 8: { + message.externalRef = reader.bytes(); + break; + } + case 9: { + message.user = reader.string(); + break; + } + case 10: { + if (!(message.workflowParameters && message.workflowParameters.length)) + message.workflowParameters = []; + message.workflowParameters.push($root.NotificationCenter.NotificationParameter.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 11: { + message.escalated = reader.bool(); + break; + } + case 12: { + message.ephemeral = reader.bool(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowProcess message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowProcess + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowProcess} WorkflowProcess + */ + WorkflowProcess.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowProcess) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowProcess: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowProcess(); + if (object.flowUid != null) + if (typeof object.flowUid === "string") + $util.base64.decode(object.flowUid, message.flowUid = $util.newBuffer($util.base64.length(object.flowUid)), 0); + else if (object.flowUid.length >= 0) + message.flowUid = object.flowUid; + if (object.userId != null) + if ($util.Long) + message.userId = $util.Long.fromValue(object.userId, false); + else if (typeof object.userId === "string") + message.userId = parseInt(object.userId, 10); + else if (typeof object.userId === "number") + message.userId = object.userId; + else if (typeof object.userId === "object") + message.userId = new $util.LongBits(object.userId.low >>> 0, object.userId.high >>> 0).toNumber(); + if (object.resource != null) { + if (!$util.isObject(object.resource)) + throw TypeError(".Workflow.WorkflowProcess.resource: object expected"); + message.resource = $root.GraphSync.GraphSyncRef.fromObject(object.resource, long + 1); + } + if (object.startedOn != null) + if ($util.Long) + message.startedOn = $util.Long.fromValue(object.startedOn, false); + else if (typeof object.startedOn === "string") + message.startedOn = parseInt(object.startedOn, 10); + else if (typeof object.startedOn === "number") + message.startedOn = object.startedOn; + else if (typeof object.startedOn === "object") + message.startedOn = new $util.LongBits(object.startedOn.low >>> 0, object.startedOn.high >>> 0).toNumber(); + if (object.expiresOn != null) + if ($util.Long) + message.expiresOn = $util.Long.fromValue(object.expiresOn, false); + else if (typeof object.expiresOn === "string") + message.expiresOn = parseInt(object.expiresOn, 10); + else if (typeof object.expiresOn === "number") + message.expiresOn = object.expiresOn; + else if (typeof object.expiresOn === "object") + message.expiresOn = new $util.LongBits(object.expiresOn.low >>> 0, object.expiresOn.high >>> 0).toNumber(); + if (object.reason != null) + if (typeof object.reason === "string") + $util.base64.decode(object.reason, message.reason = $util.newBuffer($util.base64.length(object.reason)), 0); + else if (object.reason.length >= 0) + message.reason = object.reason; + if (object.mfaVerified != null) + message.mfaVerified = Boolean(object.mfaVerified); + if (object.externalRef != null) + if (typeof object.externalRef === "string") + $util.base64.decode(object.externalRef, message.externalRef = $util.newBuffer($util.base64.length(object.externalRef)), 0); + else if (object.externalRef.length >= 0) + message.externalRef = object.externalRef; + if (object.user != null) + message.user = String(object.user); + if (object.workflowParameters) { + if (!Array.isArray(object.workflowParameters)) + throw TypeError(".Workflow.WorkflowProcess.workflowParameters: array expected"); + message.workflowParameters = []; + for (let i = 0; i < object.workflowParameters.length; ++i) { + if (!$util.isObject(object.workflowParameters[i])) + throw TypeError(".Workflow.WorkflowProcess.workflowParameters: object expected"); + message.workflowParameters[i] = $root.NotificationCenter.NotificationParameter.fromObject(object.workflowParameters[i], long + 1); + } + } + if (object.escalated != null) + message.escalated = Boolean(object.escalated); + if (object.ephemeral != null) + message.ephemeral = Boolean(object.ephemeral); + return message; + }; + + /** + * Creates a plain object from a WorkflowProcess message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowProcess + * @static + * @param {Workflow.WorkflowProcess} message WorkflowProcess + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowProcess.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.arrays || options.defaults) + object.workflowParameters = []; + if (options.defaults) { + if (options.bytes === String) + object.flowUid = ""; + else { + object.flowUid = []; + if (options.bytes !== Array) + object.flowUid = $util.newBuffer(object.flowUid); + } + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.userId = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.userId = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + object.resource = null; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.startedOn = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.startedOn = 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.expiresOn = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.expiresOn = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + if (options.bytes === String) + object.reason = ""; + else { + object.reason = []; + if (options.bytes !== Array) + object.reason = $util.newBuffer(object.reason); + } + object.mfaVerified = false; + if (options.bytes === String) + object.externalRef = ""; + else { + object.externalRef = []; + if (options.bytes !== Array) + object.externalRef = $util.newBuffer(object.externalRef); + } + object.user = ""; + object.escalated = false; + object.ephemeral = false; + } + if (message.flowUid != null && Object.hasOwnProperty.call(message, "flowUid")) + object.flowUid = options.bytes === String ? $util.base64.encode(message.flowUid, 0, message.flowUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.flowUid) : message.flowUid; + if (message.userId != null && Object.hasOwnProperty.call(message, "userId")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.userId = typeof message.userId === "number" ? BigInt(message.userId) : $util.Long.fromBits(message.userId.low >>> 0, message.userId.high >>> 0, false).toBigInt(); + else if (typeof message.userId === "number") + object.userId = options.longs === String ? String(message.userId) : message.userId; + else + object.userId = options.longs === String ? $util.Long.prototype.toString.call(message.userId) : options.longs === Number ? new $util.LongBits(message.userId.low >>> 0, message.userId.high >>> 0).toNumber() : message.userId; + if (message.resource != null && Object.hasOwnProperty.call(message, "resource")) + object.resource = $root.GraphSync.GraphSyncRef.toObject(message.resource, options, q + 1); + if (message.startedOn != null && Object.hasOwnProperty.call(message, "startedOn")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.startedOn = typeof message.startedOn === "number" ? BigInt(message.startedOn) : $util.Long.fromBits(message.startedOn.low >>> 0, message.startedOn.high >>> 0, false).toBigInt(); + else if (typeof message.startedOn === "number") + object.startedOn = options.longs === String ? String(message.startedOn) : message.startedOn; + else + object.startedOn = options.longs === String ? $util.Long.prototype.toString.call(message.startedOn) : options.longs === Number ? new $util.LongBits(message.startedOn.low >>> 0, message.startedOn.high >>> 0).toNumber() : message.startedOn; + if (message.expiresOn != null && Object.hasOwnProperty.call(message, "expiresOn")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.expiresOn = typeof message.expiresOn === "number" ? BigInt(message.expiresOn) : $util.Long.fromBits(message.expiresOn.low >>> 0, message.expiresOn.high >>> 0, false).toBigInt(); + else if (typeof message.expiresOn === "number") + object.expiresOn = options.longs === String ? String(message.expiresOn) : message.expiresOn; + else + object.expiresOn = options.longs === String ? $util.Long.prototype.toString.call(message.expiresOn) : options.longs === Number ? new $util.LongBits(message.expiresOn.low >>> 0, message.expiresOn.high >>> 0).toNumber() : message.expiresOn; + if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) + object.reason = options.bytes === String ? $util.base64.encode(message.reason, 0, message.reason.length) : options.bytes === Array ? Array.prototype.slice.call(message.reason) : message.reason; + if (message.mfaVerified != null && Object.hasOwnProperty.call(message, "mfaVerified")) + object.mfaVerified = message.mfaVerified; + if (message.externalRef != null && Object.hasOwnProperty.call(message, "externalRef")) + object.externalRef = options.bytes === String ? $util.base64.encode(message.externalRef, 0, message.externalRef.length) : options.bytes === Array ? Array.prototype.slice.call(message.externalRef) : message.externalRef; + if (message.user != null && Object.hasOwnProperty.call(message, "user")) + object.user = message.user; + if (message.workflowParameters && message.workflowParameters.length) { + object.workflowParameters = []; + for (let j = 0; j < message.workflowParameters.length; ++j) + object.workflowParameters[j] = $root.NotificationCenter.NotificationParameter.toObject(message.workflowParameters[j], options, q + 1); + } + if (message.escalated != null && Object.hasOwnProperty.call(message, "escalated")) + object.escalated = message.escalated; + if (message.ephemeral != null && Object.hasOwnProperty.call(message, "ephemeral")) + object.ephemeral = message.ephemeral; + return object; + }; + + /** + * Converts this WorkflowProcess to JSON. + * @function toJSON + * @memberof Workflow.WorkflowProcess + * @instance + * @returns {Object.} JSON object + */ + WorkflowProcess.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowProcess + * @function getTypeUrl + * @memberof Workflow.WorkflowProcess + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowProcess.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowProcess"; + }; + + return WorkflowProcess; + })(); + + Workflow.WorkflowApproval = (function() { + + /** + * Properties of a WorkflowApproval. + * @memberof Workflow + * @interface IWorkflowApproval + * @property {number|null} [userId] WorkflowApproval userId + * @property {string|null} [user] WorkflowApproval user + * @property {Uint8Array|null} [flowUid] WorkflowApproval flowUid + * @property {number|null} [approvedOn] WorkflowApproval approvedOn + */ + + /** + * Constructs a new WorkflowApproval. + * @memberof Workflow + * @classdesc Represents a WorkflowApproval. + * @implements IWorkflowApproval + * @constructor + * @param {Workflow.IWorkflowApproval=} [properties] Properties to set + */ + function WorkflowApproval(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]]; + } + + /** + * WorkflowApproval userId. + * @member {number} userId + * @memberof Workflow.WorkflowApproval + * @instance + */ + WorkflowApproval.prototype.userId = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * WorkflowApproval user. + * @member {string} user + * @memberof Workflow.WorkflowApproval + * @instance + */ + WorkflowApproval.prototype.user = ""; + + /** + * WorkflowApproval flowUid. + * @member {Uint8Array} flowUid + * @memberof Workflow.WorkflowApproval + * @instance + */ + WorkflowApproval.prototype.flowUid = $util.newBuffer([]); + + /** + * WorkflowApproval approvedOn. + * @member {number} approvedOn + * @memberof Workflow.WorkflowApproval + * @instance + */ + WorkflowApproval.prototype.approvedOn = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * Creates a new WorkflowApproval instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowApproval + * @static + * @param {Workflow.IWorkflowApproval=} [properties] Properties to set + * @returns {Workflow.WorkflowApproval} WorkflowApproval instance + */ + WorkflowApproval.create = function create(properties) { + return new WorkflowApproval(properties); + }; + + /** + * Encodes the specified WorkflowApproval message. Does not implicitly {@link Workflow.WorkflowApproval.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowApproval + * @static + * @param {Workflow.IWorkflowApproval} message WorkflowApproval message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowApproval.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.userId != null && Object.hasOwnProperty.call(message, "userId")) + writer.uint32(/* id 1, wireType 0 =*/8).int64(message.userId); + if (message.user != null && Object.hasOwnProperty.call(message, "user")) + writer.uint32(/* id 2, wireType 2 =*/18).string(message.user); + if (message.flowUid != null && Object.hasOwnProperty.call(message, "flowUid")) + writer.uint32(/* id 3, wireType 2 =*/26).bytes(message.flowUid); + if (message.approvedOn != null && Object.hasOwnProperty.call(message, "approvedOn")) + writer.uint32(/* id 4, wireType 0 =*/32).int64(message.approvedOn); + return writer; + }; + + /** + * Decodes a WorkflowApproval message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowApproval + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowApproval} WorkflowApproval + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowApproval.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.Workflow.WorkflowApproval(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.userId = reader.int64(); + break; + } + case 2: { + message.user = reader.string(); + break; + } + case 3: { + message.flowUid = reader.bytes(); + break; + } + case 4: { + message.approvedOn = reader.int64(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowApproval message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowApproval + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowApproval} WorkflowApproval + */ + WorkflowApproval.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowApproval) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowApproval: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowApproval(); + if (object.userId != null) + if ($util.Long) + message.userId = $util.Long.fromValue(object.userId, false); + else if (typeof object.userId === "string") + message.userId = parseInt(object.userId, 10); + else if (typeof object.userId === "number") + message.userId = object.userId; + else if (typeof object.userId === "object") + message.userId = new $util.LongBits(object.userId.low >>> 0, object.userId.high >>> 0).toNumber(); + if (object.user != null) + message.user = String(object.user); + if (object.flowUid != null) + if (typeof object.flowUid === "string") + $util.base64.decode(object.flowUid, message.flowUid = $util.newBuffer($util.base64.length(object.flowUid)), 0); + else if (object.flowUid.length >= 0) + message.flowUid = object.flowUid; + if (object.approvedOn != null) + if ($util.Long) + message.approvedOn = $util.Long.fromValue(object.approvedOn, false); + else if (typeof object.approvedOn === "string") + message.approvedOn = parseInt(object.approvedOn, 10); + else if (typeof object.approvedOn === "number") + message.approvedOn = object.approvedOn; + else if (typeof object.approvedOn === "object") + message.approvedOn = new $util.LongBits(object.approvedOn.low >>> 0, object.approvedOn.high >>> 0).toNumber(); + return message; + }; + + /** + * Creates a plain object from a WorkflowApproval message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowApproval + * @static + * @param {Workflow.WorkflowApproval} message WorkflowApproval + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowApproval.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.userId = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.userId = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + object.user = ""; + if (options.bytes === String) + object.flowUid = ""; + else { + object.flowUid = []; + if (options.bytes !== Array) + object.flowUid = $util.newBuffer(object.flowUid); + } + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.approvedOn = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.approvedOn = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + } + if (message.userId != null && Object.hasOwnProperty.call(message, "userId")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.userId = typeof message.userId === "number" ? BigInt(message.userId) : $util.Long.fromBits(message.userId.low >>> 0, message.userId.high >>> 0, false).toBigInt(); + else if (typeof message.userId === "number") + object.userId = options.longs === String ? String(message.userId) : message.userId; + else + object.userId = options.longs === String ? $util.Long.prototype.toString.call(message.userId) : options.longs === Number ? new $util.LongBits(message.userId.low >>> 0, message.userId.high >>> 0).toNumber() : message.userId; + if (message.user != null && Object.hasOwnProperty.call(message, "user")) + object.user = message.user; + if (message.flowUid != null && Object.hasOwnProperty.call(message, "flowUid")) + object.flowUid = options.bytes === String ? $util.base64.encode(message.flowUid, 0, message.flowUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.flowUid) : message.flowUid; + if (message.approvedOn != null && Object.hasOwnProperty.call(message, "approvedOn")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.approvedOn = typeof message.approvedOn === "number" ? BigInt(message.approvedOn) : $util.Long.fromBits(message.approvedOn.low >>> 0, message.approvedOn.high >>> 0, false).toBigInt(); + else if (typeof message.approvedOn === "number") + object.approvedOn = options.longs === String ? String(message.approvedOn) : message.approvedOn; + else + object.approvedOn = options.longs === String ? $util.Long.prototype.toString.call(message.approvedOn) : options.longs === Number ? new $util.LongBits(message.approvedOn.low >>> 0, message.approvedOn.high >>> 0).toNumber() : message.approvedOn; + return object; + }; + + /** + * Converts this WorkflowApproval to JSON. + * @function toJSON + * @memberof Workflow.WorkflowApproval + * @instance + * @returns {Object.} JSON object + */ + WorkflowApproval.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowApproval + * @function getTypeUrl + * @memberof Workflow.WorkflowApproval + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowApproval.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowApproval"; + }; + + return WorkflowApproval; + })(); + + Workflow.WorkflowContext = (function() { + + /** + * Properties of a WorkflowContext. + * @memberof Workflow + * @interface IWorkflowContext + * @property {Workflow.IWorkflowConfig|null} [workflowConfig] WorkflowContext workflowConfig + * @property {Workflow.IWorkflowProcess|null} [workflow] WorkflowContext workflow + * @property {Array.|null} [approvals] WorkflowContext approvals + * @property {Workflow.IWorkflowProcess|null} [blocker] WorkflowContext blocker + */ + + /** + * Constructs a new WorkflowContext. + * @memberof Workflow + * @classdesc Represents a WorkflowContext. + * @implements IWorkflowContext + * @constructor + * @param {Workflow.IWorkflowContext=} [properties] Properties to set + */ + function WorkflowContext(properties) { + this.approvals = []; + 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]]; + } + + /** + * WorkflowContext workflowConfig. + * @member {Workflow.IWorkflowConfig|null|undefined} workflowConfig + * @memberof Workflow.WorkflowContext + * @instance + */ + WorkflowContext.prototype.workflowConfig = null; + + /** + * WorkflowContext workflow. + * @member {Workflow.IWorkflowProcess|null|undefined} workflow + * @memberof Workflow.WorkflowContext + * @instance + */ + WorkflowContext.prototype.workflow = null; + + /** + * WorkflowContext approvals. + * @member {Array.} approvals + * @memberof Workflow.WorkflowContext + * @instance + */ + WorkflowContext.prototype.approvals = $util.emptyArray; + + /** + * WorkflowContext blocker. + * @member {Workflow.IWorkflowProcess|null|undefined} blocker + * @memberof Workflow.WorkflowContext + * @instance + */ + WorkflowContext.prototype.blocker = null; + + /** + * Creates a new WorkflowContext instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowContext + * @static + * @param {Workflow.IWorkflowContext=} [properties] Properties to set + * @returns {Workflow.WorkflowContext} WorkflowContext instance + */ + WorkflowContext.create = function create(properties) { + return new WorkflowContext(properties); + }; + + /** + * Encodes the specified WorkflowContext message. Does not implicitly {@link Workflow.WorkflowContext.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowContext + * @static + * @param {Workflow.IWorkflowContext} message WorkflowContext message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowContext.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.workflowConfig != null && Object.hasOwnProperty.call(message, "workflowConfig")) + $root.Workflow.WorkflowConfig.encode(message.workflowConfig, writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.workflow != null && Object.hasOwnProperty.call(message, "workflow")) + $root.Workflow.WorkflowProcess.encode(message.workflow, writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.approvals != null && message.approvals.length) + for (let i = 0; i < message.approvals.length; ++i) + $root.Workflow.WorkflowApproval.encode(message.approvals[i], writer.uint32(/* id 3, wireType 2 =*/26).fork(), q + 1).ldelim(); + if (message.blocker != null && Object.hasOwnProperty.call(message, "blocker")) + $root.Workflow.WorkflowProcess.encode(message.blocker, writer.uint32(/* id 4, wireType 2 =*/34).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a WorkflowContext message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowContext + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowContext} WorkflowContext + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowContext.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.Workflow.WorkflowContext(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.workflowConfig = $root.Workflow.WorkflowConfig.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 2: { + message.workflow = $root.Workflow.WorkflowProcess.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 3: { + if (!(message.approvals && message.approvals.length)) + message.approvals = []; + message.approvals.push($root.Workflow.WorkflowApproval.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 4: { + message.blocker = $root.Workflow.WorkflowProcess.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowContext message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowContext + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowContext} WorkflowContext + */ + WorkflowContext.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowContext) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowContext: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowContext(); + if (object.workflowConfig != null) { + if (!$util.isObject(object.workflowConfig)) + throw TypeError(".Workflow.WorkflowContext.workflowConfig: object expected"); + message.workflowConfig = $root.Workflow.WorkflowConfig.fromObject(object.workflowConfig, long + 1); + } + if (object.workflow != null) { + if (!$util.isObject(object.workflow)) + throw TypeError(".Workflow.WorkflowContext.workflow: object expected"); + message.workflow = $root.Workflow.WorkflowProcess.fromObject(object.workflow, long + 1); + } + if (object.approvals) { + if (!Array.isArray(object.approvals)) + throw TypeError(".Workflow.WorkflowContext.approvals: array expected"); + message.approvals = []; + for (let i = 0; i < object.approvals.length; ++i) { + if (!$util.isObject(object.approvals[i])) + throw TypeError(".Workflow.WorkflowContext.approvals: object expected"); + message.approvals[i] = $root.Workflow.WorkflowApproval.fromObject(object.approvals[i], long + 1); + } + } + if (object.blocker != null) { + if (!$util.isObject(object.blocker)) + throw TypeError(".Workflow.WorkflowContext.blocker: object expected"); + message.blocker = $root.Workflow.WorkflowProcess.fromObject(object.blocker, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a WorkflowContext message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowContext + * @static + * @param {Workflow.WorkflowContext} message WorkflowContext + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowContext.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.arrays || options.defaults) + object.approvals = []; + if (options.defaults) { + object.workflowConfig = null; + object.workflow = null; + object.blocker = null; + } + if (message.workflowConfig != null && Object.hasOwnProperty.call(message, "workflowConfig")) + object.workflowConfig = $root.Workflow.WorkflowConfig.toObject(message.workflowConfig, options, q + 1); + if (message.workflow != null && Object.hasOwnProperty.call(message, "workflow")) + object.workflow = $root.Workflow.WorkflowProcess.toObject(message.workflow, options, q + 1); + if (message.approvals && message.approvals.length) { + object.approvals = []; + for (let j = 0; j < message.approvals.length; ++j) + object.approvals[j] = $root.Workflow.WorkflowApproval.toObject(message.approvals[j], options, q + 1); + } + if (message.blocker != null && Object.hasOwnProperty.call(message, "blocker")) + object.blocker = $root.Workflow.WorkflowProcess.toObject(message.blocker, options, q + 1); + return object; + }; + + /** + * Converts this WorkflowContext to JSON. + * @function toJSON + * @memberof Workflow.WorkflowContext + * @instance + * @returns {Object.} JSON object + */ + WorkflowContext.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowContext + * @function getTypeUrl + * @memberof Workflow.WorkflowContext + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowContext.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowContext"; + }; + + return WorkflowContext; + })(); + + Workflow.WorkflowState = (function() { + + /** + * Properties of a WorkflowState. + * @memberof Workflow + * @interface IWorkflowState + * @property {Uint8Array|null} [flowUid] WorkflowState flowUid + * @property {GraphSync.IGraphSyncRef|null} [resource] WorkflowState resource + * @property {Workflow.IWorkflowStatus|null} [status] WorkflowState status + */ + + /** + * Constructs a new WorkflowState. + * @memberof Workflow + * @classdesc Represents a WorkflowState. + * @implements IWorkflowState + * @constructor + * @param {Workflow.IWorkflowState=} [properties] Properties to set + */ + function WorkflowState(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]]; + } + + /** + * WorkflowState flowUid. + * @member {Uint8Array} flowUid + * @memberof Workflow.WorkflowState + * @instance + */ + WorkflowState.prototype.flowUid = $util.newBuffer([]); + + /** + * WorkflowState resource. + * @member {GraphSync.IGraphSyncRef|null|undefined} resource + * @memberof Workflow.WorkflowState + * @instance + */ + WorkflowState.prototype.resource = null; + + /** + * WorkflowState status. + * @member {Workflow.IWorkflowStatus|null|undefined} status + * @memberof Workflow.WorkflowState + * @instance + */ + WorkflowState.prototype.status = null; + + /** + * Creates a new WorkflowState instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowState + * @static + * @param {Workflow.IWorkflowState=} [properties] Properties to set + * @returns {Workflow.WorkflowState} WorkflowState instance + */ + WorkflowState.create = function create(properties) { + return new WorkflowState(properties); + }; + + /** + * Encodes the specified WorkflowState message. Does not implicitly {@link Workflow.WorkflowState.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowState + * @static + * @param {Workflow.IWorkflowState} message WorkflowState message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowState.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.flowUid != null && Object.hasOwnProperty.call(message, "flowUid")) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.flowUid); + if (message.resource != null && Object.hasOwnProperty.call(message, "resource")) + $root.GraphSync.GraphSyncRef.encode(message.resource, writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.status != null && Object.hasOwnProperty.call(message, "status")) + $root.Workflow.WorkflowStatus.encode(message.status, writer.uint32(/* id 3, wireType 2 =*/26).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a WorkflowState message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowState + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowState} WorkflowState + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowState.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.Workflow.WorkflowState(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.flowUid = reader.bytes(); + break; + } + case 2: { + message.resource = $root.GraphSync.GraphSyncRef.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 3: { + message.status = $root.Workflow.WorkflowStatus.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowState message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowState + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowState} WorkflowState + */ + WorkflowState.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowState) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowState: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowState(); + if (object.flowUid != null) + if (typeof object.flowUid === "string") + $util.base64.decode(object.flowUid, message.flowUid = $util.newBuffer($util.base64.length(object.flowUid)), 0); + else if (object.flowUid.length >= 0) + message.flowUid = object.flowUid; + if (object.resource != null) { + if (!$util.isObject(object.resource)) + throw TypeError(".Workflow.WorkflowState.resource: object expected"); + message.resource = $root.GraphSync.GraphSyncRef.fromObject(object.resource, long + 1); + } + if (object.status != null) { + if (!$util.isObject(object.status)) + throw TypeError(".Workflow.WorkflowState.status: object expected"); + message.status = $root.Workflow.WorkflowStatus.fromObject(object.status, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a WorkflowState message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowState + * @static + * @param {Workflow.WorkflowState} message WorkflowState + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowState.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 (options.bytes === String) + object.flowUid = ""; + else { + object.flowUid = []; + if (options.bytes !== Array) + object.flowUid = $util.newBuffer(object.flowUid); + } + object.resource = null; + object.status = null; + } + if (message.flowUid != null && Object.hasOwnProperty.call(message, "flowUid")) + object.flowUid = options.bytes === String ? $util.base64.encode(message.flowUid, 0, message.flowUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.flowUid) : message.flowUid; + if (message.resource != null && Object.hasOwnProperty.call(message, "resource")) + object.resource = $root.GraphSync.GraphSyncRef.toObject(message.resource, options, q + 1); + if (message.status != null && Object.hasOwnProperty.call(message, "status")) + object.status = $root.Workflow.WorkflowStatus.toObject(message.status, options, q + 1); + return object; + }; + + /** + * Converts this WorkflowState to JSON. + * @function toJSON + * @memberof Workflow.WorkflowState + * @instance + * @returns {Object.} JSON object + */ + WorkflowState.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowState + * @function getTypeUrl + * @memberof Workflow.WorkflowState + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowState.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowState"; + }; + + return WorkflowState; + })(); + + Workflow.WorkflowAccessRequest = (function() { + + /** + * Properties of a WorkflowAccessRequest. + * @memberof Workflow + * @interface IWorkflowAccessRequest + * @property {GraphSync.IGraphSyncRef|null} [resource] WorkflowAccessRequest resource + * @property {Uint8Array|null} [reason] WorkflowAccessRequest reason + * @property {Uint8Array|null} [ticket] WorkflowAccessRequest ticket + */ + + /** + * Constructs a new WorkflowAccessRequest. + * @memberof Workflow + * @classdesc Represents a WorkflowAccessRequest. + * @implements IWorkflowAccessRequest + * @constructor + * @param {Workflow.IWorkflowAccessRequest=} [properties] Properties to set + */ + function WorkflowAccessRequest(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]]; + } + + /** + * WorkflowAccessRequest resource. + * @member {GraphSync.IGraphSyncRef|null|undefined} resource + * @memberof Workflow.WorkflowAccessRequest + * @instance + */ + WorkflowAccessRequest.prototype.resource = null; + + /** + * WorkflowAccessRequest reason. + * @member {Uint8Array} reason + * @memberof Workflow.WorkflowAccessRequest + * @instance + */ + WorkflowAccessRequest.prototype.reason = $util.newBuffer([]); + + /** + * WorkflowAccessRequest ticket. + * @member {Uint8Array} ticket + * @memberof Workflow.WorkflowAccessRequest + * @instance + */ + WorkflowAccessRequest.prototype.ticket = $util.newBuffer([]); + + /** + * Creates a new WorkflowAccessRequest instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowAccessRequest + * @static + * @param {Workflow.IWorkflowAccessRequest=} [properties] Properties to set + * @returns {Workflow.WorkflowAccessRequest} WorkflowAccessRequest instance + */ + WorkflowAccessRequest.create = function create(properties) { + return new WorkflowAccessRequest(properties); + }; + + /** + * Encodes the specified WorkflowAccessRequest message. Does not implicitly {@link Workflow.WorkflowAccessRequest.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowAccessRequest + * @static + * @param {Workflow.IWorkflowAccessRequest} message WorkflowAccessRequest message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowAccessRequest.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.resource != null && Object.hasOwnProperty.call(message, "resource")) + $root.GraphSync.GraphSyncRef.encode(message.resource, writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) + writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.reason); + if (message.ticket != null && Object.hasOwnProperty.call(message, "ticket")) + writer.uint32(/* id 3, wireType 2 =*/26).bytes(message.ticket); + return writer; + }; + + /** + * Decodes a WorkflowAccessRequest message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowAccessRequest + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowAccessRequest} WorkflowAccessRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowAccessRequest.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.Workflow.WorkflowAccessRequest(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.resource = $root.GraphSync.GraphSyncRef.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 2: { + message.reason = reader.bytes(); + break; + } + case 3: { + message.ticket = reader.bytes(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowAccessRequest message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowAccessRequest + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowAccessRequest} WorkflowAccessRequest + */ + WorkflowAccessRequest.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowAccessRequest) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowAccessRequest: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowAccessRequest(); + if (object.resource != null) { + if (!$util.isObject(object.resource)) + throw TypeError(".Workflow.WorkflowAccessRequest.resource: object expected"); + message.resource = $root.GraphSync.GraphSyncRef.fromObject(object.resource, long + 1); + } + if (object.reason != null) + if (typeof object.reason === "string") + $util.base64.decode(object.reason, message.reason = $util.newBuffer($util.base64.length(object.reason)), 0); + else if (object.reason.length >= 0) + message.reason = object.reason; + if (object.ticket != null) + if (typeof object.ticket === "string") + $util.base64.decode(object.ticket, message.ticket = $util.newBuffer($util.base64.length(object.ticket)), 0); + else if (object.ticket.length >= 0) + message.ticket = object.ticket; + return message; + }; + + /** + * Creates a plain object from a WorkflowAccessRequest message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowAccessRequest + * @static + * @param {Workflow.WorkflowAccessRequest} message WorkflowAccessRequest + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowAccessRequest.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.resource = null; + if (options.bytes === String) + object.reason = ""; + else { + object.reason = []; + if (options.bytes !== Array) + object.reason = $util.newBuffer(object.reason); + } + if (options.bytes === String) + object.ticket = ""; + else { + object.ticket = []; + if (options.bytes !== Array) + object.ticket = $util.newBuffer(object.ticket); + } + } + if (message.resource != null && Object.hasOwnProperty.call(message, "resource")) + object.resource = $root.GraphSync.GraphSyncRef.toObject(message.resource, options, q + 1); + if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) + object.reason = options.bytes === String ? $util.base64.encode(message.reason, 0, message.reason.length) : options.bytes === Array ? Array.prototype.slice.call(message.reason) : message.reason; + if (message.ticket != null && Object.hasOwnProperty.call(message, "ticket")) + object.ticket = options.bytes === String ? $util.base64.encode(message.ticket, 0, message.ticket.length) : options.bytes === Array ? Array.prototype.slice.call(message.ticket) : message.ticket; + return object; + }; + + /** + * Converts this WorkflowAccessRequest to JSON. + * @function toJSON + * @memberof Workflow.WorkflowAccessRequest + * @instance + * @returns {Object.} JSON object + */ + WorkflowAccessRequest.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowAccessRequest + * @function getTypeUrl + * @memberof Workflow.WorkflowAccessRequest + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowAccessRequest.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowAccessRequest"; + }; + + return WorkflowAccessRequest; + })(); + + Workflow.WorkflowApprovalOrDenial = (function() { + + /** + * Properties of a WorkflowApprovalOrDenial. + * @memberof Workflow + * @interface IWorkflowApprovalOrDenial + * @property {Uint8Array|null} [flowUid] WorkflowApprovalOrDenial flowUid + * @property {boolean|null} [deny] WorkflowApprovalOrDenial deny + * @property {Uint8Array|null} [denialReason] WorkflowApprovalOrDenial denialReason + */ + + /** + * Constructs a new WorkflowApprovalOrDenial. + * @memberof Workflow + * @classdesc Represents a WorkflowApprovalOrDenial. + * @implements IWorkflowApprovalOrDenial + * @constructor + * @param {Workflow.IWorkflowApprovalOrDenial=} [properties] Properties to set + */ + function WorkflowApprovalOrDenial(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]]; + } + + /** + * WorkflowApprovalOrDenial flowUid. + * @member {Uint8Array} flowUid + * @memberof Workflow.WorkflowApprovalOrDenial + * @instance + */ + WorkflowApprovalOrDenial.prototype.flowUid = $util.newBuffer([]); + + /** + * WorkflowApprovalOrDenial deny. + * @member {boolean} deny + * @memberof Workflow.WorkflowApprovalOrDenial + * @instance + */ + WorkflowApprovalOrDenial.prototype.deny = false; + + /** + * WorkflowApprovalOrDenial denialReason. + * @member {Uint8Array} denialReason + * @memberof Workflow.WorkflowApprovalOrDenial + * @instance + */ + WorkflowApprovalOrDenial.prototype.denialReason = $util.newBuffer([]); + + /** + * Creates a new WorkflowApprovalOrDenial instance using the specified properties. + * @function create + * @memberof Workflow.WorkflowApprovalOrDenial + * @static + * @param {Workflow.IWorkflowApprovalOrDenial=} [properties] Properties to set + * @returns {Workflow.WorkflowApprovalOrDenial} WorkflowApprovalOrDenial instance + */ + WorkflowApprovalOrDenial.create = function create(properties) { + return new WorkflowApprovalOrDenial(properties); + }; + + /** + * Encodes the specified WorkflowApprovalOrDenial message. Does not implicitly {@link Workflow.WorkflowApprovalOrDenial.verify|verify} messages. + * @function encode + * @memberof Workflow.WorkflowApprovalOrDenial + * @static + * @param {Workflow.IWorkflowApprovalOrDenial} message WorkflowApprovalOrDenial message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + WorkflowApprovalOrDenial.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.flowUid != null && Object.hasOwnProperty.call(message, "flowUid")) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.flowUid); + if (message.deny != null && Object.hasOwnProperty.call(message, "deny")) + writer.uint32(/* id 2, wireType 0 =*/16).bool(message.deny); + if (message.denialReason != null && Object.hasOwnProperty.call(message, "denialReason")) + writer.uint32(/* id 3, wireType 2 =*/26).bytes(message.denialReason); + return writer; + }; + + /** + * Decodes a WorkflowApprovalOrDenial message from the specified reader or buffer. + * @function decode + * @memberof Workflow.WorkflowApprovalOrDenial + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.WorkflowApprovalOrDenial} WorkflowApprovalOrDenial + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + WorkflowApprovalOrDenial.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.Workflow.WorkflowApprovalOrDenial(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.flowUid = reader.bytes(); + break; + } + case 2: { + message.deny = reader.bool(); + break; + } + case 3: { + message.denialReason = reader.bytes(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a WorkflowApprovalOrDenial message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.WorkflowApprovalOrDenial + * @static + * @param {Object.} object Plain object + * @returns {Workflow.WorkflowApprovalOrDenial} WorkflowApprovalOrDenial + */ + WorkflowApprovalOrDenial.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.WorkflowApprovalOrDenial) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.WorkflowApprovalOrDenial: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.WorkflowApprovalOrDenial(); + if (object.flowUid != null) + if (typeof object.flowUid === "string") + $util.base64.decode(object.flowUid, message.flowUid = $util.newBuffer($util.base64.length(object.flowUid)), 0); + else if (object.flowUid.length >= 0) + message.flowUid = object.flowUid; + if (object.deny != null) + message.deny = Boolean(object.deny); + if (object.denialReason != null) + if (typeof object.denialReason === "string") + $util.base64.decode(object.denialReason, message.denialReason = $util.newBuffer($util.base64.length(object.denialReason)), 0); + else if (object.denialReason.length >= 0) + message.denialReason = object.denialReason; + return message; + }; + + /** + * Creates a plain object from a WorkflowApprovalOrDenial message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.WorkflowApprovalOrDenial + * @static + * @param {Workflow.WorkflowApprovalOrDenial} message WorkflowApprovalOrDenial + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + WorkflowApprovalOrDenial.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 (options.bytes === String) + object.flowUid = ""; + else { + object.flowUid = []; + if (options.bytes !== Array) + object.flowUid = $util.newBuffer(object.flowUid); + } + object.deny = false; + if (options.bytes === String) + object.denialReason = ""; + else { + object.denialReason = []; + if (options.bytes !== Array) + object.denialReason = $util.newBuffer(object.denialReason); + } + } + if (message.flowUid != null && Object.hasOwnProperty.call(message, "flowUid")) + object.flowUid = options.bytes === String ? $util.base64.encode(message.flowUid, 0, message.flowUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.flowUid) : message.flowUid; + if (message.deny != null && Object.hasOwnProperty.call(message, "deny")) + object.deny = message.deny; + if (message.denialReason != null && Object.hasOwnProperty.call(message, "denialReason")) + object.denialReason = options.bytes === String ? $util.base64.encode(message.denialReason, 0, message.denialReason.length) : options.bytes === Array ? Array.prototype.slice.call(message.denialReason) : message.denialReason; + return object; + }; + + /** + * Converts this WorkflowApprovalOrDenial to JSON. + * @function toJSON + * @memberof Workflow.WorkflowApprovalOrDenial + * @instance + * @returns {Object.} JSON object + */ + WorkflowApprovalOrDenial.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for WorkflowApprovalOrDenial + * @function getTypeUrl + * @memberof Workflow.WorkflowApprovalOrDenial + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + WorkflowApprovalOrDenial.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.WorkflowApprovalOrDenial"; + }; + + return WorkflowApprovalOrDenial; + })(); + + Workflow.UserAccessState = (function() { + + /** + * Properties of a UserAccessState. + * @memberof Workflow + * @interface IUserAccessState + * @property {Array.|null} [workflows] UserAccessState workflows + */ + + /** + * Constructs a new UserAccessState. + * @memberof Workflow + * @classdesc Represents a UserAccessState. + * @implements IUserAccessState + * @constructor + * @param {Workflow.IUserAccessState=} [properties] Properties to set + */ + function UserAccessState(properties) { + this.workflows = []; + 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]]; + } + + /** + * UserAccessState workflows. + * @member {Array.} workflows + * @memberof Workflow.UserAccessState + * @instance + */ + UserAccessState.prototype.workflows = $util.emptyArray; + + /** + * Creates a new UserAccessState instance using the specified properties. + * @function create + * @memberof Workflow.UserAccessState + * @static + * @param {Workflow.IUserAccessState=} [properties] Properties to set + * @returns {Workflow.UserAccessState} UserAccessState instance + */ + UserAccessState.create = function create(properties) { + return new UserAccessState(properties); + }; + + /** + * Encodes the specified UserAccessState message. Does not implicitly {@link Workflow.UserAccessState.verify|verify} messages. + * @function encode + * @memberof Workflow.UserAccessState + * @static + * @param {Workflow.IUserAccessState} message UserAccessState message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + UserAccessState.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.workflows != null && message.workflows.length) + for (let i = 0; i < message.workflows.length; ++i) + $root.Workflow.WorkflowState.encode(message.workflows[i], writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a UserAccessState message from the specified reader or buffer. + * @function decode + * @memberof Workflow.UserAccessState + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.UserAccessState} UserAccessState + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + UserAccessState.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.Workflow.UserAccessState(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.workflows && message.workflows.length)) + message.workflows = []; + message.workflows.push($root.Workflow.WorkflowState.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a UserAccessState message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.UserAccessState + * @static + * @param {Object.} object Plain object + * @returns {Workflow.UserAccessState} UserAccessState + */ + UserAccessState.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.UserAccessState) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.UserAccessState: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.UserAccessState(); + if (object.workflows) { + if (!Array.isArray(object.workflows)) + throw TypeError(".Workflow.UserAccessState.workflows: array expected"); + message.workflows = []; + for (let i = 0; i < object.workflows.length; ++i) { + if (!$util.isObject(object.workflows[i])) + throw TypeError(".Workflow.UserAccessState.workflows: object expected"); + message.workflows[i] = $root.Workflow.WorkflowState.fromObject(object.workflows[i], long + 1); + } + } + return message; + }; + + /** + * Creates a plain object from a UserAccessState message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.UserAccessState + * @static + * @param {Workflow.UserAccessState} message UserAccessState + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + UserAccessState.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.arrays || options.defaults) + object.workflows = []; + if (message.workflows && message.workflows.length) { + object.workflows = []; + for (let j = 0; j < message.workflows.length; ++j) + object.workflows[j] = $root.Workflow.WorkflowState.toObject(message.workflows[j], options, q + 1); + } + return object; + }; + + /** + * Converts this UserAccessState to JSON. + * @function toJSON + * @memberof Workflow.UserAccessState + * @instance + * @returns {Object.} JSON object + */ + UserAccessState.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for UserAccessState + * @function getTypeUrl + * @memberof Workflow.UserAccessState + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + UserAccessState.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.UserAccessState"; + }; + + return UserAccessState; + })(); + + Workflow.ApprovalRequests = (function() { + + /** + * Properties of an ApprovalRequests. + * @memberof Workflow + * @interface IApprovalRequests + * @property {Array.|null} [workflows] ApprovalRequests workflows + */ + + /** + * Constructs a new ApprovalRequests. + * @memberof Workflow + * @classdesc Represents an ApprovalRequests. + * @implements IApprovalRequests + * @constructor + * @param {Workflow.IApprovalRequests=} [properties] Properties to set + */ + function ApprovalRequests(properties) { + this.workflows = []; + 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]]; + } + + /** + * ApprovalRequests workflows. + * @member {Array.} workflows + * @memberof Workflow.ApprovalRequests + * @instance + */ + ApprovalRequests.prototype.workflows = $util.emptyArray; + + /** + * Creates a new ApprovalRequests instance using the specified properties. + * @function create + * @memberof Workflow.ApprovalRequests + * @static + * @param {Workflow.IApprovalRequests=} [properties] Properties to set + * @returns {Workflow.ApprovalRequests} ApprovalRequests instance + */ + ApprovalRequests.create = function create(properties) { + return new ApprovalRequests(properties); + }; + + /** + * Encodes the specified ApprovalRequests message. Does not implicitly {@link Workflow.ApprovalRequests.verify|verify} messages. + * @function encode + * @memberof Workflow.ApprovalRequests + * @static + * @param {Workflow.IApprovalRequests} message ApprovalRequests message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ApprovalRequests.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.workflows != null && message.workflows.length) + for (let i = 0; i < message.workflows.length; ++i) + $root.Workflow.WorkflowProcess.encode(message.workflows[i], writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes an ApprovalRequests message from the specified reader or buffer. + * @function decode + * @memberof Workflow.ApprovalRequests + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.ApprovalRequests} ApprovalRequests + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ApprovalRequests.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.Workflow.ApprovalRequests(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.workflows && message.workflows.length)) + message.workflows = []; + message.workflows.push($root.Workflow.WorkflowProcess.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates an ApprovalRequests message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.ApprovalRequests + * @static + * @param {Object.} object Plain object + * @returns {Workflow.ApprovalRequests} ApprovalRequests + */ + ApprovalRequests.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.ApprovalRequests) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.ApprovalRequests: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.ApprovalRequests(); + if (object.workflows) { + if (!Array.isArray(object.workflows)) + throw TypeError(".Workflow.ApprovalRequests.workflows: array expected"); + message.workflows = []; + for (let i = 0; i < object.workflows.length; ++i) { + if (!$util.isObject(object.workflows[i])) + throw TypeError(".Workflow.ApprovalRequests.workflows: object expected"); + message.workflows[i] = $root.Workflow.WorkflowProcess.fromObject(object.workflows[i], long + 1); + } + } + return message; + }; + + /** + * Creates a plain object from an ApprovalRequests message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.ApprovalRequests + * @static + * @param {Workflow.ApprovalRequests} message ApprovalRequests + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ApprovalRequests.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.arrays || options.defaults) + object.workflows = []; + if (message.workflows && message.workflows.length) { + object.workflows = []; + for (let j = 0; j < message.workflows.length; ++j) + object.workflows[j] = $root.Workflow.WorkflowProcess.toObject(message.workflows[j], options, q + 1); + } + return object; + }; + + /** + * Converts this ApprovalRequests to JSON. + * @function toJSON + * @memberof Workflow.ApprovalRequests + * @instance + * @returns {Object.} JSON object + */ + ApprovalRequests.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for ApprovalRequests + * @function getTypeUrl + * @memberof Workflow.ApprovalRequests + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + ApprovalRequests.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.ApprovalRequests"; + }; + + return ApprovalRequests; + })(); + + Workflow.TimeOfDayRange = (function() { + + /** + * Properties of a TimeOfDayRange. + * @memberof Workflow + * @interface ITimeOfDayRange + * @property {number|null} [startTime] TimeOfDayRange startTime + * @property {number|null} [endTime] TimeOfDayRange endTime + */ + + /** + * Constructs a new TimeOfDayRange. + * @memberof Workflow + * @classdesc Represents a TimeOfDayRange. + * @implements ITimeOfDayRange + * @constructor + * @param {Workflow.ITimeOfDayRange=} [properties] Properties to set + */ + function TimeOfDayRange(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]]; + } + + /** + * TimeOfDayRange startTime. + * @member {number} startTime + * @memberof Workflow.TimeOfDayRange + * @instance + */ + TimeOfDayRange.prototype.startTime = 0; + + /** + * TimeOfDayRange endTime. + * @member {number} endTime + * @memberof Workflow.TimeOfDayRange + * @instance + */ + TimeOfDayRange.prototype.endTime = 0; + + /** + * Creates a new TimeOfDayRange instance using the specified properties. + * @function create + * @memberof Workflow.TimeOfDayRange + * @static + * @param {Workflow.ITimeOfDayRange=} [properties] Properties to set + * @returns {Workflow.TimeOfDayRange} TimeOfDayRange instance + */ + TimeOfDayRange.create = function create(properties) { + return new TimeOfDayRange(properties); + }; + + /** + * Encodes the specified TimeOfDayRange message. Does not implicitly {@link Workflow.TimeOfDayRange.verify|verify} messages. + * @function encode + * @memberof Workflow.TimeOfDayRange + * @static + * @param {Workflow.ITimeOfDayRange} message TimeOfDayRange message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + TimeOfDayRange.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.startTime != null && Object.hasOwnProperty.call(message, "startTime")) + writer.uint32(/* id 1, wireType 0 =*/8).int32(message.startTime); + if (message.endTime != null && Object.hasOwnProperty.call(message, "endTime")) + writer.uint32(/* id 2, wireType 0 =*/16).int32(message.endTime); + return writer; + }; + + /** + * Decodes a TimeOfDayRange message from the specified reader or buffer. + * @function decode + * @memberof Workflow.TimeOfDayRange + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.TimeOfDayRange} TimeOfDayRange + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + TimeOfDayRange.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.Workflow.TimeOfDayRange(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.startTime = reader.int32(); + break; + } + case 2: { + message.endTime = reader.int32(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a TimeOfDayRange message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.TimeOfDayRange + * @static + * @param {Object.} object Plain object + * @returns {Workflow.TimeOfDayRange} TimeOfDayRange + */ + TimeOfDayRange.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.TimeOfDayRange) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.TimeOfDayRange: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.TimeOfDayRange(); + if (object.startTime != null) + message.startTime = object.startTime | 0; + if (object.endTime != null) + message.endTime = object.endTime | 0; + return message; + }; + + /** + * Creates a plain object from a TimeOfDayRange message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.TimeOfDayRange + * @static + * @param {Workflow.TimeOfDayRange} message TimeOfDayRange + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + TimeOfDayRange.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.startTime = 0; + object.endTime = 0; + } + if (message.startTime != null && Object.hasOwnProperty.call(message, "startTime")) + object.startTime = message.startTime; + if (message.endTime != null && Object.hasOwnProperty.call(message, "endTime")) + object.endTime = message.endTime; + return object; + }; + + /** + * Converts this TimeOfDayRange to JSON. + * @function toJSON + * @memberof Workflow.TimeOfDayRange + * @instance + * @returns {Object.} JSON object + */ + TimeOfDayRange.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for TimeOfDayRange + * @function getTypeUrl + * @memberof Workflow.TimeOfDayRange + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + TimeOfDayRange.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.TimeOfDayRange"; + }; + + return TimeOfDayRange; + })(); + + /** + * DayOfWeek enum. + * @name Workflow.DayOfWeek + * @enum {number} + * @property {number} DAY_OF_WEEK_UNSPECIFIED=0 DAY_OF_WEEK_UNSPECIFIED value + * @property {number} MONDAY=1 MONDAY value + * @property {number} TUESDAY=2 TUESDAY value + * @property {number} WEDNESDAY=3 WEDNESDAY value + * @property {number} THURSDAY=4 THURSDAY value + * @property {number} FRIDAY=5 FRIDAY value + * @property {number} SATURDAY=6 SATURDAY value + * @property {number} SUNDAY=7 SUNDAY value + */ + Workflow.DayOfWeek = (function() { + const valuesById = {}, values = Object.create(valuesById); + values[valuesById[0] = "DAY_OF_WEEK_UNSPECIFIED"] = 0; + values[valuesById[1] = "MONDAY"] = 1; + values[valuesById[2] = "TUESDAY"] = 2; + values[valuesById[3] = "WEDNESDAY"] = 3; + values[valuesById[4] = "THURSDAY"] = 4; + values[valuesById[5] = "FRIDAY"] = 5; + values[valuesById[6] = "SATURDAY"] = 6; + values[valuesById[7] = "SUNDAY"] = 7; + return values; + })(); + + /** + * ApprovalQueueKind enum. + * @name Workflow.ApprovalQueueKind + * @enum {number} + * @property {number} AQK_APPROVAL=0 AQK_APPROVAL value + * @property {number} AQK_ESCALATION=1 AQK_ESCALATION value + */ + Workflow.ApprovalQueueKind = (function() { + const valuesById = {}, values = Object.create(valuesById); + values[valuesById[0] = "AQK_APPROVAL"] = 0; + values[valuesById[1] = "AQK_ESCALATION"] = 1; + return values; + })(); + + Workflow.ApprovalQueueEntry = (function() { + + /** + * Properties of an ApprovalQueueEntry. + * @memberof Workflow + * @interface IApprovalQueueEntry + * @property {GraphSync.IGraphSyncRef|null} [flowRef] ApprovalQueueEntry flowRef + * @property {GraphSync.IGraphSyncRef|null} [approverRef] ApprovalQueueEntry approverRef + * @property {Workflow.ApprovalQueueKind|null} [kind] ApprovalQueueEntry kind + * @property {number|null} [notifyAtMs] ApprovalQueueEntry notifyAtMs + * @property {number|null} [requesterUserId] ApprovalQueueEntry requesterUserId + * @property {Uint8Array|null} [predefinedNotificationUid] ApprovalQueueEntry predefinedNotificationUid + */ + + /** + * Constructs a new ApprovalQueueEntry. + * @memberof Workflow + * @classdesc Represents an ApprovalQueueEntry. + * @implements IApprovalQueueEntry + * @constructor + * @param {Workflow.IApprovalQueueEntry=} [properties] Properties to set + */ + function ApprovalQueueEntry(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]]; + } + + /** + * ApprovalQueueEntry flowRef. + * @member {GraphSync.IGraphSyncRef|null|undefined} flowRef + * @memberof Workflow.ApprovalQueueEntry + * @instance + */ + ApprovalQueueEntry.prototype.flowRef = null; + + /** + * ApprovalQueueEntry approverRef. + * @member {GraphSync.IGraphSyncRef|null|undefined} approverRef + * @memberof Workflow.ApprovalQueueEntry + * @instance + */ + ApprovalQueueEntry.prototype.approverRef = null; + + /** + * ApprovalQueueEntry kind. + * @member {Workflow.ApprovalQueueKind} kind + * @memberof Workflow.ApprovalQueueEntry + * @instance + */ + ApprovalQueueEntry.prototype.kind = 0; + + /** + * ApprovalQueueEntry notifyAtMs. + * @member {number} notifyAtMs + * @memberof Workflow.ApprovalQueueEntry + * @instance + */ + ApprovalQueueEntry.prototype.notifyAtMs = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * ApprovalQueueEntry requesterUserId. + * @member {number|null|undefined} requesterUserId + * @memberof Workflow.ApprovalQueueEntry + * @instance + */ + ApprovalQueueEntry.prototype.requesterUserId = null; + + /** + * ApprovalQueueEntry predefinedNotificationUid. + * @member {Uint8Array|null|undefined} predefinedNotificationUid + * @memberof Workflow.ApprovalQueueEntry + * @instance + */ + ApprovalQueueEntry.prototype.predefinedNotificationUid = null; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + // Virtual OneOf for proto3 optional field + Object.defineProperty(ApprovalQueueEntry.prototype, "_requesterUserId", { + get: $util.oneOfGetter($oneOfFields = ["requesterUserId"]), + set: $util.oneOfSetter($oneOfFields) + }); + + // Virtual OneOf for proto3 optional field + Object.defineProperty(ApprovalQueueEntry.prototype, "_predefinedNotificationUid", { + get: $util.oneOfGetter($oneOfFields = ["predefinedNotificationUid"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Creates a new ApprovalQueueEntry instance using the specified properties. + * @function create + * @memberof Workflow.ApprovalQueueEntry + * @static + * @param {Workflow.IApprovalQueueEntry=} [properties] Properties to set + * @returns {Workflow.ApprovalQueueEntry} ApprovalQueueEntry instance + */ + ApprovalQueueEntry.create = function create(properties) { + return new ApprovalQueueEntry(properties); + }; + + /** + * Encodes the specified ApprovalQueueEntry message. Does not implicitly {@link Workflow.ApprovalQueueEntry.verify|verify} messages. + * @function encode + * @memberof Workflow.ApprovalQueueEntry + * @static + * @param {Workflow.IApprovalQueueEntry} message ApprovalQueueEntry message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ApprovalQueueEntry.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.flowRef != null && Object.hasOwnProperty.call(message, "flowRef")) + $root.GraphSync.GraphSyncRef.encode(message.flowRef, writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.approverRef != null && Object.hasOwnProperty.call(message, "approverRef")) + $root.GraphSync.GraphSyncRef.encode(message.approverRef, writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.kind != null && Object.hasOwnProperty.call(message, "kind")) + writer.uint32(/* id 3, wireType 0 =*/24).int32(message.kind); + if (message.notifyAtMs != null && Object.hasOwnProperty.call(message, "notifyAtMs")) + writer.uint32(/* id 4, wireType 0 =*/32).int64(message.notifyAtMs); + if (message.requesterUserId != null && Object.hasOwnProperty.call(message, "requesterUserId")) + writer.uint32(/* id 5, wireType 0 =*/40).int64(message.requesterUserId); + if (message.predefinedNotificationUid != null && Object.hasOwnProperty.call(message, "predefinedNotificationUid")) + writer.uint32(/* id 6, wireType 2 =*/50).bytes(message.predefinedNotificationUid); + return writer; + }; + + /** + * Decodes an ApprovalQueueEntry message from the specified reader or buffer. + * @function decode + * @memberof Workflow.ApprovalQueueEntry + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.ApprovalQueueEntry} ApprovalQueueEntry + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ApprovalQueueEntry.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.Workflow.ApprovalQueueEntry(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.flowRef = $root.GraphSync.GraphSyncRef.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 2: { + message.approverRef = $root.GraphSync.GraphSyncRef.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 3: { + message.kind = reader.int32(); + break; + } + case 4: { + message.notifyAtMs = reader.int64(); + break; + } + case 5: { + message.requesterUserId = reader.int64(); + break; + } + case 6: { + message.predefinedNotificationUid = reader.bytes(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates an ApprovalQueueEntry message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.ApprovalQueueEntry + * @static + * @param {Object.} object Plain object + * @returns {Workflow.ApprovalQueueEntry} ApprovalQueueEntry + */ + ApprovalQueueEntry.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.ApprovalQueueEntry) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.ApprovalQueueEntry: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.ApprovalQueueEntry(); + if (object.flowRef != null) { + if (!$util.isObject(object.flowRef)) + throw TypeError(".Workflow.ApprovalQueueEntry.flowRef: object expected"); + message.flowRef = $root.GraphSync.GraphSyncRef.fromObject(object.flowRef, long + 1); + } + if (object.approverRef != null) { + if (!$util.isObject(object.approverRef)) + throw TypeError(".Workflow.ApprovalQueueEntry.approverRef: object expected"); + message.approverRef = $root.GraphSync.GraphSyncRef.fromObject(object.approverRef, long + 1); + } + switch (object.kind) { + default: + if (typeof object.kind === "number") { + message.kind = object.kind; + break; + } + break; + case "AQK_APPROVAL": + case 0: + message.kind = 0; + break; + case "AQK_ESCALATION": + case 1: + message.kind = 1; + break; + } + if (object.notifyAtMs != null) + if ($util.Long) + message.notifyAtMs = $util.Long.fromValue(object.notifyAtMs, false); + else if (typeof object.notifyAtMs === "string") + message.notifyAtMs = parseInt(object.notifyAtMs, 10); + else if (typeof object.notifyAtMs === "number") + message.notifyAtMs = object.notifyAtMs; + else if (typeof object.notifyAtMs === "object") + message.notifyAtMs = new $util.LongBits(object.notifyAtMs.low >>> 0, object.notifyAtMs.high >>> 0).toNumber(); + if (object.requesterUserId != null) + if ($util.Long) + message.requesterUserId = $util.Long.fromValue(object.requesterUserId, false); + else if (typeof object.requesterUserId === "string") + message.requesterUserId = parseInt(object.requesterUserId, 10); + else if (typeof object.requesterUserId === "number") + message.requesterUserId = object.requesterUserId; + else if (typeof object.requesterUserId === "object") + message.requesterUserId = new $util.LongBits(object.requesterUserId.low >>> 0, object.requesterUserId.high >>> 0).toNumber(); + if (object.predefinedNotificationUid != null) + if (typeof object.predefinedNotificationUid === "string") + $util.base64.decode(object.predefinedNotificationUid, message.predefinedNotificationUid = $util.newBuffer($util.base64.length(object.predefinedNotificationUid)), 0); + else if (object.predefinedNotificationUid.length >= 0) + message.predefinedNotificationUid = object.predefinedNotificationUid; + return message; + }; + + /** + * Creates a plain object from an ApprovalQueueEntry message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.ApprovalQueueEntry + * @static + * @param {Workflow.ApprovalQueueEntry} message ApprovalQueueEntry + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ApprovalQueueEntry.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.flowRef = null; + object.approverRef = null; + object.kind = options.enums === String ? "AQK_APPROVAL" : 0; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.notifyAtMs = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.notifyAtMs = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + } + if (message.flowRef != null && Object.hasOwnProperty.call(message, "flowRef")) + object.flowRef = $root.GraphSync.GraphSyncRef.toObject(message.flowRef, options, q + 1); + if (message.approverRef != null && Object.hasOwnProperty.call(message, "approverRef")) + object.approverRef = $root.GraphSync.GraphSyncRef.toObject(message.approverRef, options, q + 1); + if (message.kind != null && Object.hasOwnProperty.call(message, "kind")) + object.kind = options.enums === String ? $root.Workflow.ApprovalQueueKind[message.kind] === undefined ? message.kind : $root.Workflow.ApprovalQueueKind[message.kind] : message.kind; + if (message.notifyAtMs != null && Object.hasOwnProperty.call(message, "notifyAtMs")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.notifyAtMs = typeof message.notifyAtMs === "number" ? BigInt(message.notifyAtMs) : $util.Long.fromBits(message.notifyAtMs.low >>> 0, message.notifyAtMs.high >>> 0, false).toBigInt(); + else if (typeof message.notifyAtMs === "number") + object.notifyAtMs = options.longs === String ? String(message.notifyAtMs) : message.notifyAtMs; + else + object.notifyAtMs = options.longs === String ? $util.Long.prototype.toString.call(message.notifyAtMs) : options.longs === Number ? new $util.LongBits(message.notifyAtMs.low >>> 0, message.notifyAtMs.high >>> 0).toNumber() : message.notifyAtMs; + if (message.requesterUserId != null && Object.hasOwnProperty.call(message, "requesterUserId")) { + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.requesterUserId = typeof message.requesterUserId === "number" ? BigInt(message.requesterUserId) : $util.Long.fromBits(message.requesterUserId.low >>> 0, message.requesterUserId.high >>> 0, false).toBigInt(); + else if (typeof message.requesterUserId === "number") + object.requesterUserId = options.longs === String ? String(message.requesterUserId) : message.requesterUserId; + else + object.requesterUserId = options.longs === String ? $util.Long.prototype.toString.call(message.requesterUserId) : options.longs === Number ? new $util.LongBits(message.requesterUserId.low >>> 0, message.requesterUserId.high >>> 0).toNumber() : message.requesterUserId; + if (options.oneofs) + object._requesterUserId = "requesterUserId"; + } + if (message.predefinedNotificationUid != null && Object.hasOwnProperty.call(message, "predefinedNotificationUid")) { + object.predefinedNotificationUid = options.bytes === String ? $util.base64.encode(message.predefinedNotificationUid, 0, message.predefinedNotificationUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.predefinedNotificationUid) : message.predefinedNotificationUid; + if (options.oneofs) + object._predefinedNotificationUid = "predefinedNotificationUid"; + } + return object; + }; + + /** + * Converts this ApprovalQueueEntry to JSON. + * @function toJSON + * @memberof Workflow.ApprovalQueueEntry + * @instance + * @returns {Object.} JSON object + */ + ApprovalQueueEntry.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for ApprovalQueueEntry + * @function getTypeUrl + * @memberof Workflow.ApprovalQueueEntry + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + ApprovalQueueEntry.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.ApprovalQueueEntry"; + }; + + return ApprovalQueueEntry; + })(); + + Workflow.TemporalAccessFilter = (function() { + + /** + * Properties of a TemporalAccessFilter. + * @memberof Workflow + * @interface ITemporalAccessFilter + * @property {Array.|null} [timeRanges] TemporalAccessFilter timeRanges + * @property {Array.|null} [allowedDays] TemporalAccessFilter allowedDays + * @property {string|null} [timeZone] TemporalAccessFilter timeZone + */ + + /** + * Constructs a new TemporalAccessFilter. + * @memberof Workflow + * @classdesc Represents a TemporalAccessFilter. + * @implements ITemporalAccessFilter + * @constructor + * @param {Workflow.ITemporalAccessFilter=} [properties] Properties to set + */ + function TemporalAccessFilter(properties) { + this.timeRanges = []; + this.allowedDays = []; + 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]]; + } + + /** + * TemporalAccessFilter timeRanges. + * @member {Array.} timeRanges + * @memberof Workflow.TemporalAccessFilter + * @instance + */ + TemporalAccessFilter.prototype.timeRanges = $util.emptyArray; + + /** + * TemporalAccessFilter allowedDays. + * @member {Array.} allowedDays + * @memberof Workflow.TemporalAccessFilter + * @instance + */ + TemporalAccessFilter.prototype.allowedDays = $util.emptyArray; + + /** + * TemporalAccessFilter timeZone. + * @member {string} timeZone + * @memberof Workflow.TemporalAccessFilter + * @instance + */ + TemporalAccessFilter.prototype.timeZone = ""; + + /** + * Creates a new TemporalAccessFilter instance using the specified properties. + * @function create + * @memberof Workflow.TemporalAccessFilter + * @static + * @param {Workflow.ITemporalAccessFilter=} [properties] Properties to set + * @returns {Workflow.TemporalAccessFilter} TemporalAccessFilter instance + */ + TemporalAccessFilter.create = function create(properties) { + return new TemporalAccessFilter(properties); + }; + + /** + * Encodes the specified TemporalAccessFilter message. Does not implicitly {@link Workflow.TemporalAccessFilter.verify|verify} messages. + * @function encode + * @memberof Workflow.TemporalAccessFilter + * @static + * @param {Workflow.ITemporalAccessFilter} message TemporalAccessFilter message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + TemporalAccessFilter.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.timeRanges != null && message.timeRanges.length) + for (let i = 0; i < message.timeRanges.length; ++i) + $root.Workflow.TimeOfDayRange.encode(message.timeRanges[i], writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.allowedDays != null && message.allowedDays.length) { + writer.uint32(/* id 2, wireType 2 =*/18).fork(); + for (let i = 0; i < message.allowedDays.length; ++i) + writer.int32(message.allowedDays[i]); + writer.ldelim(); + } + if (message.timeZone != null && Object.hasOwnProperty.call(message, "timeZone")) + writer.uint32(/* id 3, wireType 2 =*/26).string(message.timeZone); + return writer; + }; + + /** + * Decodes a TemporalAccessFilter message from the specified reader or buffer. + * @function decode + * @memberof Workflow.TemporalAccessFilter + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.TemporalAccessFilter} TemporalAccessFilter + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + TemporalAccessFilter.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.Workflow.TemporalAccessFilter(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.timeRanges && message.timeRanges.length)) + message.timeRanges = []; + message.timeRanges.push($root.Workflow.TimeOfDayRange.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 2: { + if (!(message.allowedDays && message.allowedDays.length)) + message.allowedDays = []; + if ((tag & 7) === 2) { + let end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) + message.allowedDays.push(reader.int32()); + } else + message.allowedDays.push(reader.int32()); + break; + } + case 3: { + message.timeZone = reader.string(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a TemporalAccessFilter message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.TemporalAccessFilter + * @static + * @param {Object.} object Plain object + * @returns {Workflow.TemporalAccessFilter} TemporalAccessFilter + */ + TemporalAccessFilter.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.TemporalAccessFilter) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.TemporalAccessFilter: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.TemporalAccessFilter(); + if (object.timeRanges) { + if (!Array.isArray(object.timeRanges)) + throw TypeError(".Workflow.TemporalAccessFilter.timeRanges: array expected"); + message.timeRanges = []; + for (let i = 0; i < object.timeRanges.length; ++i) { + if (!$util.isObject(object.timeRanges[i])) + throw TypeError(".Workflow.TemporalAccessFilter.timeRanges: object expected"); + message.timeRanges[i] = $root.Workflow.TimeOfDayRange.fromObject(object.timeRanges[i], long + 1); + } + } + if (object.allowedDays) { + if (!Array.isArray(object.allowedDays)) + throw TypeError(".Workflow.TemporalAccessFilter.allowedDays: array expected"); + message.allowedDays = []; + for (let i = 0; i < object.allowedDays.length; ++i) + switch (object.allowedDays[i]) { + default: + if (typeof object.allowedDays[i] === "number") { + message.allowedDays[i] = object.allowedDays[i]; + break; + } + case "DAY_OF_WEEK_UNSPECIFIED": + case 0: + message.allowedDays[i] = 0; + break; + case "MONDAY": + case 1: + message.allowedDays[i] = 1; + break; + case "TUESDAY": + case 2: + message.allowedDays[i] = 2; + break; + case "WEDNESDAY": + case 3: + message.allowedDays[i] = 3; + break; + case "THURSDAY": + case 4: + message.allowedDays[i] = 4; + break; + case "FRIDAY": + case 5: + message.allowedDays[i] = 5; + break; + case "SATURDAY": + case 6: + message.allowedDays[i] = 6; + break; + case "SUNDAY": + case 7: + message.allowedDays[i] = 7; + break; + } + } + if (object.timeZone != null) + message.timeZone = String(object.timeZone); + return message; + }; + + /** + * Creates a plain object from a TemporalAccessFilter message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.TemporalAccessFilter + * @static + * @param {Workflow.TemporalAccessFilter} message TemporalAccessFilter + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + TemporalAccessFilter.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.arrays || options.defaults) { + object.timeRanges = []; + object.allowedDays = []; + } + if (options.defaults) + object.timeZone = ""; + if (message.timeRanges && message.timeRanges.length) { + object.timeRanges = []; + for (let j = 0; j < message.timeRanges.length; ++j) + object.timeRanges[j] = $root.Workflow.TimeOfDayRange.toObject(message.timeRanges[j], options, q + 1); + } + if (message.allowedDays && message.allowedDays.length) { + object.allowedDays = []; + for (let j = 0; j < message.allowedDays.length; ++j) + object.allowedDays[j] = options.enums === String ? $root.Workflow.DayOfWeek[message.allowedDays[j]] === undefined ? message.allowedDays[j] : $root.Workflow.DayOfWeek[message.allowedDays[j]] : message.allowedDays[j]; + } + if (message.timeZone != null && Object.hasOwnProperty.call(message, "timeZone")) + object.timeZone = message.timeZone; + return object; + }; + + /** + * Converts this TemporalAccessFilter to JSON. + * @function toJSON + * @memberof Workflow.TemporalAccessFilter + * @instance + * @returns {Object.} JSON object + */ + TemporalAccessFilter.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for TemporalAccessFilter + * @function getTypeUrl + * @memberof Workflow.TemporalAccessFilter + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + TemporalAccessFilter.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.TemporalAccessFilter"; + }; + + return TemporalAccessFilter; + })(); + + Workflow.AuthorizedUsers = (function() { + + /** + * Properties of an AuthorizedUsers. + * @memberof Workflow + * @interface IAuthorizedUsers + * @property {Array.|null} [username] AuthorizedUsers username + */ + + /** + * Constructs a new AuthorizedUsers. + * @memberof Workflow + * @classdesc Represents an AuthorizedUsers. + * @implements IAuthorizedUsers + * @constructor + * @param {Workflow.IAuthorizedUsers=} [properties] Properties to set + */ + function AuthorizedUsers(properties) { + this.username = []; + 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]]; + } + + /** + * AuthorizedUsers username. + * @member {Array.} username + * @memberof Workflow.AuthorizedUsers + * @instance + */ + AuthorizedUsers.prototype.username = $util.emptyArray; + + /** + * Creates a new AuthorizedUsers instance using the specified properties. + * @function create + * @memberof Workflow.AuthorizedUsers + * @static + * @param {Workflow.IAuthorizedUsers=} [properties] Properties to set + * @returns {Workflow.AuthorizedUsers} AuthorizedUsers instance + */ + AuthorizedUsers.create = function create(properties) { + return new AuthorizedUsers(properties); + }; + + /** + * Encodes the specified AuthorizedUsers message. Does not implicitly {@link Workflow.AuthorizedUsers.verify|verify} messages. + * @function encode + * @memberof Workflow.AuthorizedUsers + * @static + * @param {Workflow.IAuthorizedUsers} message AuthorizedUsers message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + AuthorizedUsers.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.username != null && message.username.length) + for (let i = 0; i < message.username.length; ++i) + writer.uint32(/* id 1, wireType 2 =*/10).string(message.username[i]); + return writer; + }; + + /** + * Decodes an AuthorizedUsers message from the specified reader or buffer. + * @function decode + * @memberof Workflow.AuthorizedUsers + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {Workflow.AuthorizedUsers} AuthorizedUsers + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + AuthorizedUsers.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.Workflow.AuthorizedUsers(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.username && message.username.length)) + message.username = []; + message.username.push(reader.string()); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates an AuthorizedUsers message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Workflow.AuthorizedUsers + * @static + * @param {Object.} object Plain object + * @returns {Workflow.AuthorizedUsers} AuthorizedUsers + */ + AuthorizedUsers.fromObject = function fromObject(object, long) { + if (object instanceof $root.Workflow.AuthorizedUsers) + return object; + if (!$util.isObject(object)) + throw TypeError(".Workflow.AuthorizedUsers: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.Workflow.AuthorizedUsers(); + if (object.username) { + if (!Array.isArray(object.username)) + throw TypeError(".Workflow.AuthorizedUsers.username: array expected"); + message.username = []; + for (let i = 0; i < object.username.length; ++i) + message.username[i] = String(object.username[i]); + } + return message; + }; + + /** + * Creates a plain object from an AuthorizedUsers message. Also converts values to other types if specified. + * @function toObject + * @memberof Workflow.AuthorizedUsers + * @static + * @param {Workflow.AuthorizedUsers} message AuthorizedUsers + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + AuthorizedUsers.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.arrays || options.defaults) + object.username = []; + if (message.username && message.username.length) { + object.username = []; + for (let j = 0; j < message.username.length; ++j) + object.username[j] = message.username[j]; + } + return object; + }; + + /** + * Converts this AuthorizedUsers to JSON. + * @function toJSON + * @memberof Workflow.AuthorizedUsers + * @instance + * @returns {Object.} JSON object + */ + AuthorizedUsers.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for AuthorizedUsers + * @function getTypeUrl + * @memberof Workflow.AuthorizedUsers + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + AuthorizedUsers.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/Workflow.AuthorizedUsers"; + }; + + return AuthorizedUsers; + })(); + + return Workflow; +})(); + diff --git a/keeperapi/src/proto/index.js b/keeperapi/src/proto/index.js index 82a3b393..f1788c17 100644 --- a/keeperapi/src/proto/index.js +++ b/keeperapi/src/proto/index.js @@ -23,3 +23,4 @@ export { google } from './google.js'; export { Router } from './Router.js'; export { PAM } from './PAM.js'; export { folder } from './Remove.js'; +export { Workflow } from './Workflow.js'; diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 8edaf50a..022984ff 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -19,6 +19,7 @@ import { NotificationCenter, record, folder, + Workflow, } from './proto' // generated protobuf has all properties optional and nullable, while this is not an issue for KeeperApp, this type fixes it @@ -960,6 +961,11 @@ export const automatorAdminResetMessage = ( export const getControllers = (): RestOutMessage => createOutMessage('pam/get_controllers', PAM.PAMControllersResponse) +export const getConfigurationControllerMessage = ( + data: PAM.IPAMGenericUidRequest +): RestMessage => + createMessage(data, 'pam/get_configuration_controller', PAM.PAMGenericUidRequest, PAM.PAMController) + /* -- PAM Router (DAG GraphSync) -- */ export const pamSyncMessage = ( @@ -990,6 +996,65 @@ export const pamGetLeafsMessage = ( export const pamGetOnlineControllersMessage = (): RestOutMessage => createOutMessage('api/user/get_controllers', PAM.PAMOnlineControllers) +export const readWorkflowConfigMessage = ( + data: GraphSync.IGraphSyncRef +): RestMessage => + createMessage(data, 'api/user/read_workflow_config', GraphSync.GraphSyncRef, Workflow.WorkflowConfig) + +export const createWorkflowConfigMessage = ( + data: Workflow.IWorkflowParameters +): RestInMessage => + createInMessage(data, 'api/user/create_workflow_config', Workflow.WorkflowParameters) + +export const updateWorkflowConfigMessage = ( + data: Workflow.IWorkflowParameters +): RestInMessage => + createInMessage(data, 'api/user/update_workflow_config', Workflow.WorkflowParameters) + +export const deleteWorkflowConfigMessage = (data: GraphSync.IGraphSyncRef): RestInMessage => + createInMessage(data, 'api/user/delete_workflow_config', GraphSync.GraphSyncRef) + +export const addWorkflowApproversMessage = (data: Workflow.IWorkflowConfig): RestInMessage => + createInMessage(data, 'api/user/add_workflow_approvers', Workflow.WorkflowConfig) + +export const deleteWorkflowApproversMessage = ( + data: Workflow.IWorkflowConfig +): RestInMessage => + createInMessage(data, 'api/user/delete_workflow_approvers', Workflow.WorkflowConfig) + +export const getWorkflowStateMessage = ( + data: Workflow.IWorkflowState +): RestMessage => + createMessage(data, 'api/user/get_workflow_state', Workflow.WorkflowState, Workflow.WorkflowState) + +export const getUserAccessStateMessage = (): RestOutMessage => + createOutMessage('api/user/get_user_access_state', Workflow.UserAccessState) + +export const getApprovalRequestsMessage = (): RestOutMessage => + createOutMessage('api/user/get_approval_requests', Workflow.ApprovalRequests) + +export const startWorkflowMessage = (data: Workflow.IWorkflowState): RestInMessage => + createInMessage(data, 'api/user/start_workflow', Workflow.WorkflowState) + +export const requestWorkflowAccessMessage = ( + data: Workflow.IWorkflowAccessRequest +): RestInMessage => + createInMessage(data, 'api/user/request_workflow_access', Workflow.WorkflowAccessRequest) + +export const requestEscalationMessage = (data: Workflow.IWorkflowState): RestInMessage => + createInMessage(data, 'api/user/request_escalation', Workflow.WorkflowState) + +export const approveOrDenyWorkflowAccessMessage = ( + data: Workflow.IWorkflowApprovalOrDenial +): RestInMessage => + createInMessage(data, 'api/user/approve_or_deny_workflow_access', Workflow.WorkflowApprovalOrDenial) + +export const endWorkflowMessage = (data: GraphSync.IGraphSyncRef): RestInMessage => + createInMessage(data, 'api/user/end_workflow', GraphSync.GraphSyncRef) + +export const forceCheckinMessage = (data: GraphSync.IGraphSyncRef): RestInMessage => + createInMessage(data, 'api/user/force_checkin', GraphSync.GraphSyncRef) + export const keeperDriveRecordsAdd = ( data: record.v3.IRecordsAddRequest ): RestMessage => From 626dfe3c116fd7457787d7d3b208edf83062f8fb Mon Sep 17 00:00:00 2001 From: Sergei Velichkin Date: Wed, 1 Jul 2026 17:42:24 -0400 Subject: [PATCH 25/48] BE-7755: preserve platform-connect side-effects in sideEffects allowlist --- keeperapi/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/keeperapi/package.json b/keeperapi/package.json index f06bfe56..c8c9f614 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -6,7 +6,9 @@ "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", "sideEffects": [ - "**/configureProtobuf*" + "**/configureProtobuf*", + "**/browser/index.js", + "**/worker/browserWorker.js" ], "repository": "https://github.com/Keeper-Security/keeper-sdk-javascript", "license": "ISC", From c3a260883aceb77b40d539de4b86b2ac854c668e Mon Sep 17 00:00:00 2001 From: Sergei Velichkin Date: Wed, 1 Jul 2026 18:30:16 -0400 Subject: [PATCH 26/48] BE-7755: bump keeperapi to 18.0.4 --- keeperapi/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeperapi/package.json b/keeperapi/package.json index c8c9f614..0d4dbf4a 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.3", + "version": "18.0.4", "browser": "dist/browser/index.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", From c07911f2ae85edc00a21e708c82f03f77b86a64f Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 2 Jul 2026 21:19:55 +0530 Subject: [PATCH 27/48] Reporting command implementation(Audit, Action and Password). (#194) --- .../EnterpriseReportManager.ts | 35 + .../src/enterpriseReport/actionReport.ts | 518 ++++++++++++ KeeperSdk/src/enterpriseReport/auditReport.ts | 739 ++++++++++++++++++ KeeperSdk/src/enterpriseReport/index.ts | 54 ++ .../src/enterpriseReport/passwordReport.ts | 492 ++++++++++++ KeeperSdk/src/enterpriseReport/reportTypes.ts | 412 ++++++++++ KeeperSdk/src/enterpriseReport/reportUtils.ts | 36 + KeeperSdk/src/index.ts | 69 +- KeeperSdk/src/utils/Logger.ts | 16 +- KeeperSdk/src/utils/constants.ts | 43 + KeeperSdk/src/utils/index.ts | 4 + KeeperSdk/src/vault/KeeperVault.ts | 44 ++ examples/sdk_example/package.json | 3 + .../src/enterpriseReport/action_report.ts | 122 +++ .../src/enterpriseReport/audit_report.ts | 179 +++++ .../src/enterpriseReport/password_report.ts | 80 ++ .../sdk_example/src/utils/reportFormat.ts | 125 +++ keeperapi/src/commands.ts | 77 ++ 18 files changed, 3027 insertions(+), 21 deletions(-) create mode 100644 KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts create mode 100644 KeeperSdk/src/enterpriseReport/actionReport.ts create mode 100644 KeeperSdk/src/enterpriseReport/auditReport.ts create mode 100644 KeeperSdk/src/enterpriseReport/index.ts create mode 100644 KeeperSdk/src/enterpriseReport/passwordReport.ts create mode 100644 KeeperSdk/src/enterpriseReport/reportTypes.ts create mode 100644 KeeperSdk/src/enterpriseReport/reportUtils.ts create mode 100644 examples/sdk_example/src/enterpriseReport/action_report.ts create mode 100644 examples/sdk_example/src/enterpriseReport/audit_report.ts create mode 100644 examples/sdk_example/src/enterpriseReport/password_report.ts create mode 100644 examples/sdk_example/src/utils/reportFormat.ts diff --git a/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts b/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts new file mode 100644 index 00000000..92b28e2d --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/EnterpriseReportManager.ts @@ -0,0 +1,35 @@ +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' + +export class EnterpriseReportManager { + private readonly authProvider: AuthProvider + + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider + } + + public async runAuditReport(options: AuditReportOptions = {}): Promise { + return runAuditReport(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 + } +} diff --git a/KeeperSdk/src/enterpriseReport/actionReport.ts b/KeeperSdk/src/enterpriseReport/actionReport.ts new file mode 100644 index 00000000..c59fb2d3 --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/actionReport.ts @@ -0,0 +1,518 @@ +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' +import { + 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' + +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' + +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]), +} + +function usernameTargetConfig( + pickCandidates: (users: EnterpriseUser[]) => EnterpriseUser[] +): TargetAuditConfig { + 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') + ), +} + +export async function runActionReport( + 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, + } +} + +export function getAllowedActions(target: TargetUserStatus): AdminAction[] { + return [...ALLOWED_ACTIONS[target]] +} + +export function getDefaultDaysSince(target: TargetUserStatus): number { + 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 + } +): 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)) +} + +async function queryUsersWithRecentEvents( + 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()) + } + } + + 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 || []), + } +} + +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 +): 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()])) +} + +function buildUserRoleNames( + 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(), + ]) + ) +} + +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 } +): 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 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) { + return { + action: applyAction, + status: 'failed', + affectedCount: 0, + serverMessage: extractErrorMessage(err), + } + } +} + +function validateActionReportOptions( + 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 + ) + } +} + +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) + } + + 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 +} + +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' + } +} diff --git a/KeeperSdk/src/enterpriseReport/auditReport.ts b/KeeperSdk/src/enterpriseReport/auditReport.ts new file mode 100644 index 00000000..c0a975cd --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/auditReport.ts @@ -0,0 +1,739 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + 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' +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) +} + +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, + } +} + +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 +): 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) + } + } + } + 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] +): 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, + } +} + +async function fetchRawEvents( + 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 + } + + 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[] + } +): 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[] +} + +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 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 +} + +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 +} + +function invalidateAuditCachesIfUserChanged(auth: Auth): void { + 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 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 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 +): 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 +} + +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 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) } +} + +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) +} + +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, + } +} + +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 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] + } +} + +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 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) +} + +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()) +} + +function addDays(date: Date, days: number): Date { + 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 new file mode 100644 index 00000000..3acf2bfd --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/index.ts @@ -0,0 +1,54 @@ +export { + runAuditReport, +} from './auditReport' +export { + runActionReport, + getAllowedActions, + getDefaultDaysSince, +} from './actionReport' +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' +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' +export type { + 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 new file mode 100644 index 00000000..3cac6a4b --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/passwordReport.ts @@ -0,0 +1,492 @@ +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 { + 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 +} + +type BWPasswordEntry = { + value?: string + status?: number | string +} + +type BreachWatchRecordData = { + 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 } +} + +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 } + } + + 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 buildPasswordPolicySummary(policy: PasswordPolicy): string { + 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 + } + + 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) + } + } + 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 + } + } + } + if (sequenceCount > 0) score -= 3 * sequenceCount + + if (score < 0) return 0 + if (score > 100) return 100 + return score +} + +export async function runPasswordReport( + 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, + } +} + +function parsePolicyNumber(part: string): number { + 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 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 + ) + } +} + +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(' @ ') +} + +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 +} + +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) +} + +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 + } + + 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 +} + +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) + } + } + + 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) + } + } + 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) + } + } + + return uids +} + +async function resolveTargetRecords( + 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)) +} + +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 +): 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] +} + +function applyVerboseFields( + 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) + } +} diff --git a/KeeperSdk/src/enterpriseReport/reportTypes.ts b/KeeperSdk/src/enterpriseReport/reportTypes.ts new file mode 100644 index 00000000..f3cde4f6 --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/reportTypes.ts @@ -0,0 +1,412 @@ +export type AuditReportType = 'raw' | 'dim' | '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 +} + +export type AuditDimensionIpAddress = { + 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[] +} + +export type AuditDimensionRow = + | 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[] +} + +export enum AuditReportOrder { + Asc = 'asc', + Desc = 'desc', +} + +export enum AuditReportFormat { + Message = 'message', + Fields = 'fields', +} + +export enum AuditOutputFormat { + Table = 'table', + Json = 'json', + Csv = 'csv', +} + +export enum AuditAggregate { + Occurrences = 'occurrences', + FirstCreated = 'first_created', + LastCreated = 'last_created', +} + +export const SUMMARY_REPORT_TYPES: readonly AuditSummaryReportType[] = [ + '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] + +export type CreatedFilterCriteria = { + 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[] +} + +export type AuditReportOptions = { + 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 }> +} + +export enum TargetUserStatus { + NoLogon = 'no-logon', + NoUpdate = 'no-update', + Locked = 'locked', + Invited = 'invited', + NoRecovery = 'no-recovery', +} + +export enum AdminAction { + 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', +} + +export const DEFAULT_ACTION_REPORT_COLUMNS: readonly ActionReportColumn[] = [ + 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 + +export type ActionReportEntry = { + 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 +} + +export type ActionResult = { + action: AdminAction | string + status: string + affectedCount: number + serverMessage: string +} + +export type ActionReportResult = { + 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 type PasswordPolicy = { + length: number + lower: number + upper: number + digits: number + special: number +} + +export type PasswordStrength = { + 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 +} + +export type PasswordReportOptions = { + 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 +} + +export const AUDIT_RAW_FIELDS = [ + '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 +export const AUDIT_VIRTUAL_DIMENSIONS: Readonly> = { + 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', +} + +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', +} + +export type EnterpriseAuditLicense = { + 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 +} diff --git a/KeeperSdk/src/enterpriseReport/reportUtils.ts b/KeeperSdk/src/enterpriseReport/reportUtils.ts new file mode 100644 index 00000000..cf647d99 --- /dev/null +++ b/KeeperSdk/src/enterpriseReport/reportUtils.ts @@ -0,0 +1,36 @@ +import type { Auth, KeeperResponse } from '@keeper-security/keeperapi' +import { KeeperSdkError } from '../utils' +import { AuditReportOrder } from './reportTypes' + +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}` +} + +export function assertSucceeded( + 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 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 +} diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 079460b9..58511323 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -27,6 +27,7 @@ export { setLogger, getLogger, resetLogger, + writeOutput, KeeperSdkError, isKeeperError, extractErrorMessage, @@ -41,6 +42,9 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + AuditReportErrorCode, + ActionReportErrorCode, + PasswordReportErrorCode, KEEPER_PUBLIC_HOSTS, isBoolean, isString, @@ -75,22 +79,6 @@ export type { PassphraseGenOptions, } from "./utils"; -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, @@ -236,6 +224,55 @@ export { userPermissionToText, recordPermissionToText, } from "./folders/folderTree"; + +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, + DEFAULT_TRUNCATION_LENGTH, + SUPPORTED_RECORD_VERSIONS, +} from "./enterpriseReport"; +export type { + AuditReportOptions, + AuditReportResult, + AuditReportFilter, + CreatedFilterCriteria, + CreatedPreset, + AuditEventOverviewReportRow, + AuditReportType, + AuditSummaryReportType, + ActionReportEntry, + ActionReportOptions, + ActionReportResult, + ActionResult, + PasswordPolicy, + PasswordStrength, + PasswordReportRow, + PasswordReportOptions, + PasswordReportResult, +} from "./enterpriseReport"; + export type { FolderTreeBuildOptions, FolderTreeNode, diff --git a/KeeperSdk/src/utils/Logger.ts b/KeeperSdk/src/utils/Logger.ts index b362575d..2ba99a99 100644 --- a/KeeperSdk/src/utils/Logger.ts +++ b/KeeperSdk/src/utils/Logger.ts @@ -16,12 +16,18 @@ export interface ILogger { 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]`) +} + function sanitizeArg(arg: unknown): unknown { - if (typeof arg !== "string") return arg; - return arg.replace( - CREDENTIAL_PATTERN, - (_, key: string) => `${key}=[REDACTED]`, - ); + 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`); } export class ConsoleLogger implements ILogger { diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index d89d2c5a..755d0c26 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -88,6 +88,32 @@ export enum TeamErrorCode { TeamNameTooLong = "team_name_too_long", } +export enum AuditReportErrorCode { + InvalidReportType = 'audit_invalid_report_type', + InvalidCreatedFilter = 'audit_invalid_created_filter', + InvalidFilter = 'audit_invalid_filter', + InvalidLimit = 'audit_invalid_limit', + ColumnsRequired = 'audit_columns_required', + DimensionColumnRequired = 'audit_dimension_column_required', + DimensionFailed = 'audit_dimension_failed', + ReportFailed = 'audit_report_failed', + ReportingNotEnabled = 'audit_reporting_not_enabled', + LicenseCheckFailed = 'audit_license_check_failed', +} + +export enum ActionReportErrorCode { + ReportFailed = 'action_report_failed', + InvalidAction = 'action_report_invalid_action', + TargetUserRequired = 'action_report_target_user_required', + TransferNotSupported = 'action_report_transfer_not_supported', + NodeNotFound = 'action_report_node_not_found', + NodeNotUnique = 'action_report_node_not_unique', +} + +export enum PasswordReportErrorCode { + PolicyRequired = 'password_report_policy_required', +} + export enum UserErrorCode { NoUsersToUpdate = "no_users_to_update", NoUsersToAdd = "no_users_to_add", @@ -182,6 +208,23 @@ export const ResultCodes = { TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, 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 = { diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index ae56daf6..e5ba9d15 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -8,6 +8,9 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + AuditReportErrorCode, + ActionReportErrorCode, + PasswordReportErrorCode, NsfErrorCode, KEEPER_PUBLIC_HOSTS, } from "./constants"; @@ -19,6 +22,7 @@ export { setLogger, getLogger, resetLogger, + writeOutput, } from "./Logger"; export type { ILogger } from "./Logger"; export { diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index e95bd8dc..bc699708 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -38,6 +38,16 @@ import { 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, @@ -206,6 +216,7 @@ export class KeeperVault { private readonly sharedFolderManager: SharedFolderManager; private readonly teamManager: TeamManager; private readonly roleManager: RoleManager; + private readonly enterpriseReportManager: EnterpriseReportManager; private readonly userManager: UserManager; private readonly nestedShareFolderManager: NestedShareFolderManager; @@ -241,6 +252,7 @@ export class KeeperVault { ); 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, @@ -268,6 +280,20 @@ export class KeeperVault { 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 { @@ -783,6 +809,24 @@ export class KeeperVault { 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); } diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 165e6c7b..65441eca 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -46,6 +46,9 @@ "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", + "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" diff --git a/examples/sdk_example/src/enterpriseReport/action_report.ts b/examples/sdk_example/src/enterpriseReport/action_report.ts new file mode 100644 index 00000000..111afcb9 --- /dev/null +++ b/examples/sdk_example/src/enterpriseReport/action_report.ts @@ -0,0 +1,122 @@ +import { + AdminAction, + AuditOutputFormat, + TargetUserStatus, + cleanup, + extractErrorMessage, + getAllowedActions, + getDefaultDaysSince, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { ActionReportOptions } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' +import { formatActionReportResult } from '../utils/reportFormat' + +async function actionReportExample() { + const vault = await login() + + try { + const targetRaw = ( + await prompt('Target status [no-logon/no-update/locked/invited/no-recovery, default no-logon]: ') + ) + .trim() + .toLowerCase() + const target = (targetRaw || TargetUserStatus.NoLogon) as TargetUserStatus + + const defaultDays = getDefaultDaysSince(target) + const daysRaw = (await prompt(`Days since last event [default ${defaultDays}]: `)).trim() + const daysSince = daysRaw ? Number.parseInt(daysRaw, 10) : defaultDays + if (!Number.isFinite(daysSince) || daysSince <= 0) { + logger.error('Days since must be a positive number.') + process.exitCode = 1 + return + } + + const node = (await prompt('Node filter (name or ID, Enter to skip): ')).trim() + + const columnsRaw = (await prompt('Columns (comma-separated, Enter for default): ')).trim() + const allowedActions = getAllowedActions(target) + const applyRaw = ( + await prompt(`Apply action [${allowedActions.join('/')}, default none]: `) + ) + .trim() + .toLowerCase() + const applyAction = (applyRaw || AdminAction.None) as AdminAction + + let targetUser: string | undefined + if (applyAction === AdminAction.Transfer) { + targetUser = (await prompt('Target user for transfer: ')).trim() + if (!targetUser) { + logger.error('Target user is required for transfer.') + process.exitCode = 1 + return + } + } + + const dryRun = + applyAction !== AdminAction.None && + isYes(await prompt('Dry run (preview only)? [y/N]: ')) + + let force = false + if ( + !dryRun && + (applyAction === AdminAction.Delete || applyAction === AdminAction.Transfer) + ) { + force = isYes(await prompt('Skip confirmation? [y/N]: ')) + if (!force) { + const confirmed = isYes( + await prompt(`Apply "${applyAction}" to matched users? [y/N]: `) + ) + if (!confirmed) { + logger.info('Action cancelled.') + return + } + } + } + + const options: ActionReportOptions = { + target, + daysSince, + applyAction, + dryRun, + ...(node ? { node } : {}), + ...(targetUser ? { targetUser } : {}), + ...(columnsRaw ? { columns: columnsRaw } : {}), + } + + const outputRaw = (await prompt('Output format [table/json/csv, default table]: ')).trim().toLowerCase() + let outputFormat = AuditOutputFormat.Table + if (outputRaw === 'json') outputFormat = AuditOutputFormat.Json + else if (outputRaw === 'csv') outputFormat = AuditOutputFormat.Csv + + const restore = suppressLogs() + let result + try { + result = await vault.runActionReport(options) + } finally { + restore() + } + + logger.info('') + logger.info(formatActionReportResult(result, outputFormat)) + logger.info('') + logger.info(`Rows: ${result.rows.length}`) + logger.info( + `Action: ${result.actionResult.action} (${result.actionResult.status}, affected=${result.actionResult.affectedCount})` + ) + if (result.actionResult.serverMessage && result.actionResult.serverMessage !== 'n/a') { + logger.info(result.actionResult.serverMessage) + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(actionReportExample) diff --git a/examples/sdk_example/src/enterpriseReport/audit_report.ts b/examples/sdk_example/src/enterpriseReport/audit_report.ts new file mode 100644 index 00000000..9e846bee --- /dev/null +++ b/examples/sdk_example/src/enterpriseReport/audit_report.ts @@ -0,0 +1,179 @@ +import { + AuditOutputFormat, + AuditReportFormat, + AuditReportOrder, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { AuditReportFilter, AuditReportOptions } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { formatAuditReportResult } from '../utils/reportFormat' + +async function promptAuditFilters(): Promise { + const filter: AuditReportFilter = {} + + const created = (await prompt('Created filter (e.g. last_7_days, >=2024-01-01, Enter to skip): ')).trim() + if (created) filter.created = created + + const eventType = (await prompt('Event type filter (name or id, comma-separated, Enter to skip): ')).trim() + if (eventType) { + const values = eventType.split(',').map((v) => v.trim()).filter(Boolean) + filter.eventType = + values.length === 1 + ? /^\d+$/.test(values[0]) + ? Number.parseInt(values[0], 10) + : values[0] + : values.map((v) => (/^\d+$/.test(v) ? Number.parseInt(v, 10) : v)) + } + + const username = (await prompt('Username filter (Enter to skip): ')).trim() + if (username) filter.username = username + + const toUsername = (await prompt('To-username filter (Enter to skip): ')).trim() + if (toUsername) filter.toUsername = toUsername + + const ipAddress = (await prompt('IP address filter (comma-separated, Enter to skip): ')).trim() + if (ipAddress) { + const values = ipAddress.split(',').map((v) => v.trim()).filter(Boolean) + filter.ipAddress = values.length === 1 ? values[0] : values + } + + const recordUid = (await prompt('Record UID filter (Enter to skip): ')).trim() + if (recordUid) filter.recordUid = recordUid + + const sharedFolderUid = (await prompt('Shared folder UID filter (Enter to skip): ')).trim() + if (sharedFolderUid) filter.sharedFolderUid = sharedFolderUid + + const geoLocation = (await prompt('Geo location filter (city, region, country, Enter to skip): ')).trim() + if (geoLocation) filter.geoLocation = geoLocation + + const deviceType = (await prompt('Device type filter (type[,version], Enter to skip): ')).trim() + if (deviceType) filter.deviceType = deviceType + + const parentId = (await prompt('Parent node ID filter (Enter to skip): ')).trim() + if (parentId) { + const values = parentId.split(',').map((v) => v.trim()).filter(Boolean) + filter.parentId = + values.length === 1 + ? Number.parseInt(values[0], 10) + : values.map((v) => Number.parseInt(v, 10)) + } + + return Object.keys(filter).length > 0 ? filter : undefined +} + +async function auditReportExample() { + const vault = await login() + + try { + const reportType = (await prompt('Report type [raw/dim/hour/day/week/month/span]: ')).trim().toLowerCase() + if (!reportType) { + logger.error('Report type is required.') + process.exitCode = 1 + return + } + + const options: AuditReportOptions = { + reportType: reportType as AuditReportOptions['reportType'], + } + + if (reportType === 'raw') { + const formatRaw = (await prompt('Report format [message/fields, default message]: ')).trim().toLowerCase() + if (formatRaw === 'fields') options.reportFormat = AuditReportFormat.Fields + + options.filter = await promptAuditFilters() + + const timezone = (await prompt('Timezone (e.g. America/Los_Angeles, Enter for default): ')).trim() + if (timezone) options.timezone = timezone + + const limitRaw = (await prompt('Limit (Enter for default, -1 for all raw rows): ')).trim() + if (limitRaw) { + const parsed = Number.parseInt(limitRaw, 10) + if (!Number.isFinite(parsed) || parsed === 0 || parsed < -1) { + logger.error('Limit must be a positive number or -1.') + process.exitCode = 1 + return + } + options.limit = parsed + } + + const orderRaw = (await prompt('Order [asc/desc, Enter for desc]: ')).trim().toLowerCase() + if (orderRaw === 'asc') options.order = AuditReportOrder.Asc + else if (orderRaw === 'desc') options.order = AuditReportOrder.Desc + } else if (reportType === 'dim') { + const column = (await prompt('Dimension column (e.g. audit_event_type): ')).trim() + if (!column) { + logger.error('Dimension column is required.') + process.exitCode = 1 + return + } + options.columns = [column] + } else { + const columnsRaw = (await prompt('Columns (comma-separated, e.g. username): ')).trim() + if (!columnsRaw) { + logger.error('Columns are required for summary reports.') + process.exitCode = 1 + return + } + options.columns = columnsRaw.split(',').map((v) => v.trim()).filter(Boolean) + + const aggregatesRaw = ( + await prompt('Aggregates [occurrences/first_created/last_created, comma-separated, Enter for occurrences]: ') + ) + .trim() + .toLowerCase() + if (aggregatesRaw) { + options.aggregates = aggregatesRaw.split(',').map((v) => v.trim()).filter(Boolean) as AuditReportOptions['aggregates'] + } + + options.filter = await promptAuditFilters() + + const timezone = (await prompt('Timezone (e.g. America/Los_Angeles, Enter for default): ')).trim() + if (timezone) options.timezone = timezone + + const limitRaw = (await prompt('Limit (Enter for default): ')).trim() + if (limitRaw) { + const parsed = Number.parseInt(limitRaw, 10) + if (!Number.isFinite(parsed) || parsed < 0) { + logger.error('Limit must be a non-negative number.') + process.exitCode = 1 + return + } + options.limit = parsed + } + + const orderRaw = (await prompt('Order [asc/desc, Enter for default]: ')).trim().toLowerCase() + if (orderRaw === 'asc') options.order = AuditReportOrder.Asc + else if (orderRaw === 'desc') options.order = AuditReportOrder.Desc + } + + const outputRaw = (await prompt('Output format [table/json/csv, default table]: ')).trim().toLowerCase() + let outputFormat = AuditOutputFormat.Table + if (outputRaw === 'json') outputFormat = AuditOutputFormat.Json + else if (outputRaw === 'csv') outputFormat = AuditOutputFormat.Csv + + const restore = suppressLogs() + let result + try { + result = await vault.runAuditReport(options) + } finally { + restore() + } + + logger.info('') + logger.info(formatAuditReportResult(result, outputFormat)) + logger.info('') + logger.info(`Rows: ${result.rows.length}`) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(auditReportExample) diff --git a/examples/sdk_example/src/enterpriseReport/password_report.ts b/examples/sdk_example/src/enterpriseReport/password_report.ts new file mode 100644 index 00000000..cd6709ea --- /dev/null +++ b/examples/sdk_example/src/enterpriseReport/password_report.ts @@ -0,0 +1,80 @@ +import { + AuditOutputFormat, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, + writeOutput, +} from '@keeper-security/keeper-sdk-javascript' +import type { PasswordReportOptions } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { formatPasswordReportResult } from '../utils/reportFormat' + +function parseOptionalInt(value: string): number | undefined { + const trimmed = value.trim() + if (!trimmed) return undefined + const parsed = Number.parseInt(trimmed, 10) + return Number.isFinite(parsed) ? parsed : undefined +} + +async function passwordReportExample() { + const vault = await login() + + try { + const folder = (await prompt('Folder path or UID (Enter for entire vault): ')).trim() + + const policyRaw = (await prompt('Policy Length,Lower,Upper,Digits,Special (e.g. 12,2,2,2,0): ')).trim() + let options: PasswordReportOptions = {} + + if (policyRaw) { + options.policy = policyRaw + } else { + const length = parseOptionalInt(await prompt('Min length [-l]: ')) + const lower = parseOptionalInt(await prompt('Min lowercase [--lower]: ')) + const upper = parseOptionalInt(await prompt('Min uppercase [-u]: ')) + const digits = parseOptionalInt(await prompt('Min digits [-d]: ')) + const special = parseOptionalInt(await prompt('Min special [-s]: ')) + options = { + ...(length != null ? { length } : {}), + ...(lower != null ? { lower } : {}), + ...(upper != null ? { upper } : {}), + ...(digits != null ? { digits } : {}), + ...(special != null ? { special } : {}), + } + } + + if (folder) options.folder = folder + + const verboseRaw = (await prompt('Verbose (score/BreachWatch/reuse)? [y/N]: ')).trim().toLowerCase() + if (verboseRaw === 'y' || verboseRaw === 'yes') options.verbose = true + + const outputRaw = (await prompt('Output format [table/json/csv, default table]: ')).trim().toLowerCase() + let outputFormat = AuditOutputFormat.Table + if (outputRaw === 'json') outputFormat = AuditOutputFormat.Json + else if (outputRaw === 'csv') outputFormat = AuditOutputFormat.Csv + + const restore = suppressLogs() + let result + try { + result = await vault.runPasswordReport(options) + } finally { + restore() + } + + logger.info('') + logger.info(`Policy: ${result.policySummary}`) + logger.info('') + writeOutput(formatPasswordReportResult(result, outputFormat)) + logger.info('') + logger.info(`Rows: ${result.rows.length}`) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(passwordReportExample) diff --git a/examples/sdk_example/src/utils/reportFormat.ts b/examples/sdk_example/src/utils/reportFormat.ts new file mode 100644 index 00000000..31f48525 --- /dev/null +++ b/examples/sdk_example/src/utils/reportFormat.ts @@ -0,0 +1,125 @@ +import { + AuditOutputFormat, + type ActionReportResult, + type AuditReportResult, + type PasswordReportResult, + type PasswordReportRow, +} from '@keeper-security/keeper-sdk-javascript' + +const PASSWORD_REPORT_BASE_HEADERS = [ + 'record_uid', + 'title', + 'description', + 'length', + 'lower', + 'upper', + 'digits', + 'special', +] as const + +const PASSWORD_REPORT_VERBOSE_HEADERS = ['score', 'status', 'reused'] as const + +export function fieldToTitle(field: string): string { + return field + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +export function formatReportOutput( + headers: readonly string[], + rows: readonly string[][], + outputFormat: AuditOutputFormat +): string { + if (outputFormat === AuditOutputFormat.Json) { + return JSON.stringify( + rows.map((row) => + Object.fromEntries(headers.map((header, index) => [header, row[index] ?? ''])) + ), + null, + 2 + ) + } + if (outputFormat === AuditOutputFormat.Csv) { + const escape = (value: string) => `"${value.replace(/"/g, '""')}"` + return [ + headers.map(escape).join(','), + ...rows.map((row) => row.map((cell) => escape(cell ?? '')).join(',')), + ].join('\n') + } + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').length), 2) + ) + const pad = (cell: string, index: number) => cell + ' '.repeat(Math.max(0, widths[index] - cell.length)) + const formatRow = (cells: readonly string[]) => cells.map((cell, index) => pad(cell, index)).join(' ') + const rule = formatRow(widths.map((width) => '-'.repeat(width))) + return [formatRow(headers), rule, ...rows.map((row) => formatRow(row))].join('\n') +} + +export function formatAuditReportResult( + result: AuditReportResult, + outputFormat: AuditOutputFormat = AuditOutputFormat.Table +): string { + const headers = + outputFormat === AuditOutputFormat.Json ? result.headers : result.headers.map(fieldToTitle) + + if (result.syntaxHelp && result.eventTypeReference) { + const reference = formatReportOutput(headers, result.rows, outputFormat) + return `${result.syntaxHelp}\n\nEvent type ids and names:\n\n${reference}` + } + + return formatReportOutput(headers, result.rows, outputFormat) +} + +export function formatActionReportResult( + result: ActionReportResult, + outputFormat: AuditOutputFormat = AuditOutputFormat.Table +): string { + return formatReportOutput(result.headers, result.rows, outputFormat) +} + +function buildPasswordTableHeaders(verbose: boolean, rowNumbers: boolean): string[] { + const headers: string[] = [...PASSWORD_REPORT_BASE_HEADERS] + if (verbose) headers.push(...PASSWORD_REPORT_VERBOSE_HEADERS) + if (rowNumbers) headers.unshift('#') + return headers +} + +function passwordRowToCells( + row: PasswordReportRow, + verbose: boolean, + rowNumbers: boolean, + rowNumber: number +): string[] { + const cells = [ + row.recordUid, + row.title, + row.description, + String(row.length), + String(row.lower), + String(row.upper), + String(row.digits), + String(row.special), + ] + + if (verbose) { + cells.push(row.score ?? '', row.status ?? '', row.reused ?? '') + } + if (rowNumbers) { + cells.unshift(String(rowNumber)) + } + + return cells +} + +export function formatPasswordReportResult( + result: PasswordReportResult, + outputFormat: AuditOutputFormat = AuditOutputFormat.Table, + rowNumbers = result.rowNumbers +): string { + const headers = buildPasswordTableHeaders(result.verbose, rowNumbers) + const rows = result.rows.map((row, index) => + passwordRowToCells(row, result.verbose, rowNumbers, index + 1) + ) + return formatReportOutput(headers, rows, outputFormat) +} diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index 96296d9c..a7f89c14 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -437,6 +437,83 @@ export const putEnterpriseSettingCommand = ( request: PutEnterpriseSettingRequest ): RestCommand => createCommand(request, 'put_enterprise_setting') +export type AuditReportFilterPayload = { + created?: + | 'today' + | 'yesterday' + | 'last_7_days' + | 'last_30_days' + | 'month_to_date' + | 'last_month' + | 'year_to_date' + | 'last_year' + | { + 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 type GetAuditEventReportsRequest = { + report_type: string + scope: 'enterprise' | 'user' + timezone: string + limit: number + order?: 'ascending' | 'descending' + filter?: AuditReportFilterPayload + aggregate?: string[] + columns?: string[] + report_format?: 'message' | 'fields' +} + +export type GetAuditEventReportsResponse = KeeperResponse & { + timezone?: string + audit_event_overview_report_rows?: Array> +} + +export const getAuditEventReportsCommand = ( + request: GetAuditEventReportsRequest +): RestCommand => + createCommand(request, 'get_audit_event_reports') + +export type GetAuditEventDimensionsRequest = { + report_type: 'dim' + columns: string[] + limit: number + scope: 'enterprise' | 'user' +} + +export type GetAuditEventDimensionsResponse = KeeperResponse & { + dimensions?: Record +} + +export const getAuditEventDimensionsCommand = ( + request: GetAuditEventDimensionsRequest +): RestCommand => + createCommand(request, 'get_audit_event_dimensions') + +export type GetEnterpriseDataRequest = { + include: string[] +} + +export type GetEnterpriseDataCommandResponse = KeeperResponse & { + licenses?: Record[] +} + +export const getEnterpriseDataCommand = ( + request: GetEnterpriseDataRequest +): RestCommand => + createCommand(request, 'get_enterprise_data') + /** export class KeeperCommand { command?: string; From 3dda4973884c89010ae55dc557c29a746921c511 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:46:17 +0530 Subject: [PATCH 28/48] Regenerate protobuf files (#199) --- keeperapi/scripts/generate-proto.mjs | 2 + keeperapi/src/proto.d.ts | 5769 ++++++++++++++++---------- keeperapi/src/proto/Remove.js | 1249 ++++++ keeperapi/src/proto/Router.js | 36 + keeperapi/src/proto/Workflow.js | 1 - keeperapi/src/proto/index.js | 1 + keeperapi/src/proto/keeper.js | 521 +++ keeperapi/src/proto/record.js | 1948 +++++++++ 8 files changed, 7382 insertions(+), 2145 deletions(-) create mode 100644 keeperapi/src/proto/keeper.js diff --git a/keeperapi/scripts/generate-proto.mjs b/keeperapi/scripts/generate-proto.mjs index a1060d0a..778e6e81 100644 --- a/keeperapi/scripts/generate-proto.mjs +++ b/keeperapi/scripts/generate-proto.mjs @@ -27,6 +27,8 @@ const PROTO_FILES = [ 'record_endpoints.proto', 'remove.proto', 'workflow.proto', + 'folder_access.proto', + 'record_details.proto', ] // Explicit filename overrides. 'folder' (lowercase) collides with 'Folder' on case-insensitive diff --git a/keeperapi/src/proto.d.ts b/keeperapi/src/proto.d.ts index 91690ea5..771a401c 100644 --- a/keeperapi/src/proto.d.ts +++ b/keeperapi/src/proto.d.ts @@ -69718,1445 +69718,2231 @@ export namespace record { */ public static getTypeUrl(typeUrlPrefix?: string): string; } - } -} -/** Namespace Upsell. */ -export namespace Upsell { + /** Namespace details. */ + namespace details { - /** Properties of an UpsellRequest. */ - interface IUpsellRequest { + /** Represents a RecordDetailsService */ + class RecordDetailsService extends $protobuf.rpc.Service { - /** UpsellRequest email */ - email?: (string|null); + /** + * Constructs a new RecordDetailsService service. + * @param rpcImpl RPC implementation + * @param [requestDelimited=false] Whether requests are length-delimited + * @param [responseDelimited=false] Whether responses are length-delimited + */ + constructor(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean); - /** UpsellRequest locale */ - locale?: (string|null); + /** + * Creates new RecordDetailsService service using the specified rpc implementation. + * @param rpcImpl RPC implementation + * @param [requestDelimited=false] Whether requests are length-delimited + * @param [responseDelimited=false] Whether responses are length-delimited + * @returns RPC service. Useful where requests and/or responses are streamed. + */ + public static create(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean): RecordDetailsService; - /** UpsellRequest clientVersion */ - clientVersion?: (string|null); + /** + * Calls GetRecordData. + * @param request RecordDataRequest message or plain object + * @param callback Node-style callback called with the error, if any, and RecordDataResponse + */ + public getRecordData(request: record.v3.details.IRecordDataRequest, callback: record.v3.details.RecordDetailsService.GetRecordDataCallback): void; - /** UpsellRequest sessionToken */ - sessionToken?: (string|null); - } + /** + * Calls GetRecordData. + * @param request RecordDataRequest message or plain object + * @returns Promise + */ + public getRecordData(request: record.v3.details.IRecordDataRequest): Promise; - /** Represents an UpsellRequest. */ - class UpsellRequest implements IUpsellRequest { + /** + * Calls GetRecordAccessors. + * @param request RecordAccessRequest message or plain object + * @param callback Node-style callback called with the error, if any, and RecordAccessResponse + */ + public getRecordAccessors(request: record.v3.details.IRecordAccessRequest, callback: record.v3.details.RecordDetailsService.GetRecordAccessorsCallback): void; - /** - * Constructs a new UpsellRequest. - * @param [properties] Properties to set - */ - constructor(properties?: Upsell.IUpsellRequest); + /** + * Calls GetRecordAccessors. + * @param request RecordAccessRequest message or plain object + * @returns Promise + */ + public getRecordAccessors(request: record.v3.details.IRecordAccessRequest): Promise; - /** UpsellRequest email. */ - public email: string; + /** + * Calls GetRecordAccessorDetails. + * @param request RecordAccessorDetailsRequest message or plain object + * @param callback Node-style callback called with the error, if any, and RecordAccessorDetailsResponse + */ + public getRecordAccessorDetails(request: record.v3.details.IRecordAccessorDetailsRequest, callback: record.v3.details.RecordDetailsService.GetRecordAccessorDetailsCallback): void; - /** UpsellRequest locale. */ - public locale: string; + /** + * Calls GetRecordAccessorDetails. + * @param request RecordAccessorDetailsRequest message or plain object + * @returns Promise + */ + public getRecordAccessorDetails(request: record.v3.details.IRecordAccessorDetailsRequest): Promise; + } - /** UpsellRequest clientVersion. */ - public clientVersion: string; + namespace RecordDetailsService { - /** UpsellRequest sessionToken. */ - public sessionToken: string; + /** + * Callback as used by {@link record.v3.details.RecordDetailsService#getRecordData}. + * @param error Error, if any + * @param [response] RecordDataResponse + */ + type GetRecordDataCallback = (error: (Error|null), response?: record.v3.details.RecordDataResponse) => void; - /** - * Creates a new UpsellRequest instance using the specified properties. - * @param [properties] Properties to set - * @returns UpsellRequest instance - */ - public static create(properties?: Upsell.IUpsellRequest): Upsell.UpsellRequest; + /** + * Callback as used by {@link record.v3.details.RecordDetailsService#getRecordAccessors}. + * @param error Error, if any + * @param [response] RecordAccessResponse + */ + type GetRecordAccessorsCallback = (error: (Error|null), response?: record.v3.details.RecordAccessResponse) => void; - /** - * Encodes the specified UpsellRequest message. Does not implicitly {@link Upsell.UpsellRequest.verify|verify} messages. - * @param message UpsellRequest message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: Upsell.IUpsellRequest, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Callback as used by {@link record.v3.details.RecordDetailsService#getRecordAccessorDetails}. + * @param error Error, if any + * @param [response] RecordAccessorDetailsResponse + */ + type GetRecordAccessorDetailsCallback = (error: (Error|null), response?: record.v3.details.RecordAccessorDetailsResponse) => void; + } - /** - * Decodes an UpsellRequest message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns UpsellRequest - * @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): Upsell.UpsellRequest; + /** Properties of a RecordDataRequest. */ + interface IRecordDataRequest { - /** - * Creates an UpsellRequest message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns UpsellRequest - */ - public static fromObject(object: { [k: string]: any }): Upsell.UpsellRequest; + /** + * represents the client time in milliseconds. Client time is used to + * adjust the record client_modified_time for each record. + */ + clientTime?: (number|null); - /** - * Creates a plain object from an UpsellRequest message. Also converts values to other types if specified. - * @param message UpsellRequest - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: Upsell.UpsellRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** the list of record UIDs to retrieve information for. */ + recordUids?: (Uint8Array[]|null); + } - /** - * Converts this UpsellRequest to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** Represents a record data request. Record details include the record [meta]data (title, color, etc.) */ + class RecordDataRequest implements IRecordDataRequest { - /** - * Gets the default type url for UpsellRequest - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Constructs a new RecordDataRequest. + * @param [properties] Properties to set + */ + constructor(properties?: record.v3.details.IRecordDataRequest); - /** Properties of an UpsellResponse. */ - interface IUpsellResponse { + /** + * represents the client time in milliseconds. Client time is used to + * adjust the record client_modified_time for each record. + */ + public clientTime: number; - /** UpsellResponse UpsellBanner */ - UpsellBanner?: (Upsell.IUpsellBanner[]|null); - } + /** the list of record UIDs to retrieve information for. */ + public recordUids: Uint8Array[]; - /** Represents an UpsellResponse. */ - class UpsellResponse implements IUpsellResponse { + /** + * Creates a new RecordDataRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns RecordDataRequest instance + */ + public static create(properties?: record.v3.details.IRecordDataRequest): record.v3.details.RecordDataRequest; - /** - * Constructs a new UpsellResponse. - * @param [properties] Properties to set - */ - constructor(properties?: Upsell.IUpsellResponse); + /** + * Encodes the specified RecordDataRequest message. Does not implicitly {@link record.v3.details.RecordDataRequest.verify|verify} messages. + * @param message RecordDataRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: record.v3.details.IRecordDataRequest, writer?: $protobuf.Writer): $protobuf.Writer; - /** UpsellResponse UpsellBanner. */ - public UpsellBanner: Upsell.IUpsellBanner[]; + /** + * Decodes a RecordDataRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RecordDataRequest + * @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): record.v3.details.RecordDataRequest; - /** - * Creates a new UpsellResponse instance using the specified properties. - * @param [properties] Properties to set - * @returns UpsellResponse instance - */ - public static create(properties?: Upsell.IUpsellResponse): Upsell.UpsellResponse; + /** + * Creates a RecordDataRequest message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RecordDataRequest + */ + public static fromObject(object: { [k: string]: any }): record.v3.details.RecordDataRequest; - /** - * Encodes the specified UpsellResponse message. Does not implicitly {@link Upsell.UpsellResponse.verify|verify} messages. - * @param message UpsellResponse message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: Upsell.IUpsellResponse, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Creates a plain object from a RecordDataRequest message. Also converts values to other types if specified. + * @param message RecordDataRequest + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: record.v3.details.RecordDataRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** - * Decodes an UpsellResponse message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns UpsellResponse - * @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): Upsell.UpsellResponse; + /** + * Converts this RecordDataRequest to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** - * Creates an UpsellResponse message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns UpsellResponse - */ - public static fromObject(object: { [k: string]: any }): Upsell.UpsellResponse; + /** + * Gets the default type url for RecordDataRequest + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** - * Creates a plain object from an UpsellResponse message. Also converts values to other types if specified. - * @param message UpsellResponse - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: Upsell.UpsellResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** Properties of a RecordDataResponse. */ + interface IRecordDataResponse { - /** - * Converts this UpsellResponse to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** The data associated with the record. */ + data?: (Records.IRecordData[]|null); - /** - * Gets the default type url for UpsellResponse - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * A list of record UIDs from the request that the calling user does not have access to. + * Each UID in this list corresponds to a record the user has no permission to access. + */ + forbiddenRecords?: (Uint8Array[]|null); + } - /** Properties of an UpsellBanner. */ - interface IUpsellBanner { + /** Response message containing records' data and a list of inaccessible records for the calling user. */ + class RecordDataResponse implements IRecordDataResponse { - /** UpsellBanner bannerId */ - bannerId?: (number|null); + /** + * Constructs a new RecordDataResponse. + * @param [properties] Properties to set + */ + constructor(properties?: record.v3.details.IRecordDataResponse); - /** UpsellBanner bannerOkAction */ - bannerOkAction?: (string|null); + /** The data associated with the record. */ + public data: Records.IRecordData[]; - /** UpsellBanner bannerOkButton */ - bannerOkButton?: (string|null); + /** + * A list of record UIDs from the request that the calling user does not have access to. + * Each UID in this list corresponds to a record the user has no permission to access. + */ + public forbiddenRecords: Uint8Array[]; - /** UpsellBanner bannerCancelAction */ - bannerCancelAction?: (string|null); + /** + * Creates a new RecordDataResponse instance using the specified properties. + * @param [properties] Properties to set + * @returns RecordDataResponse instance + */ + public static create(properties?: record.v3.details.IRecordDataResponse): record.v3.details.RecordDataResponse; - /** UpsellBanner bannerCancelButton */ - bannerCancelButton?: (string|null); + /** + * Encodes the specified RecordDataResponse message. Does not implicitly {@link record.v3.details.RecordDataResponse.verify|verify} messages. + * @param message RecordDataResponse message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: record.v3.details.IRecordDataResponse, writer?: $protobuf.Writer): $protobuf.Writer; - /** UpsellBanner bannerMessage */ - bannerMessage?: (string|null); + /** + * Decodes a RecordDataResponse message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RecordDataResponse + * @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): record.v3.details.RecordDataResponse; - /** UpsellBanner locale */ - locale?: (string|null); - } + /** + * Creates a RecordDataResponse message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RecordDataResponse + */ + public static fromObject(object: { [k: string]: any }): record.v3.details.RecordDataResponse; - /** Represents an UpsellBanner. */ - class UpsellBanner implements IUpsellBanner { + /** + * Creates a plain object from a RecordDataResponse message. Also converts values to other types if specified. + * @param message RecordDataResponse + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: record.v3.details.RecordDataResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** - * Constructs a new UpsellBanner. - * @param [properties] Properties to set - */ - constructor(properties?: Upsell.IUpsellBanner); + /** + * Converts this RecordDataResponse to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** UpsellBanner bannerId. */ - public bannerId: number; + /** + * Gets the default type url for RecordDataResponse + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** UpsellBanner bannerOkAction. */ - public bannerOkAction: string; + /** Properties of a RecordAccessRequest. */ + interface IRecordAccessRequest { - /** UpsellBanner bannerOkButton. */ - public bannerOkButton: string; + /** the list of record UIDs to retrieve information for. */ + recordUids?: (Uint8Array[]|null); - /** UpsellBanner bannerCancelAction. */ - public bannerCancelAction: string; + /** + * Pagination parameters. + * If not provided, uses defaults (page 0, size 100). + */ + page?: (keeper.api.common.IPage|null); + } - /** UpsellBanner bannerCancelButton. */ - public bannerCancelButton: string; + /** + * Represents a record accessors request. Record details include whom the record has been + * shared with (user or team), and what role and permissions the accessors have over the record. + */ + class RecordAccessRequest implements IRecordAccessRequest { - /** UpsellBanner bannerMessage. */ - public bannerMessage: string; + /** + * Constructs a new RecordAccessRequest. + * @param [properties] Properties to set + */ + constructor(properties?: record.v3.details.IRecordAccessRequest); - /** UpsellBanner locale. */ - public locale: string; + /** the list of record UIDs to retrieve information for. */ + public recordUids: Uint8Array[]; - /** - * Creates a new UpsellBanner instance using the specified properties. - * @param [properties] Properties to set - * @returns UpsellBanner instance - */ - public static create(properties?: Upsell.IUpsellBanner): Upsell.UpsellBanner; + /** + * Pagination parameters. + * If not provided, uses defaults (page 0, size 100). + */ + public page?: (keeper.api.common.IPage|null); - /** - * Encodes the specified UpsellBanner message. Does not implicitly {@link Upsell.UpsellBanner.verify|verify} messages. - * @param message UpsellBanner message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: Upsell.IUpsellBanner, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Creates a new RecordAccessRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns RecordAccessRequest instance + */ + public static create(properties?: record.v3.details.IRecordAccessRequest): record.v3.details.RecordAccessRequest; - /** - * Decodes an UpsellBanner message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns UpsellBanner - * @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): Upsell.UpsellBanner; + /** + * Encodes the specified RecordAccessRequest message. Does not implicitly {@link record.v3.details.RecordAccessRequest.verify|verify} messages. + * @param message RecordAccessRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: record.v3.details.IRecordAccessRequest, writer?: $protobuf.Writer): $protobuf.Writer; - /** - * Creates an UpsellBanner message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns UpsellBanner - */ - public static fromObject(object: { [k: string]: any }): Upsell.UpsellBanner; + /** + * Decodes a RecordAccessRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RecordAccessRequest + * @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): record.v3.details.RecordAccessRequest; - /** - * Creates a plain object from an UpsellBanner message. Also converts values to other types if specified. - * @param message UpsellBanner - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: Upsell.UpsellBanner, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** + * Creates a RecordAccessRequest message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RecordAccessRequest + */ + public static fromObject(object: { [k: string]: any }): record.v3.details.RecordAccessRequest; - /** - * Converts this UpsellBanner to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Creates a plain object from a RecordAccessRequest message. Also converts values to other types if specified. + * @param message RecordAccessRequest + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: record.v3.details.RecordAccessRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** - * Gets the default type url for UpsellBanner - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Converts this RecordAccessRequest to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** ClientType enum. */ - enum ClientType { - DEFAULT_CLIENT_TYPE = 0, - ALL = 1, - ANDROID = 2, - IOS = 3, - MICROSOFT = 4, - WEBAPP = 5 - } + /** + * Gets the default type url for RecordAccessRequest + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** ClientVersion enum. */ - enum ClientVersion { - DEFAULT_VERSION = 0, - SUPPORTS_ALL = 1, - BASEVERSION = 14, - ABOVERANGE = 15 - } -} + /** Properties of a RecordAccessResponse. */ + interface IRecordAccessResponse { -/** Namespace BI. */ -export namespace BI { + /** List of record access permissions, detailing the accessors and their roles. */ + recordAccesses?: (record.v3.details.IRecordAccess[]|null); - /** Currency enum. */ - enum Currency { - UNKNOWN = 0, - USD = 1, - GBP = 2, - JPY = 3, - EUR = 4, - AUD = 5, - CAD = 6 - } + /** + * A list of record UIDs from the request that the calling user does not have access to. + * Each UID in this list corresponds to a record the user has no permission to access. + */ + forbiddenRecords?: (Uint8Array[]|null); - /** Properties of a ValidateSessionTokenRequest. */ - interface IValidateSessionTokenRequest { + /** + * Pagination metadata for this response. + * Contains current page info, total count, and whether more pages exist. + */ + pageInfo?: (keeper.api.common.IPageInfo|null); + } - /** ValidateSessionTokenRequest encryptedSessionToken */ - encryptedSessionToken?: (Uint8Array|null); + /** Response message containing records' accesses and a list of inaccessible records for the calling user. */ + class RecordAccessResponse implements IRecordAccessResponse { - /** ValidateSessionTokenRequest returnMcEnterpiseIds */ - returnMcEnterpiseIds?: (boolean|null); + /** + * Constructs a new RecordAccessResponse. + * @param [properties] Properties to set + */ + constructor(properties?: record.v3.details.IRecordAccessResponse); - /** ValidateSessionTokenRequest ip */ - ip?: (string|null); - } + /** List of record access permissions, detailing the accessors and their roles. */ + public recordAccesses: record.v3.details.IRecordAccess[]; - /** Represents a ValidateSessionTokenRequest. */ - class ValidateSessionTokenRequest implements IValidateSessionTokenRequest { + /** + * A list of record UIDs from the request that the calling user does not have access to. + * Each UID in this list corresponds to a record the user has no permission to access. + */ + public forbiddenRecords: Uint8Array[]; - /** - * Constructs a new ValidateSessionTokenRequest. - * @param [properties] Properties to set - */ - constructor(properties?: BI.IValidateSessionTokenRequest); + /** + * Pagination metadata for this response. + * Contains current page info, total count, and whether more pages exist. + */ + public pageInfo?: (keeper.api.common.IPageInfo|null); - /** ValidateSessionTokenRequest encryptedSessionToken. */ - public encryptedSessionToken: Uint8Array; + /** + * Creates a new RecordAccessResponse instance using the specified properties. + * @param [properties] Properties to set + * @returns RecordAccessResponse instance + */ + public static create(properties?: record.v3.details.IRecordAccessResponse): record.v3.details.RecordAccessResponse; - /** ValidateSessionTokenRequest returnMcEnterpiseIds. */ - public returnMcEnterpiseIds: boolean; + /** + * Encodes the specified RecordAccessResponse message. Does not implicitly {@link record.v3.details.RecordAccessResponse.verify|verify} messages. + * @param message RecordAccessResponse message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: record.v3.details.IRecordAccessResponse, writer?: $protobuf.Writer): $protobuf.Writer; - /** ValidateSessionTokenRequest ip. */ - public ip: string; + /** + * Decodes a RecordAccessResponse message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RecordAccessResponse + * @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): record.v3.details.RecordAccessResponse; - /** - * Creates a new ValidateSessionTokenRequest instance using the specified properties. - * @param [properties] Properties to set - * @returns ValidateSessionTokenRequest instance - */ - public static create(properties?: BI.IValidateSessionTokenRequest): BI.ValidateSessionTokenRequest; + /** + * Creates a RecordAccessResponse message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RecordAccessResponse + */ + public static fromObject(object: { [k: string]: any }): record.v3.details.RecordAccessResponse; - /** - * Encodes the specified ValidateSessionTokenRequest message. Does not implicitly {@link BI.ValidateSessionTokenRequest.verify|verify} messages. - * @param message ValidateSessionTokenRequest message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: BI.IValidateSessionTokenRequest, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Creates a plain object from a RecordAccessResponse message. Also converts values to other types if specified. + * @param message RecordAccessResponse + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: record.v3.details.RecordAccessResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** - * Decodes a ValidateSessionTokenRequest message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns ValidateSessionTokenRequest - * @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.ValidateSessionTokenRequest; + /** + * Converts this RecordAccessResponse to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** - * Creates a ValidateSessionTokenRequest message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns ValidateSessionTokenRequest - */ - public static fromObject(object: { [k: string]: any }): BI.ValidateSessionTokenRequest; + /** + * Gets the default type url for RecordAccessResponse + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** - * Creates a plain object from a ValidateSessionTokenRequest message. Also converts values to other types if specified. - * @param message ValidateSessionTokenRequest - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: BI.ValidateSessionTokenRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** Properties of a RecordAccessorDetailsRequest. */ + interface IRecordAccessorDetailsRequest { - /** - * Converts this ValidateSessionTokenRequest to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** The record UID to retrieve information for. */ + recordUid?: (Uint8Array|null); - /** - * Gets the default type url for ValidateSessionTokenRequest - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** The accessor UID (user or team) to retrieve information for. */ + accessorUid?: (Uint8Array|null); - /** Properties of a ValidateSessionTokenResponse. */ - interface IValidateSessionTokenResponse { + /** + * Pagination parameters. + * If not provided, uses defaults (page 0, size 100). + */ + page?: (keeper.api.common.IPage|null); + } - /** ValidateSessionTokenResponse username */ - username?: (string|null); + /** Represents a record accessor details request. */ + class RecordAccessorDetailsRequest implements IRecordAccessorDetailsRequest { - /** ValidateSessionTokenResponse userId */ - userId?: (number|null); + /** + * Constructs a new RecordAccessorDetailsRequest. + * @param [properties] Properties to set + */ + constructor(properties?: record.v3.details.IRecordAccessorDetailsRequest); - /** ValidateSessionTokenResponse enterpriseUserId */ - enterpriseUserId?: (number|null); + /** The record UID to retrieve information for. */ + public recordUid: Uint8Array; - /** ValidateSessionTokenResponse status */ - status?: (BI.ValidateSessionTokenResponse.Status|null); + /** The accessor UID (user or team) to retrieve information for. */ + public accessorUid: Uint8Array; - /** ValidateSessionTokenResponse statusMessage */ - statusMessage?: (string|null); + /** + * Pagination parameters. + * If not provided, uses defaults (page 0, size 100). + */ + public page?: (keeper.api.common.IPage|null); - /** ValidateSessionTokenResponse mcEnterpriseIds */ - mcEnterpriseIds?: (number[]|null); + /** + * Creates a new RecordAccessorDetailsRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns RecordAccessorDetailsRequest instance + */ + public static create(properties?: record.v3.details.IRecordAccessorDetailsRequest): record.v3.details.RecordAccessorDetailsRequest; - /** ValidateSessionTokenResponse hasMSPPermission */ - hasMSPPermission?: (boolean|null); + /** + * Encodes the specified RecordAccessorDetailsRequest message. Does not implicitly {@link record.v3.details.RecordAccessorDetailsRequest.verify|verify} messages. + * @param message RecordAccessorDetailsRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: record.v3.details.IRecordAccessorDetailsRequest, writer?: $protobuf.Writer): $protobuf.Writer; - /** ValidateSessionTokenResponse deletedMcEnterpriseIds */ - deletedMcEnterpriseIds?: (number[]|null); - } + /** + * Decodes a RecordAccessorDetailsRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RecordAccessorDetailsRequest + * @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): record.v3.details.RecordAccessorDetailsRequest; - /** Represents a ValidateSessionTokenResponse. */ - class ValidateSessionTokenResponse implements IValidateSessionTokenResponse { + /** + * Creates a RecordAccessorDetailsRequest message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RecordAccessorDetailsRequest + */ + public static fromObject(object: { [k: string]: any }): record.v3.details.RecordAccessorDetailsRequest; - /** - * Constructs a new ValidateSessionTokenResponse. - * @param [properties] Properties to set - */ - constructor(properties?: BI.IValidateSessionTokenResponse); + /** + * Creates a plain object from a RecordAccessorDetailsRequest message. Also converts values to other types if specified. + * @param message RecordAccessorDetailsRequest + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: record.v3.details.RecordAccessorDetailsRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** ValidateSessionTokenResponse username. */ - public username: string; + /** + * Converts this RecordAccessorDetailsRequest to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** ValidateSessionTokenResponse userId. */ - public userId: number; + /** + * Gets the default type url for RecordAccessorDetailsRequest + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** ValidateSessionTokenResponse enterpriseUserId. */ - public enterpriseUserId: number; + /** Properties of a RecordAccessorDetailsResponse. */ + interface IRecordAccessorDetailsResponse { - /** ValidateSessionTokenResponse status. */ - public status: BI.ValidateSessionTokenResponse.Status; + /** Set if has direct access to the record. */ + recordAccessData?: (Folder.IRecordAccessData|null); - /** ValidateSessionTokenResponse statusMessage. */ - public statusMessage: string; + /** The list of folder the user has access and that contain the record. */ + folderAccessData?: (Folder.IFolderAccessData[]|null); - /** ValidateSessionTokenResponse mcEnterpriseIds. */ - public mcEnterpriseIds: number[]; + /** + * Pagination metadata for this response. + * Contains current page info, total count, and whether more pages exist. + */ + pageInfo?: (keeper.api.common.IPageInfo|null); + } - /** ValidateSessionTokenResponse hasMSPPermission. */ - public hasMSPPermission: boolean; + /** + * Represents a record accessor details response. + * Record accessor details include information on how a specific accessor obtained access to a record. + */ + class RecordAccessorDetailsResponse implements IRecordAccessorDetailsResponse { - /** ValidateSessionTokenResponse deletedMcEnterpriseIds. */ - public deletedMcEnterpriseIds: number[]; + /** + * Constructs a new RecordAccessorDetailsResponse. + * @param [properties] Properties to set + */ + constructor(properties?: record.v3.details.IRecordAccessorDetailsResponse); - /** - * Creates a new ValidateSessionTokenResponse instance using the specified properties. - * @param [properties] Properties to set - * @returns ValidateSessionTokenResponse instance - */ - public static create(properties?: BI.IValidateSessionTokenResponse): BI.ValidateSessionTokenResponse; + /** Set if has direct access to the record. */ + public recordAccessData?: (Folder.IRecordAccessData|null); - /** - * Encodes the specified ValidateSessionTokenResponse message. Does not implicitly {@link BI.ValidateSessionTokenResponse.verify|verify} messages. - * @param message ValidateSessionTokenResponse message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: BI.IValidateSessionTokenResponse, writer?: $protobuf.Writer): $protobuf.Writer; + /** The list of folder the user has access and that contain the record. */ + public folderAccessData: Folder.IFolderAccessData[]; - /** - * Decodes a ValidateSessionTokenResponse message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns ValidateSessionTokenResponse - * @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.ValidateSessionTokenResponse; + /** + * * Pagination metadata for this response. + * * Contains current page info, total count, and whether more pages exist. + */ + public pageInfo?: (keeper.api.common.IPageInfo|null); - /** - * Creates a ValidateSessionTokenResponse message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns ValidateSessionTokenResponse - */ - public static fromObject(object: { [k: string]: any }): BI.ValidateSessionTokenResponse; + /** + * Creates a new RecordAccessorDetailsResponse instance using the specified properties. + * @param [properties] Properties to set + * @returns RecordAccessorDetailsResponse instance + */ + public static create(properties?: record.v3.details.IRecordAccessorDetailsResponse): record.v3.details.RecordAccessorDetailsResponse; - /** - * Creates a plain object from a ValidateSessionTokenResponse message. Also converts values to other types if specified. - * @param message ValidateSessionTokenResponse - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: BI.ValidateSessionTokenResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** + * Encodes the specified RecordAccessorDetailsResponse message. Does not implicitly {@link record.v3.details.RecordAccessorDetailsResponse.verify|verify} messages. + * @param message RecordAccessorDetailsResponse message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: record.v3.details.IRecordAccessorDetailsResponse, writer?: $protobuf.Writer): $protobuf.Writer; - /** - * Converts this ValidateSessionTokenResponse to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Decodes a RecordAccessorDetailsResponse message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RecordAccessorDetailsResponse + * @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): record.v3.details.RecordAccessorDetailsResponse; - /** - * Gets the default type url for ValidateSessionTokenResponse - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Creates a RecordAccessorDetailsResponse message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RecordAccessorDetailsResponse + */ + public static fromObject(object: { [k: string]: any }): record.v3.details.RecordAccessorDetailsResponse; - namespace ValidateSessionTokenResponse { + /** + * Creates a plain object from a RecordAccessorDetailsResponse message. Also converts values to other types if specified. + * @param message RecordAccessorDetailsResponse + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: record.v3.details.RecordAccessorDetailsResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** Status enum. */ - enum Status { - VALID = 0, - NOT_VALID = 1, - EXPIRED = 2, - IP_BLOCKED = 3, - INVALID_CLIENT_VERSION = 4 - } - } + /** + * Converts this RecordAccessorDetailsResponse to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** Properties of a SubscriptionStatusRequest. */ - interface ISubscriptionStatusRequest { - } + /** + * Gets the default type url for RecordAccessorDetailsResponse + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** Represents a SubscriptionStatusRequest. */ - class SubscriptionStatusRequest implements ISubscriptionStatusRequest { + /** Properties of a RecordAccess. */ + interface IRecordAccess { - /** - * Constructs a new SubscriptionStatusRequest. - * @param [properties] Properties to set - */ - constructor(properties?: BI.ISubscriptionStatusRequest); + /** Core access details including permissions, role, and metadata. */ + data?: (Folder.IRecordAccessData|null); - /** - * Creates a new SubscriptionStatusRequest instance using the specified properties. - * @param [properties] Properties to set - * @returns SubscriptionStatusRequest instance - */ - public static create(properties?: BI.ISubscriptionStatusRequest): BI.SubscriptionStatusRequest; + /** The record accessor. */ + accessorInfo?: (record.v3.details.IAccessorInfo|null); + } - /** - * Encodes the specified SubscriptionStatusRequest message. Does not implicitly {@link BI.SubscriptionStatusRequest.verify|verify} messages. - * @param message SubscriptionStatusRequest message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: BI.ISubscriptionStatusRequest, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Describes the access a user has to a specific record. + * Includes ownership, access roles, and additional sharing metadata. + */ + class RecordAccess implements IRecordAccess { - /** - * Decodes a SubscriptionStatusRequest message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns SubscriptionStatusRequest - * @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.SubscriptionStatusRequest; + /** + * Constructs a new RecordAccess. + * @param [properties] Properties to set + */ + constructor(properties?: record.v3.details.IRecordAccess); - /** - * Creates a SubscriptionStatusRequest message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns SubscriptionStatusRequest - */ - public static fromObject(object: { [k: string]: any }): BI.SubscriptionStatusRequest; + /** Core access details including permissions, role, and metadata. */ + public data?: (Folder.IRecordAccessData|null); - /** - * Creates a plain object from a SubscriptionStatusRequest message. Also converts values to other types if specified. - * @param message SubscriptionStatusRequest - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: BI.SubscriptionStatusRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** The record accessor. */ + public accessorInfo?: (record.v3.details.IAccessorInfo|null); - /** - * Converts this SubscriptionStatusRequest to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Creates a new RecordAccess instance using the specified properties. + * @param [properties] Properties to set + * @returns RecordAccess instance + */ + public static create(properties?: record.v3.details.IRecordAccess): record.v3.details.RecordAccess; - /** - * Gets the default type url for SubscriptionStatusRequest - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Encodes the specified RecordAccess message. Does not implicitly {@link record.v3.details.RecordAccess.verify|verify} messages. + * @param message RecordAccess message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: record.v3.details.IRecordAccess, writer?: $protobuf.Writer): $protobuf.Writer; - /** Properties of a SubscriptionStatusResponse. */ - interface ISubscriptionStatusResponse { - - /** SubscriptionStatusResponse autoRenewal */ - autoRenewal?: (BI.IAutoRenewal|null); - - /** SubscriptionStatusResponse currentPaymentMethod */ - currentPaymentMethod?: (BI.IPaymentMethod|null); - - /** SubscriptionStatusResponse checkoutLink */ - checkoutLink?: (string|null); - - /** SubscriptionStatusResponse licenseCreateDate */ - licenseCreateDate?: (number|null); + /** + * Decodes a RecordAccess message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RecordAccess + * @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): record.v3.details.RecordAccess; - /** SubscriptionStatusResponse isDistributor */ - isDistributor?: (boolean|null); + /** + * Creates a RecordAccess message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RecordAccess + */ + public static fromObject(object: { [k: string]: any }): record.v3.details.RecordAccess; - /** SubscriptionStatusResponse isLegacyMsp */ - isLegacyMsp?: (boolean|null); + /** + * Creates a plain object from a RecordAccess message. Also converts values to other types if specified. + * @param message RecordAccess + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: record.v3.details.RecordAccess, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** SubscriptionStatusResponse licenseStats */ - licenseStats?: (BI.ILicenseStats[]|null); + /** + * Converts this RecordAccess to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** SubscriptionStatusResponse gradientStatus */ - gradientStatus?: (BI.GradientIntegrationStatus|null); + /** + * Gets the default type url for RecordAccess + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** SubscriptionStatusResponse hideTrialBanner */ - hideTrialBanner?: (boolean|null); + /** Properties of an AccessorInfo. */ + interface IAccessorInfo { - /** SubscriptionStatusResponse gradientLastSyncDate */ - gradientLastSyncDate?: (string|null); + /** accessor name */ + name?: (string|null); + } - /** SubscriptionStatusResponse gradientNextSyncDate */ - gradientNextSyncDate?: (string|null); + /** The entity representing the record accessor. Either a team or a user */ + class AccessorInfo implements IAccessorInfo { - /** SubscriptionStatusResponse isGradientMappingPending */ - isGradientMappingPending?: (boolean|null); + /** + * Constructs a new AccessorInfo. + * @param [properties] Properties to set + */ + constructor(properties?: record.v3.details.IAccessorInfo); - /** SubscriptionStatusResponse nhi */ - nhi?: (BI.INhiBilling|null); + /** accessor name */ + public name: string; - /** SubscriptionStatusResponse freeKsmApiCallsCount */ - freeKsmApiCallsCount?: (number|null); + /** + * Creates a new AccessorInfo instance using the specified properties. + * @param [properties] Properties to set + * @returns AccessorInfo instance + */ + public static create(properties?: record.v3.details.IAccessorInfo): record.v3.details.AccessorInfo; - /** SubscriptionStatusResponse ksm */ - ksm?: (BI.IKsmBilling|null); - } + /** + * Encodes the specified AccessorInfo message. Does not implicitly {@link record.v3.details.AccessorInfo.verify|verify} messages. + * @param message AccessorInfo message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: record.v3.details.IAccessorInfo, writer?: $protobuf.Writer): $protobuf.Writer; - /** Represents a SubscriptionStatusResponse. */ - class SubscriptionStatusResponse implements ISubscriptionStatusResponse { + /** + * Decodes an AccessorInfo message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns AccessorInfo + * @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): record.v3.details.AccessorInfo; - /** - * Constructs a new SubscriptionStatusResponse. - * @param [properties] Properties to set - */ - constructor(properties?: BI.ISubscriptionStatusResponse); + /** + * Creates an AccessorInfo message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns AccessorInfo + */ + public static fromObject(object: { [k: string]: any }): record.v3.details.AccessorInfo; - /** SubscriptionStatusResponse autoRenewal. */ - public autoRenewal?: (BI.IAutoRenewal|null); + /** + * Creates a plain object from an AccessorInfo message. Also converts values to other types if specified. + * @param message AccessorInfo + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: record.v3.details.AccessorInfo, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** SubscriptionStatusResponse currentPaymentMethod. */ - public currentPaymentMethod?: (BI.IPaymentMethod|null); + /** + * Converts this AccessorInfo to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** SubscriptionStatusResponse checkoutLink. */ - public checkoutLink: string; + /** + * Gets the default type url for AccessorInfo + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + } + } +} - /** SubscriptionStatusResponse licenseCreateDate. */ - public licenseCreateDate: number; +/** Namespace Upsell. */ +export namespace Upsell { - /** SubscriptionStatusResponse isDistributor. */ - public isDistributor: boolean; + /** Properties of an UpsellRequest. */ + interface IUpsellRequest { - /** SubscriptionStatusResponse isLegacyMsp. */ - public isLegacyMsp: boolean; + /** UpsellRequest email */ + email?: (string|null); - /** SubscriptionStatusResponse licenseStats. */ - public licenseStats: BI.ILicenseStats[]; + /** UpsellRequest locale */ + locale?: (string|null); - /** SubscriptionStatusResponse gradientStatus. */ - public gradientStatus: BI.GradientIntegrationStatus; + /** UpsellRequest clientVersion */ + clientVersion?: (string|null); - /** SubscriptionStatusResponse hideTrialBanner. */ - public hideTrialBanner: boolean; + /** UpsellRequest sessionToken */ + sessionToken?: (string|null); + } - /** SubscriptionStatusResponse gradientLastSyncDate. */ - public gradientLastSyncDate: string; + /** Represents an UpsellRequest. */ + class UpsellRequest implements IUpsellRequest { - /** SubscriptionStatusResponse gradientNextSyncDate. */ - public gradientNextSyncDate: string; + /** + * Constructs a new UpsellRequest. + * @param [properties] Properties to set + */ + constructor(properties?: Upsell.IUpsellRequest); - /** SubscriptionStatusResponse isGradientMappingPending. */ - public isGradientMappingPending: boolean; + /** UpsellRequest email. */ + public email: string; - /** SubscriptionStatusResponse nhi. */ - public nhi?: (BI.INhiBilling|null); + /** UpsellRequest locale. */ + public locale: string; - /** SubscriptionStatusResponse freeKsmApiCallsCount. */ - public freeKsmApiCallsCount: number; + /** UpsellRequest clientVersion. */ + public clientVersion: string; - /** SubscriptionStatusResponse ksm. */ - public ksm?: (BI.IKsmBilling|null); + /** UpsellRequest sessionToken. */ + public sessionToken: string; /** - * Creates a new SubscriptionStatusResponse instance using the specified properties. + * Creates a new UpsellRequest instance using the specified properties. * @param [properties] Properties to set - * @returns SubscriptionStatusResponse instance + * @returns UpsellRequest instance */ - public static create(properties?: BI.ISubscriptionStatusResponse): BI.SubscriptionStatusResponse; + public static create(properties?: Upsell.IUpsellRequest): Upsell.UpsellRequest; /** - * Encodes the specified SubscriptionStatusResponse message. Does not implicitly {@link BI.SubscriptionStatusResponse.verify|verify} messages. - * @param message SubscriptionStatusResponse message or plain object to encode + * Encodes the specified UpsellRequest message. Does not implicitly {@link Upsell.UpsellRequest.verify|verify} messages. + * @param message UpsellRequest message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: BI.ISubscriptionStatusResponse, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: Upsell.IUpsellRequest, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a SubscriptionStatusResponse message from the specified reader or buffer. + * Decodes an UpsellRequest message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns SubscriptionStatusResponse + * @returns UpsellRequest * @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.SubscriptionStatusResponse; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): Upsell.UpsellRequest; /** - * Creates a SubscriptionStatusResponse message from a plain object. Also converts values to their respective internal types. + * Creates an UpsellRequest message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns SubscriptionStatusResponse + * @returns UpsellRequest */ - public static fromObject(object: { [k: string]: any }): BI.SubscriptionStatusResponse; + public static fromObject(object: { [k: string]: any }): Upsell.UpsellRequest; /** - * Creates a plain object from a SubscriptionStatusResponse message. Also converts values to other types if specified. - * @param message SubscriptionStatusResponse + * Creates a plain object from an UpsellRequest message. Also converts values to other types if specified. + * @param message UpsellRequest * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: BI.SubscriptionStatusResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: Upsell.UpsellRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this SubscriptionStatusResponse to JSON. + * Converts this UpsellRequest to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for SubscriptionStatusResponse + * Gets the default type url for UpsellRequest * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a KsmBilling. */ - interface IKsmBilling { - - /** KsmBilling billingStartTimestamp */ - billingStartTimestamp?: (number|null); - - /** KsmBilling billingEndTimestamp */ - billingEndTimestamp?: (number|null); - - /** KsmBilling currentTierId */ - currentTierId?: (number|null); - - /** KsmBilling enterpriseBlocks */ - enterpriseBlocks?: (number|null); + /** Properties of an UpsellResponse. */ + interface IUpsellResponse { - /** KsmBilling currentTierCeiling */ - currentTierCeiling?: (number|null); + /** UpsellResponse UpsellBanner */ + UpsellBanner?: (Upsell.IUpsellBanner[]|null); } - /** Represents a KsmBilling. */ - class KsmBilling implements IKsmBilling { + /** Represents an UpsellResponse. */ + class UpsellResponse implements IUpsellResponse { /** - * Constructs a new KsmBilling. + * Constructs a new UpsellResponse. * @param [properties] Properties to set */ - constructor(properties?: BI.IKsmBilling); - - /** KsmBilling billingStartTimestamp. */ - public billingStartTimestamp: number; - - /** KsmBilling billingEndTimestamp. */ - public billingEndTimestamp: number; - - /** KsmBilling currentTierId. */ - public currentTierId: number; - - /** KsmBilling enterpriseBlocks. */ - public enterpriseBlocks: number; + constructor(properties?: Upsell.IUpsellResponse); - /** KsmBilling currentTierCeiling. */ - public currentTierCeiling: number; + /** UpsellResponse UpsellBanner. */ + public UpsellBanner: Upsell.IUpsellBanner[]; /** - * Creates a new KsmBilling instance using the specified properties. + * Creates a new UpsellResponse instance using the specified properties. * @param [properties] Properties to set - * @returns KsmBilling instance + * @returns UpsellResponse instance */ - public static create(properties?: BI.IKsmBilling): BI.KsmBilling; + public static create(properties?: Upsell.IUpsellResponse): Upsell.UpsellResponse; /** - * Encodes the specified KsmBilling message. Does not implicitly {@link BI.KsmBilling.verify|verify} messages. - * @param message KsmBilling message or plain object to encode + * Encodes the specified UpsellResponse message. Does not implicitly {@link Upsell.UpsellResponse.verify|verify} messages. + * @param message UpsellResponse message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: BI.IKsmBilling, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: Upsell.IUpsellResponse, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a KsmBilling message from the specified reader or buffer. + * Decodes an UpsellResponse message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns KsmBilling + * @returns UpsellResponse * @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.KsmBilling; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): Upsell.UpsellResponse; /** - * Creates a KsmBilling message from a plain object. Also converts values to their respective internal types. + * Creates an UpsellResponse message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns KsmBilling + * @returns UpsellResponse */ - public static fromObject(object: { [k: string]: any }): BI.KsmBilling; + public static fromObject(object: { [k: string]: any }): Upsell.UpsellResponse; /** - * Creates a plain object from a KsmBilling message. Also converts values to other types if specified. - * @param message KsmBilling + * Creates a plain object from an UpsellResponse message. Also converts values to other types if specified. + * @param message UpsellResponse * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: BI.KsmBilling, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: Upsell.UpsellResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this KsmBilling to JSON. + * Converts this UpsellResponse to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for KsmBilling + * Gets the default type url for UpsellResponse * @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 { + /** Properties of an UpsellBanner. */ + interface IUpsellBanner { - /** NhiBilling billingStartTimestamp */ - billingStartTimestamp?: (number|null); + /** UpsellBanner bannerId */ + bannerId?: (number|null); - /** NhiBilling billingEndTimestamp */ - billingEndTimestamp?: (number|null); + /** UpsellBanner bannerOkAction */ + bannerOkAction?: (string|null); - /** NhiBilling currentTierId */ - currentTierId?: (number|null); + /** UpsellBanner bannerOkButton */ + bannerOkButton?: (string|null); - /** NhiBilling enterpriseBlocks */ - enterpriseBlocks?: (number|null); + /** UpsellBanner bannerCancelAction */ + bannerCancelAction?: (string|null); - /** NhiBilling currentTierCeiling */ - currentTierCeiling?: (number|null); + /** UpsellBanner bannerCancelButton */ + bannerCancelButton?: (string|null); - /** NhiBilling billingPeriods */ - billingPeriods?: (BI.INhiBillingPeriod[]|null); + /** UpsellBanner bannerMessage */ + bannerMessage?: (string|null); + + /** UpsellBanner locale */ + locale?: (string|null); } - /** Represents a NhiBilling. */ - class NhiBilling implements INhiBilling { + /** Represents an UpsellBanner. */ + class UpsellBanner implements IUpsellBanner { /** - * Constructs a new NhiBilling. + * Constructs a new UpsellBanner. * @param [properties] Properties to set */ - constructor(properties?: BI.INhiBilling); + constructor(properties?: Upsell.IUpsellBanner); - /** NhiBilling billingStartTimestamp. */ - public billingStartTimestamp: number; + /** UpsellBanner bannerId. */ + public bannerId: number; - /** NhiBilling billingEndTimestamp. */ - public billingEndTimestamp: number; + /** UpsellBanner bannerOkAction. */ + public bannerOkAction: string; - /** NhiBilling currentTierId. */ - public currentTierId: number; + /** UpsellBanner bannerOkButton. */ + public bannerOkButton: string; - /** NhiBilling enterpriseBlocks. */ - public enterpriseBlocks: number; + /** UpsellBanner bannerCancelAction. */ + public bannerCancelAction: string; - /** NhiBilling currentTierCeiling. */ - public currentTierCeiling: number; + /** UpsellBanner bannerCancelButton. */ + public bannerCancelButton: string; - /** NhiBilling billingPeriods. */ - public billingPeriods: BI.INhiBillingPeriod[]; + /** UpsellBanner bannerMessage. */ + public bannerMessage: string; + + /** UpsellBanner locale. */ + public locale: string; /** - * Creates a new NhiBilling instance using the specified properties. + * Creates a new UpsellBanner instance using the specified properties. * @param [properties] Properties to set - * @returns NhiBilling instance + * @returns UpsellBanner instance */ - public static create(properties?: BI.INhiBilling): BI.NhiBilling; + public static create(properties?: Upsell.IUpsellBanner): Upsell.UpsellBanner; /** - * Encodes the specified NhiBilling message. Does not implicitly {@link BI.NhiBilling.verify|verify} messages. - * @param message NhiBilling message or plain object to encode + * Encodes the specified UpsellBanner message. Does not implicitly {@link Upsell.UpsellBanner.verify|verify} messages. + * @param message UpsellBanner message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: BI.INhiBilling, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: Upsell.IUpsellBanner, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a NhiBilling message from the specified reader or buffer. + * Decodes an UpsellBanner message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns NhiBilling + * @returns UpsellBanner * @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.NhiBilling; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): Upsell.UpsellBanner; /** - * Creates a NhiBilling message from a plain object. Also converts values to their respective internal types. + * Creates an UpsellBanner message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns NhiBilling + * @returns UpsellBanner */ - public static fromObject(object: { [k: string]: any }): BI.NhiBilling; + public static fromObject(object: { [k: string]: any }): Upsell.UpsellBanner; /** - * Creates a plain object from a NhiBilling message. Also converts values to other types if specified. - * @param message NhiBilling + * Creates a plain object from an UpsellBanner message. Also converts values to other types if specified. + * @param message UpsellBanner * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: BI.NhiBilling, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: Upsell.UpsellBanner, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this NhiBilling to JSON. + * Converts this UpsellBanner to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for NhiBilling + * Gets the default type url for UpsellBanner * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a NhiBillingPeriod. */ - interface INhiBillingPeriod { + /** ClientType enum. */ + enum ClientType { + DEFAULT_CLIENT_TYPE = 0, + ALL = 1, + ANDROID = 2, + IOS = 3, + MICROSOFT = 4, + WEBAPP = 5 + } - /** NhiBillingPeriod startTimestamp */ - startTimestamp?: (number|null); + /** ClientVersion enum. */ + enum ClientVersion { + DEFAULT_VERSION = 0, + SUPPORTS_ALL = 1, + BASEVERSION = 14, + ABOVERANGE = 15 + } +} - /** NhiBillingPeriod endTimestamp */ - endTimestamp?: (number|null); +/** Namespace BI. */ +export namespace BI { + + /** Currency enum. */ + enum Currency { + UNKNOWN = 0, + USD = 1, + GBP = 2, + JPY = 3, + EUR = 4, + AUD = 5, + CAD = 6 } - /** Represents a NhiBillingPeriod. */ - class NhiBillingPeriod implements INhiBillingPeriod { + /** Properties of a ValidateSessionTokenRequest. */ + interface IValidateSessionTokenRequest { + + /** ValidateSessionTokenRequest encryptedSessionToken */ + encryptedSessionToken?: (Uint8Array|null); + + /** ValidateSessionTokenRequest returnMcEnterpiseIds */ + returnMcEnterpiseIds?: (boolean|null); + + /** ValidateSessionTokenRequest ip */ + ip?: (string|null); + } + + /** Represents a ValidateSessionTokenRequest. */ + class ValidateSessionTokenRequest implements IValidateSessionTokenRequest { /** - * Constructs a new NhiBillingPeriod. + * Constructs a new ValidateSessionTokenRequest. * @param [properties] Properties to set */ - constructor(properties?: BI.INhiBillingPeriod); + constructor(properties?: BI.IValidateSessionTokenRequest); - /** NhiBillingPeriod startTimestamp. */ - public startTimestamp: number; + /** ValidateSessionTokenRequest encryptedSessionToken. */ + public encryptedSessionToken: Uint8Array; - /** NhiBillingPeriod endTimestamp. */ - public endTimestamp: number; + /** ValidateSessionTokenRequest returnMcEnterpiseIds. */ + public returnMcEnterpiseIds: boolean; + + /** ValidateSessionTokenRequest ip. */ + public ip: string; /** - * Creates a new NhiBillingPeriod instance using the specified properties. + * Creates a new ValidateSessionTokenRequest instance using the specified properties. * @param [properties] Properties to set - * @returns NhiBillingPeriod instance + * @returns ValidateSessionTokenRequest instance */ - public static create(properties?: BI.INhiBillingPeriod): BI.NhiBillingPeriod; + public static create(properties?: BI.IValidateSessionTokenRequest): BI.ValidateSessionTokenRequest; /** - * Encodes the specified NhiBillingPeriod message. Does not implicitly {@link BI.NhiBillingPeriod.verify|verify} messages. - * @param message NhiBillingPeriod message or plain object to encode + * Encodes the specified ValidateSessionTokenRequest message. Does not implicitly {@link BI.ValidateSessionTokenRequest.verify|verify} messages. + * @param message ValidateSessionTokenRequest message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: BI.INhiBillingPeriod, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: BI.IValidateSessionTokenRequest, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a NhiBillingPeriod message from the specified reader or buffer. + * Decodes a ValidateSessionTokenRequest message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns NhiBillingPeriod + * @returns ValidateSessionTokenRequest * @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.NhiBillingPeriod; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): BI.ValidateSessionTokenRequest; /** - * Creates a NhiBillingPeriod message from a plain object. Also converts values to their respective internal types. + * Creates a ValidateSessionTokenRequest message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns NhiBillingPeriod + * @returns ValidateSessionTokenRequest */ - public static fromObject(object: { [k: string]: any }): BI.NhiBillingPeriod; + public static fromObject(object: { [k: string]: any }): BI.ValidateSessionTokenRequest; /** - * Creates a plain object from a NhiBillingPeriod message. Also converts values to other types if specified. - * @param message NhiBillingPeriod + * Creates a plain object from a ValidateSessionTokenRequest message. Also converts values to other types if specified. + * @param message ValidateSessionTokenRequest * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: BI.NhiBillingPeriod, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: BI.ValidateSessionTokenRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this NhiBillingPeriod to JSON. + * Converts this ValidateSessionTokenRequest to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for NhiBillingPeriod + * Gets the default type url for ValidateSessionTokenRequest * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a LicenseStats. */ - interface ILicenseStats { + /** Properties of a ValidateSessionTokenResponse. */ + interface IValidateSessionTokenResponse { - /** LicenseStats type */ - type?: (BI.LicenseStats.Type|null); + /** ValidateSessionTokenResponse username */ + username?: (string|null); - /** LicenseStats available */ - available?: (number|null); + /** ValidateSessionTokenResponse userId */ + userId?: (number|null); - /** LicenseStats used */ - used?: (number|null); + /** ValidateSessionTokenResponse enterpriseUserId */ + enterpriseUserId?: (number|null); + + /** ValidateSessionTokenResponse status */ + status?: (BI.ValidateSessionTokenResponse.Status|null); + + /** ValidateSessionTokenResponse statusMessage */ + statusMessage?: (string|null); + + /** ValidateSessionTokenResponse mcEnterpriseIds */ + mcEnterpriseIds?: (number[]|null); + + /** ValidateSessionTokenResponse hasMSPPermission */ + hasMSPPermission?: (boolean|null); + + /** ValidateSessionTokenResponse deletedMcEnterpriseIds */ + deletedMcEnterpriseIds?: (number[]|null); } - /** Represents a LicenseStats. */ - class LicenseStats implements ILicenseStats { + /** Represents a ValidateSessionTokenResponse. */ + class ValidateSessionTokenResponse implements IValidateSessionTokenResponse { /** - * Constructs a new LicenseStats. + * Constructs a new ValidateSessionTokenResponse. * @param [properties] Properties to set */ - constructor(properties?: BI.ILicenseStats); + constructor(properties?: BI.IValidateSessionTokenResponse); - /** LicenseStats type. */ - public type: BI.LicenseStats.Type; + /** ValidateSessionTokenResponse username. */ + public username: string; - /** LicenseStats available. */ - public available: number; + /** ValidateSessionTokenResponse userId. */ + public userId: number; - /** LicenseStats used. */ - public used: number; + /** ValidateSessionTokenResponse enterpriseUserId. */ + public enterpriseUserId: number; + + /** ValidateSessionTokenResponse status. */ + public status: BI.ValidateSessionTokenResponse.Status; + + /** ValidateSessionTokenResponse statusMessage. */ + public statusMessage: string; + + /** ValidateSessionTokenResponse mcEnterpriseIds. */ + public mcEnterpriseIds: number[]; + + /** ValidateSessionTokenResponse hasMSPPermission. */ + public hasMSPPermission: boolean; + + /** ValidateSessionTokenResponse deletedMcEnterpriseIds. */ + public deletedMcEnterpriseIds: number[]; /** - * Creates a new LicenseStats instance using the specified properties. + * Creates a new ValidateSessionTokenResponse instance using the specified properties. * @param [properties] Properties to set - * @returns LicenseStats instance + * @returns ValidateSessionTokenResponse instance */ - public static create(properties?: BI.ILicenseStats): BI.LicenseStats; + public static create(properties?: BI.IValidateSessionTokenResponse): BI.ValidateSessionTokenResponse; /** - * Encodes the specified LicenseStats message. Does not implicitly {@link BI.LicenseStats.verify|verify} messages. - * @param message LicenseStats message or plain object to encode + * Encodes the specified ValidateSessionTokenResponse message. Does not implicitly {@link BI.ValidateSessionTokenResponse.verify|verify} messages. + * @param message ValidateSessionTokenResponse message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: BI.ILicenseStats, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: BI.IValidateSessionTokenResponse, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a LicenseStats message from the specified reader or buffer. + * Decodes a ValidateSessionTokenResponse message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns LicenseStats + * @returns ValidateSessionTokenResponse * @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.LicenseStats; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): BI.ValidateSessionTokenResponse; /** - * Creates a LicenseStats message from a plain object. Also converts values to their respective internal types. + * Creates a ValidateSessionTokenResponse message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns LicenseStats + * @returns ValidateSessionTokenResponse */ - public static fromObject(object: { [k: string]: any }): BI.LicenseStats; + public static fromObject(object: { [k: string]: any }): BI.ValidateSessionTokenResponse; /** - * Creates a plain object from a LicenseStats message. Also converts values to other types if specified. - * @param message LicenseStats + * Creates a plain object from a ValidateSessionTokenResponse message. Also converts values to other types if specified. + * @param message ValidateSessionTokenResponse * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: BI.LicenseStats, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: BI.ValidateSessionTokenResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this LicenseStats to JSON. + * Converts this ValidateSessionTokenResponse to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for LicenseStats + * Gets the default type url for ValidateSessionTokenResponse * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - namespace LicenseStats { + namespace ValidateSessionTokenResponse { - /** Type enum. */ - enum Type { - LICENSE_STAT_UNKNOWN = 0, - MSP_BASE = 1, - MC_BUSINESS = 2, - MC_BUSINESS_PLUS = 3, - MC_ENTERPRISE = 4, - MC_ENTERPRISE_PLUS = 5, - B2B_BUSINESS_STARTER = 6, - B2B_BUSINESS = 7, - B2B_ENTERPRISE = 8 + /** Status enum. */ + enum Status { + VALID = 0, + NOT_VALID = 1, + EXPIRED = 2, + IP_BLOCKED = 3, + INVALID_CLIENT_VERSION = 4 } } - /** Properties of an AutoRenewal. */ - interface IAutoRenewal { - - /** AutoRenewal nextOn */ - nextOn?: (number|null); - - /** AutoRenewal daysLeft */ - daysLeft?: (number|null); - - /** AutoRenewal isTrial */ - isTrial?: (boolean|null); + /** Properties of a SubscriptionStatusRequest. */ + interface ISubscriptionStatusRequest { } - /** Represents an AutoRenewal. */ - class AutoRenewal implements IAutoRenewal { + /** Represents a SubscriptionStatusRequest. */ + class SubscriptionStatusRequest implements ISubscriptionStatusRequest { /** - * Constructs a new AutoRenewal. + * Constructs a new SubscriptionStatusRequest. * @param [properties] Properties to set */ - constructor(properties?: BI.IAutoRenewal); - - /** AutoRenewal nextOn. */ - public nextOn: number; - - /** AutoRenewal daysLeft. */ - public daysLeft: number; - - /** AutoRenewal isTrial. */ - public isTrial: boolean; + constructor(properties?: BI.ISubscriptionStatusRequest); /** - * Creates a new AutoRenewal instance using the specified properties. + * Creates a new SubscriptionStatusRequest instance using the specified properties. * @param [properties] Properties to set - * @returns AutoRenewal instance + * @returns SubscriptionStatusRequest instance */ - public static create(properties?: BI.IAutoRenewal): BI.AutoRenewal; + public static create(properties?: BI.ISubscriptionStatusRequest): BI.SubscriptionStatusRequest; /** - * Encodes the specified AutoRenewal message. Does not implicitly {@link BI.AutoRenewal.verify|verify} messages. - * @param message AutoRenewal message or plain object to encode + * Encodes the specified SubscriptionStatusRequest message. Does not implicitly {@link BI.SubscriptionStatusRequest.verify|verify} messages. + * @param message SubscriptionStatusRequest message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: BI.IAutoRenewal, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: BI.ISubscriptionStatusRequest, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes an AutoRenewal message from the specified reader or buffer. + * Decodes a SubscriptionStatusRequest message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns AutoRenewal + * @returns SubscriptionStatusRequest * @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.AutoRenewal; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): BI.SubscriptionStatusRequest; /** - * Creates an AutoRenewal message from a plain object. Also converts values to their respective internal types. + * Creates a SubscriptionStatusRequest message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns AutoRenewal + * @returns SubscriptionStatusRequest */ - public static fromObject(object: { [k: string]: any }): BI.AutoRenewal; + public static fromObject(object: { [k: string]: any }): BI.SubscriptionStatusRequest; /** - * Creates a plain object from an AutoRenewal message. Also converts values to other types if specified. - * @param message AutoRenewal + * Creates a plain object from a SubscriptionStatusRequest message. Also converts values to other types if specified. + * @param message SubscriptionStatusRequest * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: BI.AutoRenewal, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: BI.SubscriptionStatusRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this AutoRenewal to JSON. + * Converts this SubscriptionStatusRequest to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for AutoRenewal + * Gets the default type url for SubscriptionStatusRequest * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a PaymentMethod. */ - interface IPaymentMethod { + /** Properties of a SubscriptionStatusResponse. */ + interface ISubscriptionStatusResponse { - /** PaymentMethod type */ - type?: (BI.PaymentMethod.Type|null); + /** SubscriptionStatusResponse autoRenewal */ + autoRenewal?: (BI.IAutoRenewal|null); - /** PaymentMethod card */ - card?: (BI.PaymentMethod.ICard|null); + /** SubscriptionStatusResponse currentPaymentMethod */ + currentPaymentMethod?: (BI.IPaymentMethod|null); - /** PaymentMethod sepa */ - sepa?: (BI.PaymentMethod.ISepa|null); + /** SubscriptionStatusResponse checkoutLink */ + checkoutLink?: (string|null); - /** PaymentMethod paypal */ - paypal?: (BI.PaymentMethod.IPaypal|null); + /** SubscriptionStatusResponse licenseCreateDate */ + licenseCreateDate?: (number|null); - /** PaymentMethod failedBilling */ - failedBilling?: (boolean|null); + /** SubscriptionStatusResponse isDistributor */ + isDistributor?: (boolean|null); - /** PaymentMethod vendor */ - vendor?: (BI.PaymentMethod.IVendor|null); + /** SubscriptionStatusResponse isLegacyMsp */ + isLegacyMsp?: (boolean|null); - /** PaymentMethod purchaseOrder */ - purchaseOrder?: (BI.PaymentMethod.IPurchaseOrder|null); - } + /** SubscriptionStatusResponse licenseStats */ + licenseStats?: (BI.ILicenseStats[]|null); - /** Represents a PaymentMethod. */ - class PaymentMethod implements IPaymentMethod { + /** SubscriptionStatusResponse gradientStatus */ + gradientStatus?: (BI.GradientIntegrationStatus|null); - /** - * Constructs a new PaymentMethod. - * @param [properties] Properties to set - */ - constructor(properties?: BI.IPaymentMethod); + /** SubscriptionStatusResponse hideTrialBanner */ + hideTrialBanner?: (boolean|null); - /** PaymentMethod type. */ - public type: BI.PaymentMethod.Type; + /** SubscriptionStatusResponse gradientLastSyncDate */ + gradientLastSyncDate?: (string|null); - /** PaymentMethod card. */ - public card?: (BI.PaymentMethod.ICard|null); + /** SubscriptionStatusResponse gradientNextSyncDate */ + gradientNextSyncDate?: (string|null); - /** PaymentMethod sepa. */ - public sepa?: (BI.PaymentMethod.ISepa|null); + /** SubscriptionStatusResponse isGradientMappingPending */ + isGradientMappingPending?: (boolean|null); - /** PaymentMethod paypal. */ - public paypal?: (BI.PaymentMethod.IPaypal|null); + /** SubscriptionStatusResponse nhi */ + nhi?: (BI.INhiBilling|null); - /** PaymentMethod failedBilling. */ - public failedBilling: boolean; + /** SubscriptionStatusResponse freeKsmApiCallsCount */ + freeKsmApiCallsCount?: (number|null); - /** PaymentMethod vendor. */ - public vendor?: (BI.PaymentMethod.IVendor|null); + /** SubscriptionStatusResponse ksm */ + ksm?: (BI.IKsmBilling|null); + } - /** PaymentMethod purchaseOrder. */ - public purchaseOrder?: (BI.PaymentMethod.IPurchaseOrder|null); + /** Represents a SubscriptionStatusResponse. */ + class SubscriptionStatusResponse implements ISubscriptionStatusResponse { /** - * Creates a new PaymentMethod instance using the specified properties. + * Constructs a new SubscriptionStatusResponse. * @param [properties] Properties to set - * @returns PaymentMethod instance */ - public static create(properties?: BI.IPaymentMethod): BI.PaymentMethod; + constructor(properties?: BI.ISubscriptionStatusResponse); - /** - * Encodes the specified PaymentMethod message. Does not implicitly {@link BI.PaymentMethod.verify|verify} messages. - * @param message PaymentMethod message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: BI.IPaymentMethod, writer?: $protobuf.Writer): $protobuf.Writer; + /** SubscriptionStatusResponse autoRenewal. */ + public autoRenewal?: (BI.IAutoRenewal|null); - /** - * Decodes a PaymentMethod message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns PaymentMethod - * @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.PaymentMethod; + /** SubscriptionStatusResponse currentPaymentMethod. */ + public currentPaymentMethod?: (BI.IPaymentMethod|null); - /** - * Creates a PaymentMethod message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns PaymentMethod - */ - public static fromObject(object: { [k: string]: any }): BI.PaymentMethod; + /** SubscriptionStatusResponse checkoutLink. */ + public checkoutLink: string; - /** - * Creates a plain object from a PaymentMethod message. Also converts values to other types if specified. - * @param message PaymentMethod - * @param [options] Conversion options - * @returns Plain object + /** SubscriptionStatusResponse licenseCreateDate. */ + public licenseCreateDate: number; + + /** SubscriptionStatusResponse isDistributor. */ + public isDistributor: boolean; + + /** SubscriptionStatusResponse isLegacyMsp. */ + public isLegacyMsp: boolean; + + /** SubscriptionStatusResponse licenseStats. */ + public licenseStats: BI.ILicenseStats[]; + + /** SubscriptionStatusResponse gradientStatus. */ + public gradientStatus: BI.GradientIntegrationStatus; + + /** SubscriptionStatusResponse hideTrialBanner. */ + public hideTrialBanner: boolean; + + /** SubscriptionStatusResponse gradientLastSyncDate. */ + public gradientLastSyncDate: string; + + /** SubscriptionStatusResponse gradientNextSyncDate. */ + public gradientNextSyncDate: string; + + /** SubscriptionStatusResponse isGradientMappingPending. */ + public isGradientMappingPending: boolean; + + /** SubscriptionStatusResponse nhi. */ + public nhi?: (BI.INhiBilling|null); + + /** SubscriptionStatusResponse freeKsmApiCallsCount. */ + public freeKsmApiCallsCount: number; + + /** SubscriptionStatusResponse ksm. */ + public ksm?: (BI.IKsmBilling|null); + + /** + * Creates a new SubscriptionStatusResponse instance using the specified properties. + * @param [properties] Properties to set + * @returns SubscriptionStatusResponse instance */ - public static toObject(message: BI.PaymentMethod, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static create(properties?: BI.ISubscriptionStatusResponse): BI.SubscriptionStatusResponse; /** - * Converts this PaymentMethod to JSON. + * Encodes the specified SubscriptionStatusResponse message. Does not implicitly {@link BI.SubscriptionStatusResponse.verify|verify} messages. + * @param message SubscriptionStatusResponse message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.ISubscriptionStatusResponse, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a SubscriptionStatusResponse message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns SubscriptionStatusResponse + * @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.SubscriptionStatusResponse; + + /** + * Creates a SubscriptionStatusResponse message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns SubscriptionStatusResponse + */ + public static fromObject(object: { [k: string]: any }): BI.SubscriptionStatusResponse; + + /** + * Creates a plain object from a SubscriptionStatusResponse message. Also converts values to other types if specified. + * @param message SubscriptionStatusResponse + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.SubscriptionStatusResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this SubscriptionStatusResponse to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for PaymentMethod + * Gets the default type url for SubscriptionStatusResponse * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - namespace PaymentMethod { + /** Properties of a KsmBilling. */ + interface IKsmBilling { - /** Type enum. */ - enum Type { - CARD = 0, - SEPA = 1, - PAYPAL = 2, - NONE = 3, - VENDOR = 4, - PURCHASEORDER = 5 - } + /** KsmBilling billingStartTimestamp */ + billingStartTimestamp?: (number|null); - /** Properties of a Card. */ - interface ICard { + /** KsmBilling billingEndTimestamp */ + billingEndTimestamp?: (number|null); - /** Card last4 */ - last4?: (string|null); + /** KsmBilling currentTierId */ + currentTierId?: (number|null); - /** Card brand */ - brand?: (string|null); - } + /** KsmBilling enterpriseBlocks */ + enterpriseBlocks?: (number|null); - /** Represents a Card. */ - class Card implements ICard { + /** KsmBilling currentTierCeiling */ + currentTierCeiling?: (number|null); + } - /** - * Constructs a new Card. - * @param [properties] Properties to set - */ - constructor(properties?: BI.PaymentMethod.ICard); + /** Represents a KsmBilling. */ + class KsmBilling implements IKsmBilling { - /** Card last4. */ - public last4: string; + /** + * Constructs a new KsmBilling. + * @param [properties] Properties to set + */ + constructor(properties?: BI.IKsmBilling); - /** Card brand. */ - public brand: string; + /** KsmBilling billingStartTimestamp. */ + public billingStartTimestamp: number; - /** - * Creates a new Card instance using the specified properties. - * @param [properties] Properties to set - * @returns Card instance - */ - public static create(properties?: BI.PaymentMethod.ICard): BI.PaymentMethod.Card; + /** KsmBilling billingEndTimestamp. */ + public billingEndTimestamp: number; - /** - * Encodes the specified Card message. Does not implicitly {@link BI.PaymentMethod.Card.verify|verify} messages. - * @param message Card message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: BI.PaymentMethod.ICard, writer?: $protobuf.Writer): $protobuf.Writer; + /** KsmBilling currentTierId. */ + public currentTierId: number; - /** - * Decodes a Card message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns Card - * @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.PaymentMethod.Card; + /** KsmBilling enterpriseBlocks. */ + public enterpriseBlocks: number; - /** - * Creates a Card message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns Card - */ - public static fromObject(object: { [k: string]: any }): BI.PaymentMethod.Card; + /** KsmBilling currentTierCeiling. */ + public currentTierCeiling: number; - /** - * Creates a plain object from a Card message. Also converts values to other types if specified. - * @param message Card - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: BI.PaymentMethod.Card, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** + * Creates a new KsmBilling instance using the specified properties. + * @param [properties] Properties to set + * @returns KsmBilling instance + */ + public static create(properties?: BI.IKsmBilling): BI.KsmBilling; - /** - * Converts this Card to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Encodes the specified KsmBilling message. Does not implicitly {@link BI.KsmBilling.verify|verify} messages. + * @param message KsmBilling message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.IKsmBilling, writer?: $protobuf.Writer): $protobuf.Writer; - /** - * Gets the default type url for Card - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Decodes a KsmBilling message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns KsmBilling + * @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.KsmBilling; - /** Properties of a Sepa. */ - interface ISepa { + /** + * Creates a KsmBilling message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns KsmBilling + */ + public static fromObject(object: { [k: string]: any }): BI.KsmBilling; - /** Sepa last4 */ - last4?: (string|null); + /** + * Creates a plain object from a KsmBilling message. Also converts values to other types if specified. + * @param message KsmBilling + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.KsmBilling, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** Sepa country */ - country?: (string|null); - } + /** + * Converts this KsmBilling to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** Represents a Sepa. */ - class Sepa implements ISepa { + /** + * Gets the default type url for KsmBilling + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** - * Constructs a new Sepa. - * @param [properties] Properties to set - */ - constructor(properties?: BI.PaymentMethod.ISepa); + /** Properties of a NhiBilling. */ + interface INhiBilling { - /** Sepa last4. */ - public last4: string; + /** NhiBilling billingStartTimestamp */ + billingStartTimestamp?: (number|null); - /** Sepa country. */ - public country: string; + /** NhiBilling billingEndTimestamp */ + billingEndTimestamp?: (number|null); - /** + /** NhiBilling currentTierId */ + currentTierId?: (number|null); + + /** NhiBilling enterpriseBlocks */ + enterpriseBlocks?: (number|null); + + /** NhiBilling currentTierCeiling */ + currentTierCeiling?: (number|null); + + /** NhiBilling billingPeriods */ + billingPeriods?: (BI.INhiBillingPeriod[]|null); + } + + /** Represents a NhiBilling. */ + class NhiBilling implements INhiBilling { + + /** + * Constructs a new NhiBilling. + * @param [properties] Properties to set + */ + constructor(properties?: BI.INhiBilling); + + /** NhiBilling billingStartTimestamp. */ + public billingStartTimestamp: number; + + /** NhiBilling billingEndTimestamp. */ + public billingEndTimestamp: number; + + /** NhiBilling currentTierId. */ + public currentTierId: number; + + /** NhiBilling enterpriseBlocks. */ + public enterpriseBlocks: number; + + /** NhiBilling currentTierCeiling. */ + public currentTierCeiling: number; + + /** NhiBilling billingPeriods. */ + public billingPeriods: BI.INhiBillingPeriod[]; + + /** + * Creates a new NhiBilling instance using the specified properties. + * @param [properties] Properties to set + * @returns NhiBilling instance + */ + public static create(properties?: BI.INhiBilling): BI.NhiBilling; + + /** + * Encodes the specified NhiBilling message. Does not implicitly {@link BI.NhiBilling.verify|verify} messages. + * @param message NhiBilling message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.INhiBilling, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a NhiBilling message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns NhiBilling + * @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.NhiBilling; + + /** + * Creates a NhiBilling message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns NhiBilling + */ + public static fromObject(object: { [k: string]: any }): BI.NhiBilling; + + /** + * Creates a plain object from a NhiBilling message. Also converts values to other types if specified. + * @param message NhiBilling + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.NhiBilling, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this NhiBilling to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for NhiBilling + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a NhiBillingPeriod. */ + interface INhiBillingPeriod { + + /** NhiBillingPeriod startTimestamp */ + startTimestamp?: (number|null); + + /** NhiBillingPeriod endTimestamp */ + endTimestamp?: (number|null); + } + + /** Represents a NhiBillingPeriod. */ + class NhiBillingPeriod implements INhiBillingPeriod { + + /** + * Constructs a new NhiBillingPeriod. + * @param [properties] Properties to set + */ + constructor(properties?: BI.INhiBillingPeriod); + + /** NhiBillingPeriod startTimestamp. */ + public startTimestamp: number; + + /** NhiBillingPeriod endTimestamp. */ + public endTimestamp: number; + + /** + * Creates a new NhiBillingPeriod instance using the specified properties. + * @param [properties] Properties to set + * @returns NhiBillingPeriod instance + */ + public static create(properties?: BI.INhiBillingPeriod): BI.NhiBillingPeriod; + + /** + * Encodes the specified NhiBillingPeriod message. Does not implicitly {@link BI.NhiBillingPeriod.verify|verify} messages. + * @param message NhiBillingPeriod message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.INhiBillingPeriod, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a NhiBillingPeriod message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns NhiBillingPeriod + * @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.NhiBillingPeriod; + + /** + * Creates a NhiBillingPeriod message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns NhiBillingPeriod + */ + public static fromObject(object: { [k: string]: any }): BI.NhiBillingPeriod; + + /** + * Creates a plain object from a NhiBillingPeriod message. Also converts values to other types if specified. + * @param message NhiBillingPeriod + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.NhiBillingPeriod, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this NhiBillingPeriod to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for NhiBillingPeriod + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a LicenseStats. */ + interface ILicenseStats { + + /** LicenseStats type */ + type?: (BI.LicenseStats.Type|null); + + /** LicenseStats available */ + available?: (number|null); + + /** LicenseStats used */ + used?: (number|null); + } + + /** Represents a LicenseStats. */ + class LicenseStats implements ILicenseStats { + + /** + * Constructs a new LicenseStats. + * @param [properties] Properties to set + */ + constructor(properties?: BI.ILicenseStats); + + /** LicenseStats type. */ + public type: BI.LicenseStats.Type; + + /** LicenseStats available. */ + public available: number; + + /** LicenseStats used. */ + public used: number; + + /** + * Creates a new LicenseStats instance using the specified properties. + * @param [properties] Properties to set + * @returns LicenseStats instance + */ + public static create(properties?: BI.ILicenseStats): BI.LicenseStats; + + /** + * Encodes the specified LicenseStats message. Does not implicitly {@link BI.LicenseStats.verify|verify} messages. + * @param message LicenseStats message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.ILicenseStats, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a LicenseStats message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns LicenseStats + * @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.LicenseStats; + + /** + * Creates a LicenseStats message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns LicenseStats + */ + public static fromObject(object: { [k: string]: any }): BI.LicenseStats; + + /** + * Creates a plain object from a LicenseStats message. Also converts values to other types if specified. + * @param message LicenseStats + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.LicenseStats, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this LicenseStats to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for LicenseStats + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + namespace LicenseStats { + + /** Type enum. */ + enum Type { + LICENSE_STAT_UNKNOWN = 0, + MSP_BASE = 1, + MC_BUSINESS = 2, + MC_BUSINESS_PLUS = 3, + MC_ENTERPRISE = 4, + MC_ENTERPRISE_PLUS = 5, + B2B_BUSINESS_STARTER = 6, + B2B_BUSINESS = 7, + B2B_ENTERPRISE = 8 + } + } + + /** Properties of an AutoRenewal. */ + interface IAutoRenewal { + + /** AutoRenewal nextOn */ + nextOn?: (number|null); + + /** AutoRenewal daysLeft */ + daysLeft?: (number|null); + + /** AutoRenewal isTrial */ + isTrial?: (boolean|null); + } + + /** Represents an AutoRenewal. */ + class AutoRenewal implements IAutoRenewal { + + /** + * Constructs a new AutoRenewal. + * @param [properties] Properties to set + */ + constructor(properties?: BI.IAutoRenewal); + + /** AutoRenewal nextOn. */ + public nextOn: number; + + /** AutoRenewal daysLeft. */ + public daysLeft: number; + + /** AutoRenewal isTrial. */ + public isTrial: boolean; + + /** + * Creates a new AutoRenewal instance using the specified properties. + * @param [properties] Properties to set + * @returns AutoRenewal instance + */ + public static create(properties?: BI.IAutoRenewal): BI.AutoRenewal; + + /** + * Encodes the specified AutoRenewal message. Does not implicitly {@link BI.AutoRenewal.verify|verify} messages. + * @param message AutoRenewal message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.IAutoRenewal, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes an AutoRenewal message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns AutoRenewal + * @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.AutoRenewal; + + /** + * Creates an AutoRenewal message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns AutoRenewal + */ + public static fromObject(object: { [k: string]: any }): BI.AutoRenewal; + + /** + * Creates a plain object from an AutoRenewal message. Also converts values to other types if specified. + * @param message AutoRenewal + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.AutoRenewal, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this AutoRenewal to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for AutoRenewal + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a PaymentMethod. */ + interface IPaymentMethod { + + /** PaymentMethod type */ + type?: (BI.PaymentMethod.Type|null); + + /** PaymentMethod card */ + card?: (BI.PaymentMethod.ICard|null); + + /** PaymentMethod sepa */ + sepa?: (BI.PaymentMethod.ISepa|null); + + /** PaymentMethod paypal */ + paypal?: (BI.PaymentMethod.IPaypal|null); + + /** PaymentMethod failedBilling */ + failedBilling?: (boolean|null); + + /** PaymentMethod vendor */ + vendor?: (BI.PaymentMethod.IVendor|null); + + /** PaymentMethod purchaseOrder */ + purchaseOrder?: (BI.PaymentMethod.IPurchaseOrder|null); + } + + /** Represents a PaymentMethod. */ + class PaymentMethod implements IPaymentMethod { + + /** + * Constructs a new PaymentMethod. + * @param [properties] Properties to set + */ + constructor(properties?: BI.IPaymentMethod); + + /** PaymentMethod type. */ + public type: BI.PaymentMethod.Type; + + /** PaymentMethod card. */ + public card?: (BI.PaymentMethod.ICard|null); + + /** PaymentMethod sepa. */ + public sepa?: (BI.PaymentMethod.ISepa|null); + + /** PaymentMethod paypal. */ + public paypal?: (BI.PaymentMethod.IPaypal|null); + + /** PaymentMethod failedBilling. */ + public failedBilling: boolean; + + /** PaymentMethod vendor. */ + public vendor?: (BI.PaymentMethod.IVendor|null); + + /** PaymentMethod purchaseOrder. */ + public purchaseOrder?: (BI.PaymentMethod.IPurchaseOrder|null); + + /** + * Creates a new PaymentMethod instance using the specified properties. + * @param [properties] Properties to set + * @returns PaymentMethod instance + */ + public static create(properties?: BI.IPaymentMethod): BI.PaymentMethod; + + /** + * Encodes the specified PaymentMethod message. Does not implicitly {@link BI.PaymentMethod.verify|verify} messages. + * @param message PaymentMethod message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.IPaymentMethod, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a PaymentMethod message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns PaymentMethod + * @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.PaymentMethod; + + /** + * Creates a PaymentMethod message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns PaymentMethod + */ + public static fromObject(object: { [k: string]: any }): BI.PaymentMethod; + + /** + * Creates a plain object from a PaymentMethod message. Also converts values to other types if specified. + * @param message PaymentMethod + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.PaymentMethod, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this PaymentMethod to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for PaymentMethod + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + namespace PaymentMethod { + + /** Type enum. */ + enum Type { + CARD = 0, + SEPA = 1, + PAYPAL = 2, + NONE = 3, + VENDOR = 4, + PURCHASEORDER = 5 + } + + /** Properties of a Card. */ + interface ICard { + + /** Card last4 */ + last4?: (string|null); + + /** Card brand */ + brand?: (string|null); + } + + /** Represents a Card. */ + class Card implements ICard { + + /** + * Constructs a new Card. + * @param [properties] Properties to set + */ + constructor(properties?: BI.PaymentMethod.ICard); + + /** Card last4. */ + public last4: string; + + /** Card brand. */ + public brand: string; + + /** + * Creates a new Card instance using the specified properties. + * @param [properties] Properties to set + * @returns Card instance + */ + public static create(properties?: BI.PaymentMethod.ICard): BI.PaymentMethod.Card; + + /** + * Encodes the specified Card message. Does not implicitly {@link BI.PaymentMethod.Card.verify|verify} messages. + * @param message Card message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: BI.PaymentMethod.ICard, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a Card message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns Card + * @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.PaymentMethod.Card; + + /** + * Creates a Card message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns Card + */ + public static fromObject(object: { [k: string]: any }): BI.PaymentMethod.Card; + + /** + * Creates a plain object from a Card message. Also converts values to other types if specified. + * @param message Card + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: BI.PaymentMethod.Card, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this Card to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for Card + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a Sepa. */ + interface ISepa { + + /** Sepa last4 */ + last4?: (string|null); + + /** Sepa country */ + country?: (string|null); + } + + /** Represents a Sepa. */ + class Sepa implements ISepa { + + /** + * Constructs a new Sepa. + * @param [properties] Properties to set + */ + constructor(properties?: BI.PaymentMethod.ISepa); + + /** Sepa last4. */ + public last4: string; + + /** Sepa country. */ + public country: string; + + /** * Creates a new Sepa instance using the specified properties. * @param [properties] Properties to set * @returns Sepa instance @@ -82342,6 +83128,9 @@ export namespace Router { /** RouterRotationInfo disabled */ disabled?: (boolean|null); + + /** RouterRotationInfo scripts */ + scripts?: (Uint8Array[]|null); } /** Represents a RouterRotationInfo. */ @@ -82380,6 +83169,9 @@ export namespace Router { /** RouterRotationInfo disabled. */ public disabled: boolean; + /** RouterRotationInfo scripts. */ + public scripts: Uint8Array[]; + /** * Creates a new RouterRotationInfo instance using the specified properties. * @param [properties] Properties to set @@ -89135,2145 +89927,2622 @@ export namespace PAM { public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): PAM.GetNhiUidsResponse; /** - * Creates a GetNhiUidsResponse message from a plain object. Also converts values to their respective internal types. + * Creates a GetNhiUidsResponse message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns GetNhiUidsResponse + */ + public static fromObject(object: { [k: string]: any }): PAM.GetNhiUidsResponse; + + /** + * Creates a plain object from a GetNhiUidsResponse message. Also converts values to other types if specified. + * @param message GetNhiUidsResponse + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: PAM.GetNhiUidsResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this GetNhiUidsResponse to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for GetNhiUidsResponse + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a SetNhiKsmEffectiveDateRequest. */ + interface ISetNhiKsmEffectiveDateRequest { + + /** SetNhiKsmEffectiveDateRequest effectiveDate */ + effectiveDate?: (number|null); + } + + /** Represents a SetNhiKsmEffectiveDateRequest. */ + class SetNhiKsmEffectiveDateRequest implements ISetNhiKsmEffectiveDateRequest { + + /** + * Constructs a new SetNhiKsmEffectiveDateRequest. + * @param [properties] Properties to set + */ + constructor(properties?: PAM.ISetNhiKsmEffectiveDateRequest); + + /** SetNhiKsmEffectiveDateRequest effectiveDate. */ + public effectiveDate: number; + + /** + * Creates a new SetNhiKsmEffectiveDateRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns SetNhiKsmEffectiveDateRequest instance + */ + public static create(properties?: PAM.ISetNhiKsmEffectiveDateRequest): PAM.SetNhiKsmEffectiveDateRequest; + + /** + * Encodes the specified SetNhiKsmEffectiveDateRequest message. Does not implicitly {@link PAM.SetNhiKsmEffectiveDateRequest.verify|verify} messages. + * @param message SetNhiKsmEffectiveDateRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: PAM.ISetNhiKsmEffectiveDateRequest, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a SetNhiKsmEffectiveDateRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns SetNhiKsmEffectiveDateRequest + * @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): PAM.SetNhiKsmEffectiveDateRequest; + + /** + * Creates a SetNhiKsmEffectiveDateRequest message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns SetNhiKsmEffectiveDateRequest + */ + public static fromObject(object: { [k: string]: any }): PAM.SetNhiKsmEffectiveDateRequest; + + /** + * Creates a plain object from a SetNhiKsmEffectiveDateRequest message. Also converts values to other types if specified. + * @param message SetNhiKsmEffectiveDateRequest + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: PAM.SetNhiKsmEffectiveDateRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this SetNhiKsmEffectiveDateRequest to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for SetNhiKsmEffectiveDateRequest + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a GetNhiKsmEffectiveDateResponse. */ + interface IGetNhiKsmEffectiveDateResponse { + + /** GetNhiKsmEffectiveDateResponse effectiveDate */ + effectiveDate?: (number|null); + + /** GetNhiKsmEffectiveDateResponse defaultDate */ + defaultDate?: (number|null); + } + + /** Represents a GetNhiKsmEffectiveDateResponse. */ + class GetNhiKsmEffectiveDateResponse implements IGetNhiKsmEffectiveDateResponse { + + /** + * Constructs a new GetNhiKsmEffectiveDateResponse. + * @param [properties] Properties to set + */ + constructor(properties?: PAM.IGetNhiKsmEffectiveDateResponse); + + /** GetNhiKsmEffectiveDateResponse effectiveDate. */ + public effectiveDate: number; + + /** GetNhiKsmEffectiveDateResponse defaultDate. */ + public defaultDate: number; + + /** + * Creates a new GetNhiKsmEffectiveDateResponse instance using the specified properties. + * @param [properties] Properties to set + * @returns GetNhiKsmEffectiveDateResponse instance + */ + public static create(properties?: PAM.IGetNhiKsmEffectiveDateResponse): PAM.GetNhiKsmEffectiveDateResponse; + + /** + * Encodes the specified GetNhiKsmEffectiveDateResponse message. Does not implicitly {@link PAM.GetNhiKsmEffectiveDateResponse.verify|verify} messages. + * @param message GetNhiKsmEffectiveDateResponse message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: PAM.IGetNhiKsmEffectiveDateResponse, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a GetNhiKsmEffectiveDateResponse message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns GetNhiKsmEffectiveDateResponse + * @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): PAM.GetNhiKsmEffectiveDateResponse; + + /** + * Creates a GetNhiKsmEffectiveDateResponse message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns GetNhiKsmEffectiveDateResponse + */ + public static fromObject(object: { [k: string]: any }): PAM.GetNhiKsmEffectiveDateResponse; + + /** + * Creates a plain object from a GetNhiKsmEffectiveDateResponse message. Also converts values to other types if specified. + * @param message GetNhiKsmEffectiveDateResponse + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: PAM.GetNhiKsmEffectiveDateResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this GetNhiKsmEffectiveDateResponse to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for GetNhiKsmEffectiveDateResponse + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a PAMUniversalSyncPreCheckRequest. */ + interface IPAMUniversalSyncPreCheckRequest { + + /** PAMUniversalSyncPreCheckRequest networkUid */ + networkUid?: (Uint8Array|null); + + /** PAMUniversalSyncPreCheckRequest folderUids */ + folderUids?: (Uint8Array[]|null); + } + + /** Represents a PAMUniversalSyncPreCheckRequest. */ + class PAMUniversalSyncPreCheckRequest implements IPAMUniversalSyncPreCheckRequest { + + /** + * Constructs a new PAMUniversalSyncPreCheckRequest. + * @param [properties] Properties to set + */ + constructor(properties?: PAM.IPAMUniversalSyncPreCheckRequest); + + /** PAMUniversalSyncPreCheckRequest networkUid. */ + public networkUid: Uint8Array; + + /** PAMUniversalSyncPreCheckRequest folderUids. */ + public folderUids: Uint8Array[]; + + /** + * Creates a new PAMUniversalSyncPreCheckRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns PAMUniversalSyncPreCheckRequest instance + */ + public static create(properties?: PAM.IPAMUniversalSyncPreCheckRequest): PAM.PAMUniversalSyncPreCheckRequest; + + /** + * Encodes the specified PAMUniversalSyncPreCheckRequest message. Does not implicitly {@link PAM.PAMUniversalSyncPreCheckRequest.verify|verify} messages. + * @param message PAMUniversalSyncPreCheckRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: PAM.IPAMUniversalSyncPreCheckRequest, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a PAMUniversalSyncPreCheckRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns PAMUniversalSyncPreCheckRequest + * @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): PAM.PAMUniversalSyncPreCheckRequest; + + /** + * Creates a PAMUniversalSyncPreCheckRequest message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns GetNhiUidsResponse + * @returns PAMUniversalSyncPreCheckRequest */ - public static fromObject(object: { [k: string]: any }): PAM.GetNhiUidsResponse; + public static fromObject(object: { [k: string]: any }): PAM.PAMUniversalSyncPreCheckRequest; /** - * Creates a plain object from a GetNhiUidsResponse message. Also converts values to other types if specified. - * @param message GetNhiUidsResponse + * Creates a plain object from a PAMUniversalSyncPreCheckRequest message. Also converts values to other types if specified. + * @param message PAMUniversalSyncPreCheckRequest * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: PAM.GetNhiUidsResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: PAM.PAMUniversalSyncPreCheckRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this GetNhiUidsResponse to JSON. + * Converts this PAMUniversalSyncPreCheckRequest to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for GetNhiUidsResponse + * Gets the default type url for PAMUniversalSyncPreCheckRequest * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a SetNhiKsmEffectiveDateRequest. */ - interface ISetNhiKsmEffectiveDateRequest { + /** Properties of a PAMUniversalSyncPreCheckResult. */ + interface IPAMUniversalSyncPreCheckResult { - /** SetNhiKsmEffectiveDateRequest effectiveDate */ - effectiveDate?: (number|null); + /** PAMUniversalSyncPreCheckResult folderUid */ + folderUid?: (Uint8Array|null); + + /** PAMUniversalSyncPreCheckResult isUsed */ + isUsed?: (boolean|null); } - /** Represents a SetNhiKsmEffectiveDateRequest. */ - class SetNhiKsmEffectiveDateRequest implements ISetNhiKsmEffectiveDateRequest { + /** Represents a PAMUniversalSyncPreCheckResult. */ + class PAMUniversalSyncPreCheckResult implements IPAMUniversalSyncPreCheckResult { /** - * Constructs a new SetNhiKsmEffectiveDateRequest. + * Constructs a new PAMUniversalSyncPreCheckResult. * @param [properties] Properties to set */ - constructor(properties?: PAM.ISetNhiKsmEffectiveDateRequest); + constructor(properties?: PAM.IPAMUniversalSyncPreCheckResult); - /** SetNhiKsmEffectiveDateRequest effectiveDate. */ - public effectiveDate: number; + /** PAMUniversalSyncPreCheckResult folderUid. */ + public folderUid: Uint8Array; + + /** PAMUniversalSyncPreCheckResult isUsed. */ + public isUsed: boolean; /** - * Creates a new SetNhiKsmEffectiveDateRequest instance using the specified properties. + * Creates a new PAMUniversalSyncPreCheckResult instance using the specified properties. * @param [properties] Properties to set - * @returns SetNhiKsmEffectiveDateRequest instance + * @returns PAMUniversalSyncPreCheckResult instance */ - public static create(properties?: PAM.ISetNhiKsmEffectiveDateRequest): PAM.SetNhiKsmEffectiveDateRequest; + public static create(properties?: PAM.IPAMUniversalSyncPreCheckResult): PAM.PAMUniversalSyncPreCheckResult; /** - * Encodes the specified SetNhiKsmEffectiveDateRequest message. Does not implicitly {@link PAM.SetNhiKsmEffectiveDateRequest.verify|verify} messages. - * @param message SetNhiKsmEffectiveDateRequest message or plain object to encode + * Encodes the specified PAMUniversalSyncPreCheckResult message. Does not implicitly {@link PAM.PAMUniversalSyncPreCheckResult.verify|verify} messages. + * @param message PAMUniversalSyncPreCheckResult message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: PAM.ISetNhiKsmEffectiveDateRequest, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: PAM.IPAMUniversalSyncPreCheckResult, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a SetNhiKsmEffectiveDateRequest message from the specified reader or buffer. + * Decodes a PAMUniversalSyncPreCheckResult message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns SetNhiKsmEffectiveDateRequest + * @returns PAMUniversalSyncPreCheckResult * @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): PAM.SetNhiKsmEffectiveDateRequest; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): PAM.PAMUniversalSyncPreCheckResult; /** - * Creates a SetNhiKsmEffectiveDateRequest message from a plain object. Also converts values to their respective internal types. + * Creates a PAMUniversalSyncPreCheckResult message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns SetNhiKsmEffectiveDateRequest + * @returns PAMUniversalSyncPreCheckResult */ - public static fromObject(object: { [k: string]: any }): PAM.SetNhiKsmEffectiveDateRequest; + public static fromObject(object: { [k: string]: any }): PAM.PAMUniversalSyncPreCheckResult; /** - * Creates a plain object from a SetNhiKsmEffectiveDateRequest message. Also converts values to other types if specified. - * @param message SetNhiKsmEffectiveDateRequest + * Creates a plain object from a PAMUniversalSyncPreCheckResult message. Also converts values to other types if specified. + * @param message PAMUniversalSyncPreCheckResult * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: PAM.SetNhiKsmEffectiveDateRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: PAM.PAMUniversalSyncPreCheckResult, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this SetNhiKsmEffectiveDateRequest to JSON. + * Converts this PAMUniversalSyncPreCheckResult to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for SetNhiKsmEffectiveDateRequest + * Gets the default type url for PAMUniversalSyncPreCheckResult * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a GetNhiKsmEffectiveDateResponse. */ - interface IGetNhiKsmEffectiveDateResponse { - - /** GetNhiKsmEffectiveDateResponse effectiveDate */ - effectiveDate?: (number|null); + /** Properties of a PAMUniversalSyncPreCheckResponse. */ + interface IPAMUniversalSyncPreCheckResponse { - /** GetNhiKsmEffectiveDateResponse defaultDate */ - defaultDate?: (number|null); + /** PAMUniversalSyncPreCheckResponse results */ + results?: (PAM.IPAMUniversalSyncPreCheckResult[]|null); } - /** Represents a GetNhiKsmEffectiveDateResponse. */ - class GetNhiKsmEffectiveDateResponse implements IGetNhiKsmEffectiveDateResponse { + /** Represents a PAMUniversalSyncPreCheckResponse. */ + class PAMUniversalSyncPreCheckResponse implements IPAMUniversalSyncPreCheckResponse { /** - * Constructs a new GetNhiKsmEffectiveDateResponse. + * Constructs a new PAMUniversalSyncPreCheckResponse. * @param [properties] Properties to set */ - constructor(properties?: PAM.IGetNhiKsmEffectiveDateResponse); - - /** GetNhiKsmEffectiveDateResponse effectiveDate. */ - public effectiveDate: number; + constructor(properties?: PAM.IPAMUniversalSyncPreCheckResponse); - /** GetNhiKsmEffectiveDateResponse defaultDate. */ - public defaultDate: number; + /** PAMUniversalSyncPreCheckResponse results. */ + public results: PAM.IPAMUniversalSyncPreCheckResult[]; /** - * Creates a new GetNhiKsmEffectiveDateResponse instance using the specified properties. + * Creates a new PAMUniversalSyncPreCheckResponse instance using the specified properties. * @param [properties] Properties to set - * @returns GetNhiKsmEffectiveDateResponse instance + * @returns PAMUniversalSyncPreCheckResponse instance */ - public static create(properties?: PAM.IGetNhiKsmEffectiveDateResponse): PAM.GetNhiKsmEffectiveDateResponse; + public static create(properties?: PAM.IPAMUniversalSyncPreCheckResponse): PAM.PAMUniversalSyncPreCheckResponse; /** - * Encodes the specified GetNhiKsmEffectiveDateResponse message. Does not implicitly {@link PAM.GetNhiKsmEffectiveDateResponse.verify|verify} messages. - * @param message GetNhiKsmEffectiveDateResponse message or plain object to encode + * Encodes the specified PAMUniversalSyncPreCheckResponse message. Does not implicitly {@link PAM.PAMUniversalSyncPreCheckResponse.verify|verify} messages. + * @param message PAMUniversalSyncPreCheckResponse message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: PAM.IGetNhiKsmEffectiveDateResponse, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: PAM.IPAMUniversalSyncPreCheckResponse, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a GetNhiKsmEffectiveDateResponse message from the specified reader or buffer. + * Decodes a PAMUniversalSyncPreCheckResponse message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns GetNhiKsmEffectiveDateResponse + * @returns PAMUniversalSyncPreCheckResponse * @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): PAM.GetNhiKsmEffectiveDateResponse; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): PAM.PAMUniversalSyncPreCheckResponse; /** - * Creates a GetNhiKsmEffectiveDateResponse message from a plain object. Also converts values to their respective internal types. + * Creates a PAMUniversalSyncPreCheckResponse message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns GetNhiKsmEffectiveDateResponse + * @returns PAMUniversalSyncPreCheckResponse */ - public static fromObject(object: { [k: string]: any }): PAM.GetNhiKsmEffectiveDateResponse; + public static fromObject(object: { [k: string]: any }): PAM.PAMUniversalSyncPreCheckResponse; /** - * Creates a plain object from a GetNhiKsmEffectiveDateResponse message. Also converts values to other types if specified. - * @param message GetNhiKsmEffectiveDateResponse + * Creates a plain object from a PAMUniversalSyncPreCheckResponse message. Also converts values to other types if specified. + * @param message PAMUniversalSyncPreCheckResponse * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: PAM.GetNhiKsmEffectiveDateResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: PAM.PAMUniversalSyncPreCheckResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this GetNhiKsmEffectiveDateResponse to JSON. + * Converts this PAMUniversalSyncPreCheckResponse to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; - /** - * Gets the default type url for GetNhiKsmEffectiveDateResponse - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Gets the default type url for PAMUniversalSyncPreCheckResponse + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } +} + +/** Namespace folder. */ +export namespace folder { + + /** Namespace v3. */ + namespace v3 { + + /** Namespace remove. */ + namespace remove { + + /** Represents a RemoveService */ + class RemoveService extends $protobuf.rpc.Service { + + /** + * Constructs a new RemoveService service. + * @param rpcImpl RPC implementation + * @param [requestDelimited=false] Whether requests are length-delimited + * @param [responseDelimited=false] Whether responses are length-delimited + */ + constructor(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean); + + /** + * Creates new RemoveService service using the specified rpc implementation. + * @param rpcImpl RPC implementation + * @param [requestDelimited=false] Whether requests are length-delimited + * @param [responseDelimited=false] Whether responses are length-delimited + * @returns RPC service. Useful where requests and/or responses are streamed. + */ + public static create(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean): RemoveService; + + /** + * Preview or execute record removal from folders. + * PREVIEW: Computes impact metrics and returns a signed confirmation token. + * CONFIRM: Validates token and executes the removal operation. + * @param request RemoveRecordRequest message or plain object + * @param callback Node-style callback called with the error, if any, and RemoveResponse + */ + public removeRecord(request: folder.v3.remove.IRemoveRecordRequest, callback: folder.v3.remove.RemoveService.RemoveRecordCallback): void; + + /** + * Preview or execute record removal from folders. + * PREVIEW: Computes impact metrics and returns a signed confirmation token. + * CONFIRM: Validates token and executes the removal operation. + * @param request RemoveRecordRequest message or plain object + * @returns Promise + */ + public removeRecord(request: folder.v3.remove.IRemoveRecordRequest): Promise; + + /** + * Preview or execute folder deletion. + * PREVIEW: Computes impact metrics and returns a signed confirmation token. + * CONFIRM: Validates token and executes the deletion operation. + * @param request RemoveFolderRequest message or plain object + * @param callback Node-style callback called with the error, if any, and RemoveResponse + */ + public removeFolder(request: folder.v3.remove.IRemoveFolderRequest, callback: folder.v3.remove.RemoveService.RemoveFolderCallback): void; + + /** + * Preview or execute folder deletion. + * PREVIEW: Computes impact metrics and returns a signed confirmation token. + * CONFIRM: Validates token and executes the deletion operation. + * @param request RemoveFolderRequest message or plain object + * @returns Promise + */ + public removeFolder(request: folder.v3.remove.IRemoveFolderRequest): Promise; + + /** + * Restore records and/or folders from the caller's trashcan into a target folder (KA-8144). + * Each input item is validated independently; failures are reported per-item via + * TrashcanRestoreResponse.results — a failed item does not poison the batch. + * @param request TrashcanRestoreRequest message or plain object + * @param callback Node-style callback called with the error, if any, and TrashcanRestoreResponse + */ + public trashcanRestore(request: folder.v3.remove.ITrashcanRestoreRequest, callback: folder.v3.remove.RemoveService.TrashcanRestoreCallback): void; + + /** + * Restore records and/or folders from the caller's trashcan into a target folder (KA-8144). + * Each input item is validated independently; failures are reported per-item via + * TrashcanRestoreResponse.results — a failed item does not poison the batch. + * @param request TrashcanRestoreRequest message or plain object + * @returns Promise + */ + public trashcanRestore(request: folder.v3.remove.ITrashcanRestoreRequest): Promise; + } + + namespace RemoveService { + + /** + * Callback as used by {@link folder.v3.remove.RemoveService#removeRecord}. + * @param error Error, if any + * @param [response] RemoveResponse + */ + type RemoveRecordCallback = (error: (Error|null), response?: folder.v3.remove.RemoveResponse) => void; + + /** + * Callback as used by {@link folder.v3.remove.RemoveService#removeFolder}. + * @param error Error, if any + * @param [response] RemoveResponse + */ + type RemoveFolderCallback = (error: (Error|null), response?: folder.v3.remove.RemoveResponse) => void; + + /** + * Callback as used by {@link folder.v3.remove.RemoveService#trashcanRestore}. + * @param error Error, if any + * @param [response] TrashcanRestoreResponse + */ + type TrashcanRestoreCallback = (error: (Error|null), response?: folder.v3.remove.TrashcanRestoreResponse) => void; + } + + /** RemoveAction enum. */ + enum RemoveAction { + REMOVE_ACTION_PREVIEW = 0, + REMOVE_ACTION_CONFIRM = 1 + } + + /** RecordOperationType enum. */ + enum RecordOperationType { + RECORD_OPERATION_UNKNOWN = 0, + UNLINK_FROM_FOLDER = 1, + MOVE_TO_FOLDER_TRASH = 2, + MOVE_TO_OWNER_TRASH = 3 + } - /** Properties of a PAMUniversalSyncPreCheckRequest. */ - interface IPAMUniversalSyncPreCheckRequest { + /** FolderOperationType enum. */ + enum FolderOperationType { + FOLDER_OPERATION_UNKNOWN = 0, + FOLDER_MOVE_TO_FOLDER_TRASH = 1, + FOLDER_MOVE_TO_OWNER_TRASH = 2, + FOLDER_DELETE_PERMANENT = 3 + } - /** PAMUniversalSyncPreCheckRequest networkUid */ - networkUid?: (Uint8Array|null); + /** RemoveErrorCode enum. */ + enum RemoveErrorCode { + REMOVE_ERROR_UNKNOWN = 0, + REMOVE_ERROR_NOT_FOUND = 1, + REMOVE_ERROR_ACCESS_DENIED = 2, + REMOVE_ERROR_TRASHCAN_FOLDER = 3, + REMOVE_ERROR_ROOT_FOLDER = 4, + REMOVE_ERROR_DESCENDANT_DENIED = 5 + } - /** PAMUniversalSyncPreCheckRequest folderUids */ - folderUids?: (Uint8Array[]|null); - } + /** RemoveStatus enum. */ + enum RemoveStatus { + REMOVE_STATUS_UNKNOWN = 0, + REMOVE_STATUS_SUCCESS = 1, + REMOVE_STATUS_STALE_PREVIEW = 2, + REMOVE_STATUS_TOKEN_EXPIRED = 3, + REMOVE_STATUS_TOKEN_INVALID = 4, + REMOVE_STATUS_ACCESS_DENIED = 5, + REMOVE_STATUS_VALIDATION_ERROR = 6 + } - /** Represents a PAMUniversalSyncPreCheckRequest. */ - class PAMUniversalSyncPreCheckRequest implements IPAMUniversalSyncPreCheckRequest { + /** Properties of a RecordRemoval. */ + interface IRecordRemoval { - /** - * Constructs a new PAMUniversalSyncPreCheckRequest. - * @param [properties] Properties to set - */ - constructor(properties?: PAM.IPAMUniversalSyncPreCheckRequest); + /** RecordRemoval folderUid */ + folderUid?: (Uint8Array|null); - /** PAMUniversalSyncPreCheckRequest networkUid. */ - public networkUid: Uint8Array; + /** RecordRemoval recordUid */ + recordUid?: (Uint8Array|null); - /** PAMUniversalSyncPreCheckRequest folderUids. */ - public folderUids: Uint8Array[]; + /** RecordRemoval operationType */ + operationType?: (folder.v3.remove.RecordOperationType|null); + } - /** - * Creates a new PAMUniversalSyncPreCheckRequest instance using the specified properties. - * @param [properties] Properties to set - * @returns PAMUniversalSyncPreCheckRequest instance - */ - public static create(properties?: PAM.IPAMUniversalSyncPreCheckRequest): PAM.PAMUniversalSyncPreCheckRequest; + /** Represents a RecordRemoval. */ + class RecordRemoval implements IRecordRemoval { - /** - * Encodes the specified PAMUniversalSyncPreCheckRequest message. Does not implicitly {@link PAM.PAMUniversalSyncPreCheckRequest.verify|verify} messages. - * @param message PAMUniversalSyncPreCheckRequest message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: PAM.IPAMUniversalSyncPreCheckRequest, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Constructs a new RecordRemoval. + * @param [properties] Properties to set + */ + constructor(properties?: folder.v3.remove.IRecordRemoval); - /** - * Decodes a PAMUniversalSyncPreCheckRequest message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns PAMUniversalSyncPreCheckRequest - * @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): PAM.PAMUniversalSyncPreCheckRequest; + /** RecordRemoval folderUid. */ + public folderUid: Uint8Array; - /** - * Creates a PAMUniversalSyncPreCheckRequest message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns PAMUniversalSyncPreCheckRequest - */ - public static fromObject(object: { [k: string]: any }): PAM.PAMUniversalSyncPreCheckRequest; + /** RecordRemoval recordUid. */ + public recordUid: Uint8Array; - /** - * Creates a plain object from a PAMUniversalSyncPreCheckRequest message. Also converts values to other types if specified. - * @param message PAMUniversalSyncPreCheckRequest - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: PAM.PAMUniversalSyncPreCheckRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** RecordRemoval operationType. */ + public operationType: folder.v3.remove.RecordOperationType; - /** - * Converts this PAMUniversalSyncPreCheckRequest to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Creates a new RecordRemoval instance using the specified properties. + * @param [properties] Properties to set + * @returns RecordRemoval instance + */ + public static create(properties?: folder.v3.remove.IRecordRemoval): folder.v3.remove.RecordRemoval; - /** - * Gets the default type url for PAMUniversalSyncPreCheckRequest - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Encodes the specified RecordRemoval message. Does not implicitly {@link folder.v3.remove.RecordRemoval.verify|verify} messages. + * @param message RecordRemoval message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: folder.v3.remove.IRecordRemoval, writer?: $protobuf.Writer): $protobuf.Writer; - /** Properties of a PAMUniversalSyncPreCheckResult. */ - interface IPAMUniversalSyncPreCheckResult { + /** + * Decodes a RecordRemoval message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RecordRemoval + * @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): folder.v3.remove.RecordRemoval; - /** PAMUniversalSyncPreCheckResult folderUid */ - folderUid?: (Uint8Array|null); + /** + * Creates a RecordRemoval message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RecordRemoval + */ + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RecordRemoval; - /** PAMUniversalSyncPreCheckResult isUsed */ - isUsed?: (boolean|null); - } + /** + * Creates a plain object from a RecordRemoval message. Also converts values to other types if specified. + * @param message RecordRemoval + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: folder.v3.remove.RecordRemoval, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** Represents a PAMUniversalSyncPreCheckResult. */ - class PAMUniversalSyncPreCheckResult implements IPAMUniversalSyncPreCheckResult { + /** + * Converts this RecordRemoval to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** - * Constructs a new PAMUniversalSyncPreCheckResult. - * @param [properties] Properties to set - */ - constructor(properties?: PAM.IPAMUniversalSyncPreCheckResult); + /** + * Gets the default type url for RecordRemoval + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** PAMUniversalSyncPreCheckResult folderUid. */ - public folderUid: Uint8Array; + /** Properties of a FolderRemoval. */ + interface IFolderRemoval { - /** PAMUniversalSyncPreCheckResult isUsed. */ - public isUsed: boolean; + /** FolderRemoval folderUid */ + folderUid?: (Uint8Array|null); - /** - * Creates a new PAMUniversalSyncPreCheckResult instance using the specified properties. - * @param [properties] Properties to set - * @returns PAMUniversalSyncPreCheckResult instance - */ - public static create(properties?: PAM.IPAMUniversalSyncPreCheckResult): PAM.PAMUniversalSyncPreCheckResult; + /** FolderRemoval operationType */ + operationType?: (folder.v3.remove.FolderOperationType|null); + } - /** - * Encodes the specified PAMUniversalSyncPreCheckResult message. Does not implicitly {@link PAM.PAMUniversalSyncPreCheckResult.verify|verify} messages. - * @param message PAMUniversalSyncPreCheckResult message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: PAM.IPAMUniversalSyncPreCheckResult, writer?: $protobuf.Writer): $protobuf.Writer; + /** Represents a FolderRemoval. */ + class FolderRemoval implements IFolderRemoval { - /** - * Decodes a PAMUniversalSyncPreCheckResult message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns PAMUniversalSyncPreCheckResult - * @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): PAM.PAMUniversalSyncPreCheckResult; + /** + * Constructs a new FolderRemoval. + * @param [properties] Properties to set + */ + constructor(properties?: folder.v3.remove.IFolderRemoval); - /** - * Creates a PAMUniversalSyncPreCheckResult message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns PAMUniversalSyncPreCheckResult - */ - public static fromObject(object: { [k: string]: any }): PAM.PAMUniversalSyncPreCheckResult; + /** FolderRemoval folderUid. */ + public folderUid: Uint8Array; - /** - * Creates a plain object from a PAMUniversalSyncPreCheckResult message. Also converts values to other types if specified. - * @param message PAMUniversalSyncPreCheckResult - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: PAM.PAMUniversalSyncPreCheckResult, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** FolderRemoval operationType. */ + public operationType: folder.v3.remove.FolderOperationType; - /** - * Converts this PAMUniversalSyncPreCheckResult to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Creates a new FolderRemoval instance using the specified properties. + * @param [properties] Properties to set + * @returns FolderRemoval instance + */ + public static create(properties?: folder.v3.remove.IFolderRemoval): folder.v3.remove.FolderRemoval; - /** - * Gets the default type url for PAMUniversalSyncPreCheckResult - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Encodes the specified FolderRemoval message. Does not implicitly {@link folder.v3.remove.FolderRemoval.verify|verify} messages. + * @param message FolderRemoval message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: folder.v3.remove.IFolderRemoval, writer?: $protobuf.Writer): $protobuf.Writer; - /** Properties of a PAMUniversalSyncPreCheckResponse. */ - interface IPAMUniversalSyncPreCheckResponse { + /** + * Decodes a FolderRemoval message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns FolderRemoval + * @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): folder.v3.remove.FolderRemoval; - /** PAMUniversalSyncPreCheckResponse results */ - results?: (PAM.IPAMUniversalSyncPreCheckResult[]|null); - } + /** + * Creates a FolderRemoval message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns FolderRemoval + */ + public static fromObject(object: { [k: string]: any }): folder.v3.remove.FolderRemoval; - /** Represents a PAMUniversalSyncPreCheckResponse. */ - class PAMUniversalSyncPreCheckResponse implements IPAMUniversalSyncPreCheckResponse { + /** + * Creates a plain object from a FolderRemoval message. Also converts values to other types if specified. + * @param message FolderRemoval + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: folder.v3.remove.FolderRemoval, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** - * Constructs a new PAMUniversalSyncPreCheckResponse. - * @param [properties] Properties to set - */ - constructor(properties?: PAM.IPAMUniversalSyncPreCheckResponse); + /** + * Converts this FolderRemoval to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** PAMUniversalSyncPreCheckResponse results. */ - public results: PAM.IPAMUniversalSyncPreCheckResult[]; + /** + * Gets the default type url for FolderRemoval + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** - * Creates a new PAMUniversalSyncPreCheckResponse instance using the specified properties. - * @param [properties] Properties to set - * @returns PAMUniversalSyncPreCheckResponse instance - */ - public static create(properties?: PAM.IPAMUniversalSyncPreCheckResponse): PAM.PAMUniversalSyncPreCheckResponse; + /** Properties of a RemoveRecordRequest. */ + interface IRemoveRecordRequest { - /** - * Encodes the specified PAMUniversalSyncPreCheckResponse message. Does not implicitly {@link PAM.PAMUniversalSyncPreCheckResponse.verify|verify} messages. - * @param message PAMUniversalSyncPreCheckResponse message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: PAM.IPAMUniversalSyncPreCheckResponse, writer?: $protobuf.Writer): $protobuf.Writer; + /** RemoveRecordRequest action */ + action?: (folder.v3.remove.RemoveAction|null); - /** - * Decodes a PAMUniversalSyncPreCheckResponse message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns PAMUniversalSyncPreCheckResponse - * @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): PAM.PAMUniversalSyncPreCheckResponse; + /** RemoveRecordRequest records */ + records?: (folder.v3.remove.IRecordRemoval[]|null); - /** - * Creates a PAMUniversalSyncPreCheckResponse message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns PAMUniversalSyncPreCheckResponse - */ - public static fromObject(object: { [k: string]: any }): PAM.PAMUniversalSyncPreCheckResponse; + /** RemoveRecordRequest confirmationToken */ + confirmationToken?: (Uint8Array|null); + } - /** - * Creates a plain object from a PAMUniversalSyncPreCheckResponse message. Also converts values to other types if specified. - * @param message PAMUniversalSyncPreCheckResponse - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: PAM.PAMUniversalSyncPreCheckResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** Represents a RemoveRecordRequest. */ + class RemoveRecordRequest implements IRemoveRecordRequest { - /** - * Converts this PAMUniversalSyncPreCheckResponse to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Constructs a new RemoveRecordRequest. + * @param [properties] Properties to set + */ + constructor(properties?: folder.v3.remove.IRemoveRecordRequest); - /** - * Gets the default type url for PAMUniversalSyncPreCheckResponse - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } -} + /** RemoveRecordRequest action. */ + public action: folder.v3.remove.RemoveAction; -/** Namespace folder. */ -export namespace folder { + /** RemoveRecordRequest records. */ + public records: folder.v3.remove.IRecordRemoval[]; - /** Namespace v3. */ - namespace v3 { + /** RemoveRecordRequest confirmationToken. */ + public confirmationToken: Uint8Array; - /** Namespace remove. */ - namespace remove { + /** + * Creates a new RemoveRecordRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns RemoveRecordRequest instance + */ + public static create(properties?: folder.v3.remove.IRemoveRecordRequest): folder.v3.remove.RemoveRecordRequest; - /** Represents a RemoveService */ - class RemoveService extends $protobuf.rpc.Service { + /** + * Encodes the specified RemoveRecordRequest message. Does not implicitly {@link folder.v3.remove.RemoveRecordRequest.verify|verify} messages. + * @param message RemoveRecordRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: folder.v3.remove.IRemoveRecordRequest, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Constructs a new RemoveService service. - * @param rpcImpl RPC implementation - * @param [requestDelimited=false] Whether requests are length-delimited - * @param [responseDelimited=false] Whether responses are length-delimited + * Decodes a RemoveRecordRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RemoveRecordRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing */ - constructor(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean); + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RemoveRecordRequest; /** - * Creates new RemoveService service using the specified rpc implementation. - * @param rpcImpl RPC implementation - * @param [requestDelimited=false] Whether requests are length-delimited - * @param [responseDelimited=false] Whether responses are length-delimited - * @returns RPC service. Useful where requests and/or responses are streamed. + * Creates a RemoveRecordRequest message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RemoveRecordRequest */ - public static create(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean): RemoveService; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemoveRecordRequest; /** - * Preview or execute record removal from folders. - * PREVIEW: Computes impact metrics and returns a signed confirmation token. - * CONFIRM: Validates token and executes the removal operation. - * @param request RemoveRecordRequest message or plain object - * @param callback Node-style callback called with the error, if any, and RemoveResponse + * Creates a plain object from a RemoveRecordRequest message. Also converts values to other types if specified. + * @param message RemoveRecordRequest + * @param [options] Conversion options + * @returns Plain object */ - public removeRecord(request: folder.v3.remove.IRemoveRecordRequest, callback: folder.v3.remove.RemoveService.RemoveRecordCallback): void; + public static toObject(message: folder.v3.remove.RemoveRecordRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Preview or execute record removal from folders. - * PREVIEW: Computes impact metrics and returns a signed confirmation token. - * CONFIRM: Validates token and executes the removal operation. - * @param request RemoveRecordRequest message or plain object - * @returns Promise + * Converts this RemoveRecordRequest to JSON. + * @returns JSON object */ - public removeRecord(request: folder.v3.remove.IRemoveRecordRequest): Promise; + public toJSON(): { [k: string]: any }; /** - * Preview or execute folder deletion. - * PREVIEW: Computes impact metrics and returns a signed confirmation token. - * CONFIRM: Validates token and executes the deletion operation. - * @param request RemoveFolderRequest message or plain object - * @param callback Node-style callback called with the error, if any, and RemoveResponse + * Gets the default type url for RemoveRecordRequest + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url */ - public removeFolder(request: folder.v3.remove.IRemoveFolderRequest, callback: folder.v3.remove.RemoveService.RemoveFolderCallback): void; + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a RemoveFolderRequest. */ + interface IRemoveFolderRequest { + + /** RemoveFolderRequest action */ + action?: (folder.v3.remove.RemoveAction|null); + + /** RemoveFolderRequest folders */ + folders?: (folder.v3.remove.IFolderRemoval[]|null); + + /** RemoveFolderRequest confirmationToken */ + confirmationToken?: (Uint8Array|null); + } + + /** Represents a RemoveFolderRequest. */ + class RemoveFolderRequest implements IRemoveFolderRequest { /** - * Preview or execute folder deletion. - * PREVIEW: Computes impact metrics and returns a signed confirmation token. - * CONFIRM: Validates token and executes the deletion operation. - * @param request RemoveFolderRequest message or plain object - * @returns Promise + * Constructs a new RemoveFolderRequest. + * @param [properties] Properties to set */ - public removeFolder(request: folder.v3.remove.IRemoveFolderRequest): Promise; + constructor(properties?: folder.v3.remove.IRemoveFolderRequest); + + /** RemoveFolderRequest action. */ + public action: folder.v3.remove.RemoveAction; + + /** RemoveFolderRequest folders. */ + public folders: folder.v3.remove.IFolderRemoval[]; + + /** RemoveFolderRequest confirmationToken. */ + public confirmationToken: Uint8Array; /** - * Restore records and/or folders from the caller's trashcan into a target folder (KA-8144). - * Each input item is validated independently; failures are reported per-item via - * TrashcanRestoreResponse.results — a failed item does not poison the batch. - * @param request TrashcanRestoreRequest message or plain object - * @param callback Node-style callback called with the error, if any, and TrashcanRestoreResponse + * Creates a new RemoveFolderRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns RemoveFolderRequest instance + */ + public static create(properties?: folder.v3.remove.IRemoveFolderRequest): folder.v3.remove.RemoveFolderRequest; + + /** + * Encodes the specified RemoveFolderRequest message. Does not implicitly {@link folder.v3.remove.RemoveFolderRequest.verify|verify} messages. + * @param message RemoveFolderRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer */ - public trashcanRestore(request: folder.v3.remove.ITrashcanRestoreRequest, callback: folder.v3.remove.RemoveService.TrashcanRestoreCallback): void; + public static encode(message: folder.v3.remove.IRemoveFolderRequest, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Restore records and/or folders from the caller's trashcan into a target folder (KA-8144). - * Each input item is validated independently; failures are reported per-item via - * TrashcanRestoreResponse.results — a failed item does not poison the batch. - * @param request TrashcanRestoreRequest message or plain object - * @returns Promise + * Decodes a RemoveFolderRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns RemoveFolderRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing */ - public trashcanRestore(request: folder.v3.remove.ITrashcanRestoreRequest): Promise; - } - - namespace RemoveService { + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RemoveFolderRequest; /** - * Callback as used by {@link folder.v3.remove.RemoveService#removeRecord}. - * @param error Error, if any - * @param [response] RemoveResponse + * Creates a RemoveFolderRequest message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns RemoveFolderRequest */ - type RemoveRecordCallback = (error: (Error|null), response?: folder.v3.remove.RemoveResponse) => void; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemoveFolderRequest; /** - * Callback as used by {@link folder.v3.remove.RemoveService#removeFolder}. - * @param error Error, if any - * @param [response] RemoveResponse + * Creates a plain object from a RemoveFolderRequest message. Also converts values to other types if specified. + * @param message RemoveFolderRequest + * @param [options] Conversion options + * @returns Plain object */ - type RemoveFolderCallback = (error: (Error|null), response?: folder.v3.remove.RemoveResponse) => void; + public static toObject(message: folder.v3.remove.RemoveFolderRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Callback as used by {@link folder.v3.remove.RemoveService#trashcanRestore}. - * @param error Error, if any - * @param [response] TrashcanRestoreResponse + * Converts this RemoveFolderRequest to JSON. + * @returns JSON object */ - type TrashcanRestoreCallback = (error: (Error|null), response?: folder.v3.remove.TrashcanRestoreResponse) => void; - } - - /** RemoveAction enum. */ - enum RemoveAction { - REMOVE_ACTION_PREVIEW = 0, - REMOVE_ACTION_CONFIRM = 1 - } - - /** RecordOperationType enum. */ - enum RecordOperationType { - RECORD_OPERATION_UNKNOWN = 0, - UNLINK_FROM_FOLDER = 1, - MOVE_TO_FOLDER_TRASH = 2, - MOVE_TO_OWNER_TRASH = 3 - } - - /** FolderOperationType enum. */ - enum FolderOperationType { - FOLDER_OPERATION_UNKNOWN = 0, - FOLDER_MOVE_TO_FOLDER_TRASH = 1, - FOLDER_MOVE_TO_OWNER_TRASH = 2, - FOLDER_DELETE_PERMANENT = 3 - } + public toJSON(): { [k: string]: any }; - /** RemoveErrorCode enum. */ - enum RemoveErrorCode { - REMOVE_ERROR_UNKNOWN = 0, - REMOVE_ERROR_NOT_FOUND = 1, - REMOVE_ERROR_ACCESS_DENIED = 2, - REMOVE_ERROR_TRASHCAN_FOLDER = 3, - REMOVE_ERROR_ROOT_FOLDER = 4, - REMOVE_ERROR_DESCENDANT_DENIED = 5 + /** + * Gets the default type url for RemoveFolderRequest + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; } - /** RemoveStatus enum. */ - enum RemoveStatus { - REMOVE_STATUS_UNKNOWN = 0, - REMOVE_STATUS_SUCCESS = 1, - REMOVE_STATUS_STALE_PREVIEW = 2, - REMOVE_STATUS_TOKEN_EXPIRED = 3, - REMOVE_STATUS_TOKEN_INVALID = 4, - REMOVE_STATUS_ACCESS_DENIED = 5, - REMOVE_STATUS_VALIDATION_ERROR = 6 - } + /** Properties of a RemoveResponse. */ + interface IRemoveResponse { - /** Properties of a RecordRemoval. */ - interface IRecordRemoval { + /** RemoveResponse confirmationToken */ + confirmationToken?: (Uint8Array|null); - /** RecordRemoval folderUid */ - folderUid?: (Uint8Array|null); + /** RemoveResponse tokenExpiresAt */ + tokenExpiresAt?: (number|null); - /** RecordRemoval recordUid */ - recordUid?: (Uint8Array|null); + /** RemoveResponse results */ + results?: (folder.v3.remove.IRemoveResult[]|null); - /** RecordRemoval operationType */ - operationType?: (folder.v3.remove.RecordOperationType|null); + /** RemoveResponse errorMessage */ + errorMessage?: (string|null); } - /** Represents a RecordRemoval. */ - class RecordRemoval implements IRecordRemoval { + /** + * Response for remove operations (both record and folder). + * + * For PREVIEW: Contains confirmation_token and per-item results with impact. + * For CONFIRM: Contains per-item results with execution status. + */ + class RemoveResponse implements IRemoveResponse { /** - * Constructs a new RecordRemoval. + * Constructs a new RemoveResponse. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRecordRemoval); + constructor(properties?: folder.v3.remove.IRemoveResponse); - /** RecordRemoval folderUid. */ - public folderUid: Uint8Array; + /** RemoveResponse confirmationToken. */ + public confirmationToken: Uint8Array; - /** RecordRemoval recordUid. */ - public recordUid: Uint8Array; + /** RemoveResponse tokenExpiresAt. */ + public tokenExpiresAt: number; - /** RecordRemoval operationType. */ - public operationType: folder.v3.remove.RecordOperationType; + /** RemoveResponse results. */ + public results: folder.v3.remove.IRemoveResult[]; + + /** RemoveResponse errorMessage. */ + public errorMessage: string; /** - * Creates a new RecordRemoval instance using the specified properties. + * Creates a new RemoveResponse instance using the specified properties. * @param [properties] Properties to set - * @returns RecordRemoval instance + * @returns RemoveResponse instance */ - public static create(properties?: folder.v3.remove.IRecordRemoval): folder.v3.remove.RecordRemoval; + public static create(properties?: folder.v3.remove.IRemoveResponse): folder.v3.remove.RemoveResponse; /** - * Encodes the specified RecordRemoval message. Does not implicitly {@link folder.v3.remove.RecordRemoval.verify|verify} messages. - * @param message RecordRemoval message or plain object to encode + * Encodes the specified RemoveResponse message. Does not implicitly {@link folder.v3.remove.RemoveResponse.verify|verify} messages. + * @param message RemoveResponse message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRecordRemoval, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IRemoveResponse, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RecordRemoval message from the specified reader or buffer. + * Decodes a RemoveResponse message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RecordRemoval + * @returns RemoveResponse * @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): folder.v3.remove.RecordRemoval; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RemoveResponse; /** - * Creates a RecordRemoval message from a plain object. Also converts values to their respective internal types. + * Creates a RemoveResponse message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RecordRemoval + * @returns RemoveResponse */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RecordRemoval; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemoveResponse; /** - * Creates a plain object from a RecordRemoval message. Also converts values to other types if specified. - * @param message RecordRemoval + * Creates a plain object from a RemoveResponse message. Also converts values to other types if specified. + * @param message RemoveResponse * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RecordRemoval, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.RemoveResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RecordRemoval to JSON. + * Converts this RemoveResponse to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RecordRemoval + * Gets the default type url for RemoveResponse * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a FolderRemoval. */ - interface IFolderRemoval { + /** Properties of a RemoveResult. */ + interface IRemoveResult { - /** FolderRemoval folderUid */ + /** RemoveResult itemUid */ + itemUid?: (Uint8Array|null); + + /** RemoveResult folderUid */ folderUid?: (Uint8Array|null); - /** FolderRemoval operationType */ - operationType?: (folder.v3.remove.FolderOperationType|null); + /** RemoveResult status */ + status?: (folder.v3.remove.RemoveStatus|null); + + /** RemoveResult impact */ + impact?: (folder.v3.remove.IImpact|null); + + /** RemoveResult error */ + error?: (folder.v3.remove.IItemError|null); } - /** Represents a FolderRemoval. */ - class FolderRemoval implements IFolderRemoval { + /** Per-item result for a single record or folder. */ + class RemoveResult implements IRemoveResult { /** - * Constructs a new FolderRemoval. + * Constructs a new RemoveResult. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IFolderRemoval); + constructor(properties?: folder.v3.remove.IRemoveResult); - /** FolderRemoval folderUid. */ + /** RemoveResult itemUid. */ + public itemUid: Uint8Array; + + /** RemoveResult folderUid. */ public folderUid: Uint8Array; - /** FolderRemoval operationType. */ - public operationType: folder.v3.remove.FolderOperationType; + /** RemoveResult status. */ + public status: folder.v3.remove.RemoveStatus; + + /** RemoveResult impact. */ + public impact?: (folder.v3.remove.IImpact|null); + + /** RemoveResult error. */ + public error?: (folder.v3.remove.IItemError|null); /** - * Creates a new FolderRemoval instance using the specified properties. + * Creates a new RemoveResult instance using the specified properties. * @param [properties] Properties to set - * @returns FolderRemoval instance + * @returns RemoveResult instance */ - public static create(properties?: folder.v3.remove.IFolderRemoval): folder.v3.remove.FolderRemoval; + public static create(properties?: folder.v3.remove.IRemoveResult): folder.v3.remove.RemoveResult; /** - * Encodes the specified FolderRemoval message. Does not implicitly {@link folder.v3.remove.FolderRemoval.verify|verify} messages. - * @param message FolderRemoval message or plain object to encode + * Encodes the specified RemoveResult message. Does not implicitly {@link folder.v3.remove.RemoveResult.verify|verify} messages. + * @param message RemoveResult message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IFolderRemoval, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IRemoveResult, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a FolderRemoval message from the specified reader or buffer. + * Decodes a RemoveResult message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns FolderRemoval + * @returns RemoveResult * @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): folder.v3.remove.FolderRemoval; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RemoveResult; /** - * Creates a FolderRemoval message from a plain object. Also converts values to their respective internal types. + * Creates a RemoveResult message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns FolderRemoval + * @returns RemoveResult */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.FolderRemoval; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemoveResult; /** - * Creates a plain object from a FolderRemoval message. Also converts values to other types if specified. - * @param message FolderRemoval + * Creates a plain object from a RemoveResult message. Also converts values to other types if specified. + * @param message RemoveResult * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.FolderRemoval, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.RemoveResult, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this FolderRemoval to JSON. + * Converts this RemoveResult to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for FolderRemoval + * Gets the default type url for RemoveResult * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a RemoveRecordRequest. */ - interface IRemoveRecordRequest { + /** Properties of an Impact. */ + interface IImpact { + + /** Impact foldersCount */ + foldersCount?: (number|null); + + /** Impact recordsCount */ + recordsCount?: (number|null); - /** RemoveRecordRequest action */ - action?: (folder.v3.remove.RemoveAction|null); + /** Impact affectedUsersCount */ + affectedUsersCount?: (number|null); - /** RemoveRecordRequest records */ - records?: (folder.v3.remove.IRecordRemoval[]|null); + /** Impact affectedTeamsCount */ + affectedTeamsCount?: (number|null); - /** RemoveRecordRequest confirmationToken */ - confirmationToken?: (Uint8Array|null); + /** Impact recordInfo */ + recordInfo?: (folder.v3.remove.IRecordInfo[]|null); + + /** Impact warnings */ + warnings?: (string[]|null); } - /** Represents a RemoveRecordRequest. */ - class RemoveRecordRequest implements IRemoveRecordRequest { + /** Impact metrics for a single item (record or folder tree). */ + class Impact implements IImpact { /** - * Constructs a new RemoveRecordRequest. + * Constructs a new Impact. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRemoveRecordRequest); + constructor(properties?: folder.v3.remove.IImpact); - /** RemoveRecordRequest action. */ - public action: folder.v3.remove.RemoveAction; + /** Impact foldersCount. */ + public foldersCount: number; - /** RemoveRecordRequest records. */ - public records: folder.v3.remove.IRecordRemoval[]; + /** Impact recordsCount. */ + public recordsCount: number; - /** RemoveRecordRequest confirmationToken. */ - public confirmationToken: Uint8Array; + /** Impact affectedUsersCount. */ + public affectedUsersCount: number; + + /** Impact affectedTeamsCount. */ + public affectedTeamsCount: number; + + /** Impact recordInfo. */ + public recordInfo: folder.v3.remove.IRecordInfo[]; + + /** Impact warnings. */ + public warnings: string[]; /** - * Creates a new RemoveRecordRequest instance using the specified properties. + * Creates a new Impact instance using the specified properties. * @param [properties] Properties to set - * @returns RemoveRecordRequest instance + * @returns Impact instance */ - public static create(properties?: folder.v3.remove.IRemoveRecordRequest): folder.v3.remove.RemoveRecordRequest; + public static create(properties?: folder.v3.remove.IImpact): folder.v3.remove.Impact; /** - * Encodes the specified RemoveRecordRequest message. Does not implicitly {@link folder.v3.remove.RemoveRecordRequest.verify|verify} messages. - * @param message RemoveRecordRequest message or plain object to encode + * Encodes the specified Impact message. Does not implicitly {@link folder.v3.remove.Impact.verify|verify} messages. + * @param message Impact message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRemoveRecordRequest, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IImpact, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RemoveRecordRequest message from the specified reader or buffer. + * Decodes an Impact message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RemoveRecordRequest + * @returns Impact * @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): folder.v3.remove.RemoveRecordRequest; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.Impact; /** - * Creates a RemoveRecordRequest message from a plain object. Also converts values to their respective internal types. + * Creates an Impact message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RemoveRecordRequest + * @returns Impact */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemoveRecordRequest; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.Impact; /** - * Creates a plain object from a RemoveRecordRequest message. Also converts values to other types if specified. - * @param message RemoveRecordRequest + * Creates a plain object from an Impact message. Also converts values to other types if specified. + * @param message Impact * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RemoveRecordRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.Impact, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RemoveRecordRequest to JSON. + * Converts this Impact to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RemoveRecordRequest + * Gets the default type url for Impact * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a RemoveFolderRequest. */ - interface IRemoveFolderRequest { - - /** RemoveFolderRequest action */ - action?: (folder.v3.remove.RemoveAction|null); + /** Properties of a RecordInfo. */ + interface IRecordInfo { - /** RemoveFolderRequest folders */ - folders?: (folder.v3.remove.IFolderRemoval[]|null); + /** RecordInfo recordUid */ + recordUid?: (Uint8Array|null); - /** RemoveFolderRequest confirmationToken */ - confirmationToken?: (Uint8Array|null); + /** RecordInfo locationsCount */ + locationsCount?: (number|null); } - /** Represents a RemoveFolderRequest. */ - class RemoveFolderRequest implements IRemoveFolderRequest { + /** + * Additional info for a record being removed. + * Only populated for MOVE_TO_OWNER_TRASH to show "also in X other folders". + */ + class RecordInfo implements IRecordInfo { /** - * Constructs a new RemoveFolderRequest. + * Constructs a new RecordInfo. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRemoveFolderRequest); - - /** RemoveFolderRequest action. */ - public action: folder.v3.remove.RemoveAction; + constructor(properties?: folder.v3.remove.IRecordInfo); - /** RemoveFolderRequest folders. */ - public folders: folder.v3.remove.IFolderRemoval[]; + /** RecordInfo recordUid. */ + public recordUid: Uint8Array; - /** RemoveFolderRequest confirmationToken. */ - public confirmationToken: Uint8Array; + /** RecordInfo locationsCount. */ + public locationsCount: number; /** - * Creates a new RemoveFolderRequest instance using the specified properties. + * Creates a new RecordInfo instance using the specified properties. * @param [properties] Properties to set - * @returns RemoveFolderRequest instance + * @returns RecordInfo instance */ - public static create(properties?: folder.v3.remove.IRemoveFolderRequest): folder.v3.remove.RemoveFolderRequest; + public static create(properties?: folder.v3.remove.IRecordInfo): folder.v3.remove.RecordInfo; /** - * Encodes the specified RemoveFolderRequest message. Does not implicitly {@link folder.v3.remove.RemoveFolderRequest.verify|verify} messages. - * @param message RemoveFolderRequest message or plain object to encode + * Encodes the specified RecordInfo message. Does not implicitly {@link folder.v3.remove.RecordInfo.verify|verify} messages. + * @param message RecordInfo message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRemoveFolderRequest, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IRecordInfo, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RemoveFolderRequest message from the specified reader or buffer. + * Decodes a RecordInfo message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RemoveFolderRequest + * @returns RecordInfo * @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): folder.v3.remove.RemoveFolderRequest; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RecordInfo; /** - * Creates a RemoveFolderRequest message from a plain object. Also converts values to their respective internal types. + * Creates a RecordInfo message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RemoveFolderRequest + * @returns RecordInfo */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemoveFolderRequest; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RecordInfo; /** - * Creates a plain object from a RemoveFolderRequest message. Also converts values to other types if specified. - * @param message RemoveFolderRequest + * Creates a plain object from a RecordInfo message. Also converts values to other types if specified. + * @param message RecordInfo * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RemoveFolderRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.RecordInfo, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RemoveFolderRequest to JSON. + * Converts this RecordInfo to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RemoveFolderRequest + * Gets the default type url for RecordInfo * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a RemoveResponse. */ - interface IRemoveResponse { - - /** RemoveResponse confirmationToken */ - confirmationToken?: (Uint8Array|null); - - /** RemoveResponse tokenExpiresAt */ - tokenExpiresAt?: (number|null); + /** Properties of an ItemError. */ + interface IItemError { - /** RemoveResponse results */ - results?: (folder.v3.remove.IRemoveResult[]|null); + /** ItemError code */ + code?: (folder.v3.remove.RemoveErrorCode|null); - /** RemoveResponse errorMessage */ - errorMessage?: (string|null); + /** ItemError message */ + message?: (string|null); } - /** - * Response for remove operations (both record and folder). - * - * For PREVIEW: Contains confirmation_token and per-item results with impact. - * For CONFIRM: Contains per-item results with execution status. - */ - class RemoveResponse implements IRemoveResponse { + /** Error details for a failed item. */ + class ItemError implements IItemError { /** - * Constructs a new RemoveResponse. + * Constructs a new ItemError. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRemoveResponse); - - /** RemoveResponse confirmationToken. */ - public confirmationToken: Uint8Array; - - /** RemoveResponse tokenExpiresAt. */ - public tokenExpiresAt: number; + constructor(properties?: folder.v3.remove.IItemError); - /** RemoveResponse results. */ - public results: folder.v3.remove.IRemoveResult[]; + /** ItemError code. */ + public code: folder.v3.remove.RemoveErrorCode; - /** RemoveResponse errorMessage. */ - public errorMessage: string; + /** ItemError message. */ + public message: string; /** - * Creates a new RemoveResponse instance using the specified properties. + * Creates a new ItemError instance using the specified properties. * @param [properties] Properties to set - * @returns RemoveResponse instance + * @returns ItemError instance */ - public static create(properties?: folder.v3.remove.IRemoveResponse): folder.v3.remove.RemoveResponse; + public static create(properties?: folder.v3.remove.IItemError): folder.v3.remove.ItemError; /** - * Encodes the specified RemoveResponse message. Does not implicitly {@link folder.v3.remove.RemoveResponse.verify|verify} messages. - * @param message RemoveResponse message or plain object to encode + * Encodes the specified ItemError message. Does not implicitly {@link folder.v3.remove.ItemError.verify|verify} messages. + * @param message ItemError message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRemoveResponse, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IItemError, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RemoveResponse message from the specified reader or buffer. + * Decodes an ItemError message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RemoveResponse + * @returns ItemError * @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): folder.v3.remove.RemoveResponse; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.ItemError; /** - * Creates a RemoveResponse message from a plain object. Also converts values to their respective internal types. + * Creates an ItemError message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RemoveResponse + * @returns ItemError */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemoveResponse; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.ItemError; /** - * Creates a plain object from a RemoveResponse message. Also converts values to other types if specified. - * @param message RemoveResponse + * Creates a plain object from an ItemError message. Also converts values to other types if specified. + * @param message ItemError * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RemoveResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.ItemError, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RemoveResponse to JSON. + * Converts this ItemError to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RemoveResponse + * Gets the default type url for ItemError * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a RemoveResult. */ - interface IRemoveResult { + /** Properties of a RemovalTokenPayload. */ + interface IRemovalTokenPayload { - /** RemoveResult itemUid */ - itemUid?: (Uint8Array|null); + /** RemovalTokenPayload itemFingerprints */ + itemFingerprints?: (folder.v3.remove.IItemFingerprint[]|null); - /** RemoveResult folderUid */ - folderUid?: (Uint8Array|null); + /** RemovalTokenPayload userId */ + userId?: (number|null); - /** RemoveResult status */ - status?: (folder.v3.remove.RemoveStatus|null); + /** RemovalTokenPayload deviceId */ + deviceId?: (number|null); - /** RemoveResult impact */ - impact?: (folder.v3.remove.IImpact|null); + /** RemovalTokenPayload sessionUid */ + sessionUid?: (Uint8Array|null); - /** RemoveResult error */ - error?: (folder.v3.remove.IItemError|null); + /** RemovalTokenPayload expiresAtMillis */ + expiresAtMillis?: (number|null); } - /** Per-item result for a single record or folder. */ - class RemoveResult implements IRemoveResult { + /** Internal token payload (not exposed in API, just for serialization) */ + class RemovalTokenPayload implements IRemovalTokenPayload { /** - * Constructs a new RemoveResult. + * Constructs a new RemovalTokenPayload. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRemoveResult); + constructor(properties?: folder.v3.remove.IRemovalTokenPayload); - /** RemoveResult itemUid. */ - public itemUid: Uint8Array; + /** RemovalTokenPayload itemFingerprints. */ + public itemFingerprints: folder.v3.remove.IItemFingerprint[]; - /** RemoveResult folderUid. */ - public folderUid: Uint8Array; + /** RemovalTokenPayload userId. */ + public userId: number; - /** RemoveResult status. */ - public status: folder.v3.remove.RemoveStatus; + /** RemovalTokenPayload deviceId. */ + public deviceId: number; - /** RemoveResult impact. */ - public impact?: (folder.v3.remove.IImpact|null); + /** RemovalTokenPayload sessionUid. */ + public sessionUid: Uint8Array; - /** RemoveResult error. */ - public error?: (folder.v3.remove.IItemError|null); + /** RemovalTokenPayload expiresAtMillis. */ + public expiresAtMillis: number; /** - * Creates a new RemoveResult instance using the specified properties. + * Creates a new RemovalTokenPayload instance using the specified properties. * @param [properties] Properties to set - * @returns RemoveResult instance + * @returns RemovalTokenPayload instance */ - public static create(properties?: folder.v3.remove.IRemoveResult): folder.v3.remove.RemoveResult; + public static create(properties?: folder.v3.remove.IRemovalTokenPayload): folder.v3.remove.RemovalTokenPayload; /** - * Encodes the specified RemoveResult message. Does not implicitly {@link folder.v3.remove.RemoveResult.verify|verify} messages. - * @param message RemoveResult message or plain object to encode + * Encodes the specified RemovalTokenPayload message. Does not implicitly {@link folder.v3.remove.RemovalTokenPayload.verify|verify} messages. + * @param message RemovalTokenPayload message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRemoveResult, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IRemovalTokenPayload, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RemoveResult message from the specified reader or buffer. + * Decodes a RemovalTokenPayload message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RemoveResult + * @returns RemovalTokenPayload * @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): folder.v3.remove.RemoveResult; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RemovalTokenPayload; /** - * Creates a RemoveResult message from a plain object. Also converts values to their respective internal types. + * Creates a RemovalTokenPayload message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RemoveResult + * @returns RemovalTokenPayload */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemoveResult; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemovalTokenPayload; /** - * Creates a plain object from a RemoveResult message. Also converts values to other types if specified. - * @param message RemoveResult + * Creates a plain object from a RemovalTokenPayload message. Also converts values to other types if specified. + * @param message RemovalTokenPayload * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RemoveResult, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.RemovalTokenPayload, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RemoveResult to JSON. + * Converts this RemovalTokenPayload to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RemoveResult + * Gets the default type url for RemovalTokenPayload * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of an Impact. */ - interface IImpact { - - /** Impact foldersCount */ - foldersCount?: (number|null); - - /** Impact recordsCount */ - recordsCount?: (number|null); - - /** Impact affectedUsersCount */ - affectedUsersCount?: (number|null); + /** Properties of an ItemFingerprint. */ + interface IItemFingerprint { - /** Impact affectedTeamsCount */ - affectedTeamsCount?: (number|null); + /** ItemFingerprint record */ + record?: (folder.v3.remove.IRecordTarget|null); - /** Impact recordInfo */ - recordInfo?: (folder.v3.remove.IRecordInfo[]|null); + /** ItemFingerprint folder */ + folder?: (folder.v3.remove.IFolderTarget|null); - /** Impact warnings */ - warnings?: (string[]|null); + /** ItemFingerprint fingerprint */ + fingerprint?: (Uint8Array|null); } - /** Impact metrics for a single item (record or folder tree). */ - class Impact implements IImpact { + /** Represents an ItemFingerprint. */ + class ItemFingerprint implements IItemFingerprint { /** - * Constructs a new Impact. + * Constructs a new ItemFingerprint. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IImpact); - - /** Impact foldersCount. */ - public foldersCount: number; - - /** Impact recordsCount. */ - public recordsCount: number; + constructor(properties?: folder.v3.remove.IItemFingerprint); - /** Impact affectedUsersCount. */ - public affectedUsersCount: number; + /** ItemFingerprint record. */ + public record?: (folder.v3.remove.IRecordTarget|null); - /** Impact affectedTeamsCount. */ - public affectedTeamsCount: number; + /** ItemFingerprint folder. */ + public folder?: (folder.v3.remove.IFolderTarget|null); - /** Impact recordInfo. */ - public recordInfo: folder.v3.remove.IRecordInfo[]; + /** ItemFingerprint fingerprint. */ + public fingerprint: Uint8Array; - /** Impact warnings. */ - public warnings: string[]; + /** ItemFingerprint target. */ + public target?: ("record"|"folder"); /** - * Creates a new Impact instance using the specified properties. + * Creates a new ItemFingerprint instance using the specified properties. * @param [properties] Properties to set - * @returns Impact instance + * @returns ItemFingerprint instance */ - public static create(properties?: folder.v3.remove.IImpact): folder.v3.remove.Impact; + public static create(properties?: folder.v3.remove.IItemFingerprint): folder.v3.remove.ItemFingerprint; /** - * Encodes the specified Impact message. Does not implicitly {@link folder.v3.remove.Impact.verify|verify} messages. - * @param message Impact message or plain object to encode + * Encodes the specified ItemFingerprint message. Does not implicitly {@link folder.v3.remove.ItemFingerprint.verify|verify} messages. + * @param message ItemFingerprint message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IImpact, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IItemFingerprint, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes an Impact message from the specified reader or buffer. + * Decodes an ItemFingerprint message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns Impact + * @returns ItemFingerprint * @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): folder.v3.remove.Impact; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.ItemFingerprint; /** - * Creates an Impact message from a plain object. Also converts values to their respective internal types. + * Creates an ItemFingerprint message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns Impact + * @returns ItemFingerprint */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.Impact; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.ItemFingerprint; /** - * Creates a plain object from an Impact message. Also converts values to other types if specified. - * @param message Impact + * Creates a plain object from an ItemFingerprint message. Also converts values to other types if specified. + * @param message ItemFingerprint * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.Impact, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.ItemFingerprint, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this Impact to JSON. + * Converts this ItemFingerprint to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for Impact + * Gets the default type url for ItemFingerprint * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a RecordInfo. */ - interface IRecordInfo { + /** Properties of a RecordTarget. */ + interface IRecordTarget { - /** RecordInfo recordUid */ + /** RecordTarget folderUid */ + folderUid?: (Uint8Array|null); + + /** RecordTarget recordUid */ recordUid?: (Uint8Array|null); - /** RecordInfo locationsCount */ - locationsCount?: (number|null); + /** RecordTarget operationType */ + operationType?: (folder.v3.remove.RecordOperationType|null); } - /** - * Additional info for a record being removed. - * Only populated for MOVE_TO_OWNER_TRASH to show "also in X other folders". - */ - class RecordInfo implements IRecordInfo { + /** Represents a RecordTarget. */ + class RecordTarget implements IRecordTarget { /** - * Constructs a new RecordInfo. + * Constructs a new RecordTarget. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRecordInfo); + constructor(properties?: folder.v3.remove.IRecordTarget); - /** RecordInfo recordUid. */ + /** RecordTarget folderUid. */ + public folderUid: Uint8Array; + + /** RecordTarget recordUid. */ public recordUid: Uint8Array; - /** RecordInfo locationsCount. */ - public locationsCount: number; + /** RecordTarget operationType. */ + public operationType: folder.v3.remove.RecordOperationType; /** - * Creates a new RecordInfo instance using the specified properties. + * Creates a new RecordTarget instance using the specified properties. * @param [properties] Properties to set - * @returns RecordInfo instance + * @returns RecordTarget instance */ - public static create(properties?: folder.v3.remove.IRecordInfo): folder.v3.remove.RecordInfo; + public static create(properties?: folder.v3.remove.IRecordTarget): folder.v3.remove.RecordTarget; /** - * Encodes the specified RecordInfo message. Does not implicitly {@link folder.v3.remove.RecordInfo.verify|verify} messages. - * @param message RecordInfo message or plain object to encode + * Encodes the specified RecordTarget message. Does not implicitly {@link folder.v3.remove.RecordTarget.verify|verify} messages. + * @param message RecordTarget message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRecordInfo, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IRecordTarget, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RecordInfo message from the specified reader or buffer. + * Decodes a RecordTarget message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RecordInfo + * @returns RecordTarget * @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): folder.v3.remove.RecordInfo; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RecordTarget; /** - * Creates a RecordInfo message from a plain object. Also converts values to their respective internal types. + * Creates a RecordTarget message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RecordInfo + * @returns RecordTarget */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RecordInfo; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RecordTarget; /** - * Creates a plain object from a RecordInfo message. Also converts values to other types if specified. - * @param message RecordInfo + * Creates a plain object from a RecordTarget message. Also converts values to other types if specified. + * @param message RecordTarget * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RecordInfo, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.RecordTarget, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RecordInfo to JSON. + * Converts this RecordTarget to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RecordInfo + * Gets the default type url for RecordTarget * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of an ItemError. */ - interface IItemError { + /** Properties of a FolderTarget. */ + interface IFolderTarget { - /** ItemError code */ - code?: (folder.v3.remove.RemoveErrorCode|null); + /** FolderTarget folderUid */ + folderUid?: (Uint8Array|null); - /** ItemError message */ - message?: (string|null); + /** FolderTarget operationType */ + operationType?: (folder.v3.remove.FolderOperationType|null); } - /** Error details for a failed item. */ - class ItemError implements IItemError { + /** Represents a FolderTarget. */ + class FolderTarget implements IFolderTarget { /** - * Constructs a new ItemError. + * Constructs a new FolderTarget. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IItemError); + constructor(properties?: folder.v3.remove.IFolderTarget); - /** ItemError code. */ - public code: folder.v3.remove.RemoveErrorCode; + /** FolderTarget folderUid. */ + public folderUid: Uint8Array; - /** ItemError message. */ - public message: string; + /** FolderTarget operationType. */ + public operationType: folder.v3.remove.FolderOperationType; /** - * Creates a new ItemError instance using the specified properties. + * Creates a new FolderTarget instance using the specified properties. * @param [properties] Properties to set - * @returns ItemError instance + * @returns FolderTarget instance */ - public static create(properties?: folder.v3.remove.IItemError): folder.v3.remove.ItemError; + public static create(properties?: folder.v3.remove.IFolderTarget): folder.v3.remove.FolderTarget; /** - * Encodes the specified ItemError message. Does not implicitly {@link folder.v3.remove.ItemError.verify|verify} messages. - * @param message ItemError message or plain object to encode + * Encodes the specified FolderTarget message. Does not implicitly {@link folder.v3.remove.FolderTarget.verify|verify} messages. + * @param message FolderTarget message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IItemError, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IFolderTarget, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes an ItemError message from the specified reader or buffer. + * Decodes a FolderTarget message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns ItemError + * @returns FolderTarget * @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): folder.v3.remove.ItemError; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.FolderTarget; /** - * Creates an ItemError message from a plain object. Also converts values to their respective internal types. + * Creates a FolderTarget message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns ItemError + * @returns FolderTarget */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.ItemError; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.FolderTarget; /** - * Creates a plain object from an ItemError message. Also converts values to other types if specified. - * @param message ItemError + * Creates a plain object from a FolderTarget message. Also converts values to other types if specified. + * @param message FolderTarget * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.ItemError, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.FolderTarget, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this ItemError to JSON. + * Converts this FolderTarget to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for ItemError + * Gets the default type url for FolderTarget * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a RemovalTokenPayload. */ - interface IRemovalTokenPayload { + /** RestoreStatus enum. */ + enum RestoreStatus { + RESTORE_STATUS_UNKNOWN = 0, + RS_SUCCESS = 1, + RS_NOT_IN_TRASHCAN = 2, + RS_ACCESS_DENIED = 3, + RS_TARGET_FOLDER_NOT_FOUND = 4, + RS_ALREADY_EXISTS_IN_TARGET = 5, + RS_FAIL = 6 + } - /** RemovalTokenPayload itemFingerprints */ - itemFingerprints?: (folder.v3.remove.IItemFingerprint[]|null); + /** RestoreItemType enum. */ + enum RestoreItemType { + RESTORE_ITEM_UNKNOWN = 0, + RESTORE_ITEM_RECORD = 1, + RESTORE_ITEM_FOLDER = 2 + } - /** RemovalTokenPayload userId */ - userId?: (number|null); + /** Properties of a RestoreResult. */ + interface IRestoreResult { - /** RemovalTokenPayload deviceId */ - deviceId?: (number|null); + /** RestoreResult itemUid */ + itemUid?: (Uint8Array|null); - /** RemovalTokenPayload sessionUid */ - sessionUid?: (Uint8Array|null); + /** RestoreResult itemType */ + itemType?: (folder.v3.remove.RestoreItemType|null); - /** RemovalTokenPayload expiresAtMillis */ - expiresAtMillis?: (number|null); + /** RestoreResult status */ + status?: (folder.v3.remove.RestoreStatus|null); + + /** RestoreResult errorMessage */ + errorMessage?: (string|null); } - /** Internal token payload (not exposed in API, just for serialization) */ - class RemovalTokenPayload implements IRemovalTokenPayload { + /** Represents a RestoreResult. */ + class RestoreResult implements IRestoreResult { /** - * Constructs a new RemovalTokenPayload. + * Constructs a new RestoreResult. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRemovalTokenPayload); - - /** RemovalTokenPayload itemFingerprints. */ - public itemFingerprints: folder.v3.remove.IItemFingerprint[]; + constructor(properties?: folder.v3.remove.IRestoreResult); - /** RemovalTokenPayload userId. */ - public userId: number; + /** RestoreResult itemUid. */ + public itemUid: Uint8Array; - /** RemovalTokenPayload deviceId. */ - public deviceId: number; + /** RestoreResult itemType. */ + public itemType: folder.v3.remove.RestoreItemType; - /** RemovalTokenPayload sessionUid. */ - public sessionUid: Uint8Array; + /** RestoreResult status. */ + public status: folder.v3.remove.RestoreStatus; - /** RemovalTokenPayload expiresAtMillis. */ - public expiresAtMillis: number; + /** RestoreResult errorMessage. */ + public errorMessage: string; /** - * Creates a new RemovalTokenPayload instance using the specified properties. + * Creates a new RestoreResult instance using the specified properties. * @param [properties] Properties to set - * @returns RemovalTokenPayload instance + * @returns RestoreResult instance */ - public static create(properties?: folder.v3.remove.IRemovalTokenPayload): folder.v3.remove.RemovalTokenPayload; + public static create(properties?: folder.v3.remove.IRestoreResult): folder.v3.remove.RestoreResult; /** - * Encodes the specified RemovalTokenPayload message. Does not implicitly {@link folder.v3.remove.RemovalTokenPayload.verify|verify} messages. - * @param message RemovalTokenPayload message or plain object to encode + * Encodes the specified RestoreResult message. Does not implicitly {@link folder.v3.remove.RestoreResult.verify|verify} messages. + * @param message RestoreResult message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRemovalTokenPayload, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IRestoreResult, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RemovalTokenPayload message from the specified reader or buffer. + * Decodes a RestoreResult message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RemovalTokenPayload + * @returns RestoreResult * @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): folder.v3.remove.RemovalTokenPayload; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RestoreResult; /** - * Creates a RemovalTokenPayload message from a plain object. Also converts values to their respective internal types. + * Creates a RestoreResult message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RemovalTokenPayload + * @returns RestoreResult */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RemovalTokenPayload; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RestoreResult; /** - * Creates a plain object from a RemovalTokenPayload message. Also converts values to other types if specified. - * @param message RemovalTokenPayload + * Creates a plain object from a RestoreResult message. Also converts values to other types if specified. + * @param message RestoreResult * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RemovalTokenPayload, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.RestoreResult, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RemovalTokenPayload to JSON. + * Converts this RestoreResult to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RemovalTokenPayload + * Gets the default type url for RestoreResult * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of an ItemFingerprint. */ - interface IItemFingerprint { - - /** ItemFingerprint record */ - record?: (folder.v3.remove.IRecordTarget|null); + /** Properties of a TrashcanRestoreResponse. */ + interface ITrashcanRestoreResponse { - /** ItemFingerprint folder */ - folder?: (folder.v3.remove.IFolderTarget|null); + /** TrashcanRestoreResponse results */ + results?: (folder.v3.remove.IRestoreResult[]|null); - /** ItemFingerprint fingerprint */ - fingerprint?: (Uint8Array|null); + /** TrashcanRestoreResponse errorMessage */ + errorMessage?: (string|null); } - /** Represents an ItemFingerprint. */ - class ItemFingerprint implements IItemFingerprint { + /** Represents a TrashcanRestoreResponse. */ + class TrashcanRestoreResponse implements ITrashcanRestoreResponse { /** - * Constructs a new ItemFingerprint. + * Constructs a new TrashcanRestoreResponse. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IItemFingerprint); - - /** ItemFingerprint record. */ - public record?: (folder.v3.remove.IRecordTarget|null); - - /** ItemFingerprint folder. */ - public folder?: (folder.v3.remove.IFolderTarget|null); + constructor(properties?: folder.v3.remove.ITrashcanRestoreResponse); - /** ItemFingerprint fingerprint. */ - public fingerprint: Uint8Array; + /** TrashcanRestoreResponse results. */ + public results: folder.v3.remove.IRestoreResult[]; - /** ItemFingerprint target. */ - public target?: ("record"|"folder"); + /** TrashcanRestoreResponse errorMessage. */ + public errorMessage: string; /** - * Creates a new ItemFingerprint instance using the specified properties. + * Creates a new TrashcanRestoreResponse instance using the specified properties. * @param [properties] Properties to set - * @returns ItemFingerprint instance + * @returns TrashcanRestoreResponse instance */ - public static create(properties?: folder.v3.remove.IItemFingerprint): folder.v3.remove.ItemFingerprint; + public static create(properties?: folder.v3.remove.ITrashcanRestoreResponse): folder.v3.remove.TrashcanRestoreResponse; /** - * Encodes the specified ItemFingerprint message. Does not implicitly {@link folder.v3.remove.ItemFingerprint.verify|verify} messages. - * @param message ItemFingerprint message or plain object to encode + * Encodes the specified TrashcanRestoreResponse message. Does not implicitly {@link folder.v3.remove.TrashcanRestoreResponse.verify|verify} messages. + * @param message TrashcanRestoreResponse message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IItemFingerprint, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.ITrashcanRestoreResponse, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes an ItemFingerprint message from the specified reader or buffer. + * Decodes a TrashcanRestoreResponse message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns ItemFingerprint + * @returns TrashcanRestoreResponse * @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): folder.v3.remove.ItemFingerprint; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.TrashcanRestoreResponse; /** - * Creates an ItemFingerprint message from a plain object. Also converts values to their respective internal types. + * Creates a TrashcanRestoreResponse message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns ItemFingerprint + * @returns TrashcanRestoreResponse */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.ItemFingerprint; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.TrashcanRestoreResponse; /** - * Creates a plain object from an ItemFingerprint message. Also converts values to other types if specified. - * @param message ItemFingerprint + * Creates a plain object from a TrashcanRestoreResponse message. Also converts values to other types if specified. + * @param message TrashcanRestoreResponse * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.ItemFingerprint, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.TrashcanRestoreResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this ItemFingerprint to JSON. + * Converts this TrashcanRestoreResponse to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for ItemFingerprint + * Gets the default type url for TrashcanRestoreResponse * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a RecordTarget. */ - interface IRecordTarget { - - /** RecordTarget folderUid */ - folderUid?: (Uint8Array|null); + /** Properties of a RestoreRecord. */ + interface IRestoreRecord { - /** RecordTarget recordUid */ + /** RestoreRecord recordUid */ recordUid?: (Uint8Array|null); - /** RecordTarget operationType */ - operationType?: (folder.v3.remove.RecordOperationType|null); + /** RestoreRecord encryptedRecordKey */ + encryptedRecordKey?: (Uint8Array|null); + + /** RestoreRecord sourceFolderUid */ + sourceFolderUid?: (Uint8Array|null); } - /** Represents a RecordTarget. */ - class RecordTarget implements IRecordTarget { + /** Represents a RestoreRecord. */ + class RestoreRecord implements IRestoreRecord { /** - * Constructs a new RecordTarget. + * Constructs a new RestoreRecord. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRecordTarget); - - /** RecordTarget folderUid. */ - public folderUid: Uint8Array; + constructor(properties?: folder.v3.remove.IRestoreRecord); - /** RecordTarget recordUid. */ + /** RestoreRecord recordUid. */ public recordUid: Uint8Array; - /** RecordTarget operationType. */ - public operationType: folder.v3.remove.RecordOperationType; + /** RestoreRecord encryptedRecordKey. */ + public encryptedRecordKey: Uint8Array; + + /** RestoreRecord sourceFolderUid. */ + public sourceFolderUid: Uint8Array; /** - * Creates a new RecordTarget instance using the specified properties. + * Creates a new RestoreRecord instance using the specified properties. * @param [properties] Properties to set - * @returns RecordTarget instance + * @returns RestoreRecord instance */ - public static create(properties?: folder.v3.remove.IRecordTarget): folder.v3.remove.RecordTarget; + public static create(properties?: folder.v3.remove.IRestoreRecord): folder.v3.remove.RestoreRecord; /** - * Encodes the specified RecordTarget message. Does not implicitly {@link folder.v3.remove.RecordTarget.verify|verify} messages. - * @param message RecordTarget message or plain object to encode + * Encodes the specified RestoreRecord message. Does not implicitly {@link folder.v3.remove.RestoreRecord.verify|verify} messages. + * @param message RestoreRecord message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRecordTarget, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IRestoreRecord, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RecordTarget message from the specified reader or buffer. + * Decodes a RestoreRecord message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RecordTarget + * @returns RestoreRecord * @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): folder.v3.remove.RecordTarget; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RestoreRecord; /** - * Creates a RecordTarget message from a plain object. Also converts values to their respective internal types. + * Creates a RestoreRecord message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RecordTarget + * @returns RestoreRecord */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RecordTarget; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RestoreRecord; /** - * Creates a plain object from a RecordTarget message. Also converts values to other types if specified. - * @param message RecordTarget + * Creates a plain object from a RestoreRecord message. Also converts values to other types if specified. + * @param message RestoreRecord * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RecordTarget, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.RestoreRecord, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RecordTarget to JSON. + * Converts this RestoreRecord to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RecordTarget + * Gets the default type url for RestoreRecord * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** Properties of a FolderTarget. */ - interface IFolderTarget { + /** Properties of a RestoreFolder. */ + interface IRestoreFolder { - /** FolderTarget folderUid */ + /** RestoreFolder folderUid */ folderUid?: (Uint8Array|null); - /** FolderTarget operationType */ - operationType?: (folder.v3.remove.FolderOperationType|null); + /** RestoreFolder encryptedFolderKey */ + encryptedFolderKey?: (Uint8Array|null); } - /** Represents a FolderTarget. */ - class FolderTarget implements IFolderTarget { + /** Represents a RestoreFolder. */ + class RestoreFolder implements IRestoreFolder { /** - * Constructs a new FolderTarget. + * Constructs a new RestoreFolder. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IFolderTarget); + constructor(properties?: folder.v3.remove.IRestoreFolder); - /** FolderTarget folderUid. */ + /** RestoreFolder folderUid. */ public folderUid: Uint8Array; - /** FolderTarget operationType. */ - public operationType: folder.v3.remove.FolderOperationType; + /** RestoreFolder encryptedFolderKey. */ + public encryptedFolderKey: Uint8Array; /** - * Creates a new FolderTarget instance using the specified properties. + * Creates a new RestoreFolder instance using the specified properties. * @param [properties] Properties to set - * @returns FolderTarget instance + * @returns RestoreFolder instance */ - public static create(properties?: folder.v3.remove.IFolderTarget): folder.v3.remove.FolderTarget; + public static create(properties?: folder.v3.remove.IRestoreFolder): folder.v3.remove.RestoreFolder; /** - * Encodes the specified FolderTarget message. Does not implicitly {@link folder.v3.remove.FolderTarget.verify|verify} messages. - * @param message FolderTarget message or plain object to encode + * Encodes the specified RestoreFolder message. Does not implicitly {@link folder.v3.remove.RestoreFolder.verify|verify} messages. + * @param message RestoreFolder message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IFolderTarget, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.IRestoreFolder, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a FolderTarget message from the specified reader or buffer. + * Decodes a RestoreFolder message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns FolderTarget + * @returns RestoreFolder * @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): folder.v3.remove.FolderTarget; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.RestoreFolder; /** - * Creates a FolderTarget message from a plain object. Also converts values to their respective internal types. + * Creates a RestoreFolder message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns FolderTarget + * @returns RestoreFolder */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.FolderTarget; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.RestoreFolder; /** - * Creates a plain object from a FolderTarget message. Also converts values to other types if specified. - * @param message FolderTarget + * Creates a plain object from a RestoreFolder message. Also converts values to other types if specified. + * @param message RestoreFolder * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.FolderTarget, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.RestoreFolder, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this FolderTarget to JSON. + * Converts this RestoreFolder to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for FolderTarget + * Gets the default type url for RestoreFolder * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } - /** RestoreStatus enum. */ - enum RestoreStatus { - RESTORE_STATUS_UNKNOWN = 0, - RS_SUCCESS = 1, - RS_NOT_IN_TRASHCAN = 2, - RS_ACCESS_DENIED = 3, - RS_TARGET_FOLDER_NOT_FOUND = 4, - RS_ALREADY_EXISTS_IN_TARGET = 5, - RS_FAIL = 6 - } - - /** RestoreItemType enum. */ - enum RestoreItemType { - RESTORE_ITEM_UNKNOWN = 0, - RESTORE_ITEM_RECORD = 1, - RESTORE_ITEM_FOLDER = 2 - } - - /** Properties of a RestoreResult. */ - interface IRestoreResult { - - /** RestoreResult itemUid */ - itemUid?: (Uint8Array|null); + /** Properties of a TrashcanRestoreRequest. */ + interface ITrashcanRestoreRequest { - /** RestoreResult itemType */ - itemType?: (folder.v3.remove.RestoreItemType|null); + /** TrashcanRestoreRequest records */ + records?: (folder.v3.remove.IRestoreRecord[]|null); - /** RestoreResult status */ - status?: (folder.v3.remove.RestoreStatus|null); + /** TrashcanRestoreRequest folders */ + folders?: (folder.v3.remove.IRestoreFolder[]|null); - /** RestoreResult errorMessage */ - errorMessage?: (string|null); + /** TrashcanRestoreRequest targetFolderUid */ + targetFolderUid?: (Uint8Array|null); } - /** Represents a RestoreResult. */ - class RestoreResult implements IRestoreResult { + /** Represents a TrashcanRestoreRequest. */ + class TrashcanRestoreRequest implements ITrashcanRestoreRequest { /** - * Constructs a new RestoreResult. + * Constructs a new TrashcanRestoreRequest. * @param [properties] Properties to set */ - constructor(properties?: folder.v3.remove.IRestoreResult); - - /** RestoreResult itemUid. */ - public itemUid: Uint8Array; + constructor(properties?: folder.v3.remove.ITrashcanRestoreRequest); - /** RestoreResult itemType. */ - public itemType: folder.v3.remove.RestoreItemType; + /** TrashcanRestoreRequest records. */ + public records: folder.v3.remove.IRestoreRecord[]; - /** RestoreResult status. */ - public status: folder.v3.remove.RestoreStatus; + /** TrashcanRestoreRequest folders. */ + public folders: folder.v3.remove.IRestoreFolder[]; - /** RestoreResult errorMessage. */ - public errorMessage: string; + /** TrashcanRestoreRequest targetFolderUid. */ + public targetFolderUid: Uint8Array; /** - * Creates a new RestoreResult instance using the specified properties. + * Creates a new TrashcanRestoreRequest instance using the specified properties. * @param [properties] Properties to set - * @returns RestoreResult instance + * @returns TrashcanRestoreRequest instance */ - public static create(properties?: folder.v3.remove.IRestoreResult): folder.v3.remove.RestoreResult; + public static create(properties?: folder.v3.remove.ITrashcanRestoreRequest): folder.v3.remove.TrashcanRestoreRequest; /** - * Encodes the specified RestoreResult message. Does not implicitly {@link folder.v3.remove.RestoreResult.verify|verify} messages. - * @param message RestoreResult message or plain object to encode + * Encodes the specified TrashcanRestoreRequest message. Does not implicitly {@link folder.v3.remove.TrashcanRestoreRequest.verify|verify} messages. + * @param message TrashcanRestoreRequest message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: folder.v3.remove.IRestoreResult, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: folder.v3.remove.ITrashcanRestoreRequest, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a RestoreResult message from the specified reader or buffer. + * Decodes a TrashcanRestoreRequest message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns RestoreResult + * @returns TrashcanRestoreRequest * @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): folder.v3.remove.RestoreResult; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): folder.v3.remove.TrashcanRestoreRequest; /** - * Creates a RestoreResult message from a plain object. Also converts values to their respective internal types. + * Creates a TrashcanRestoreRequest message from a plain object. Also converts values to their respective internal types. * @param object Plain object - * @returns RestoreResult + * @returns TrashcanRestoreRequest */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RestoreResult; + public static fromObject(object: { [k: string]: any }): folder.v3.remove.TrashcanRestoreRequest; /** - * Creates a plain object from a RestoreResult message. Also converts values to other types if specified. - * @param message RestoreResult + * Creates a plain object from a TrashcanRestoreRequest message. Also converts values to other types if specified. + * @param message TrashcanRestoreRequest * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: folder.v3.remove.RestoreResult, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: folder.v3.remove.TrashcanRestoreRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this RestoreResult to JSON. + * Converts this TrashcanRestoreRequest to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; /** - * Gets the default type url for RestoreResult + * Gets the default type url for TrashcanRestoreRequest * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") * @returns The default type url */ public static getTypeUrl(typeUrlPrefix?: string): string; } + } - /** Properties of a TrashcanRestoreResponse. */ - interface ITrashcanRestoreResponse { + /** RPC service for folder operations. */ + class FolderService extends $protobuf.rpc.Service { - /** TrashcanRestoreResponse results */ - results?: (folder.v3.remove.IRestoreResult[]|null); + /** + * Constructs a new FolderService service. + * @param rpcImpl RPC implementation + * @param [requestDelimited=false] Whether requests are length-delimited + * @param [responseDelimited=false] Whether responses are length-delimited + */ + constructor(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean); - /** TrashcanRestoreResponse errorMessage */ - errorMessage?: (string|null); - } + /** + * Creates new FolderService service using the specified rpc implementation. + * @param rpcImpl RPC implementation + * @param [requestDelimited=false] Whether requests are length-delimited + * @param [responseDelimited=false] Whether responses are length-delimited + * @returns RPC service. Useful where requests and/or responses are streamed. + */ + public static create(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean): FolderService; - /** Represents a TrashcanRestoreResponse. */ - class TrashcanRestoreResponse implements ITrashcanRestoreResponse { + /** + * Retrieve users and teams with access to specified folders. + * Requires can_change_user_permissions permission or Share Admin/MC Admin privileges. + * @param request GetFolderAccessRequest message or plain object + * @param callback Node-style callback called with the error, if any, and GetFolderAccessResponse + */ + public getFolderAccess(request: folder.v3.IGetFolderAccessRequest, callback: folder.v3.FolderService.GetFolderAccessCallback): void; - /** - * Constructs a new TrashcanRestoreResponse. - * @param [properties] Properties to set - */ - constructor(properties?: folder.v3.remove.ITrashcanRestoreResponse); + /** + * Retrieve users and teams with access to specified folders. + * Requires can_change_user_permissions permission or Share Admin/MC Admin privileges. + * @param request GetFolderAccessRequest message or plain object + * @returns Promise + */ + public getFolderAccess(request: folder.v3.IGetFolderAccessRequest): Promise; + } - /** TrashcanRestoreResponse results. */ - public results: folder.v3.remove.IRestoreResult[]; + namespace FolderService { - /** TrashcanRestoreResponse errorMessage. */ - public errorMessage: string; + /** + * Callback as used by {@link folder.v3.FolderService#getFolderAccess}. + * @param error Error, if any + * @param [response] GetFolderAccessResponse + */ + type GetFolderAccessCallback = (error: (Error|null), response?: folder.v3.GetFolderAccessResponse) => void; + } - /** - * Creates a new TrashcanRestoreResponse instance using the specified properties. - * @param [properties] Properties to set - * @returns TrashcanRestoreResponse instance - */ - public static create(properties?: folder.v3.remove.ITrashcanRestoreResponse): folder.v3.remove.TrashcanRestoreResponse; + /** Properties of a GetFolderAccessRequest. */ + interface IGetFolderAccessRequest { + + /** List of folder UIDs to query (max: 100) */ + folderUid?: (Uint8Array[]|null); + + /** + * Continuation token for pagination. + * Contains the last_modified timestamp from the previous page. + * Omit for the first page. + */ + continuationToken?: (folder.v3.IContinuationToken|null); + + /** + * Maximum number of accessors to return per page. + * Default: 100, Max: 1000 + */ + pageSize?: (number|null); + } + + /** + * Request to retrieve folder accessors (users and teams with access to folders). + * Supports cursor-based pagination using last_modified timestamps. + */ + class GetFolderAccessRequest implements IGetFolderAccessRequest { + + /** + * Constructs a new GetFolderAccessRequest. + * @param [properties] Properties to set + */ + constructor(properties?: folder.v3.IGetFolderAccessRequest); + + /** List of folder UIDs to query (max: 100) */ + public folderUid: Uint8Array[]; + + /** + * Continuation token for pagination. + * Contains the last_modified timestamp from the previous page. + * Omit for the first page. + */ + public continuationToken?: (folder.v3.IContinuationToken|null); + + /** + * Maximum number of accessors to return per page. + * Default: 100, Max: 1000 + */ + public pageSize?: (number|null); + + /** + * Creates a new GetFolderAccessRequest instance using the specified properties. + * @param [properties] Properties to set + * @returns GetFolderAccessRequest instance + */ + public static create(properties?: folder.v3.IGetFolderAccessRequest): folder.v3.GetFolderAccessRequest; + + /** + * Encodes the specified GetFolderAccessRequest message. Does not implicitly {@link folder.v3.GetFolderAccessRequest.verify|verify} messages. + * @param message GetFolderAccessRequest message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: folder.v3.IGetFolderAccessRequest, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a GetFolderAccessRequest message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns GetFolderAccessRequest + * @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): folder.v3.GetFolderAccessRequest; + + /** + * Creates a GetFolderAccessRequest message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns GetFolderAccessRequest + */ + public static fromObject(object: { [k: string]: any }): folder.v3.GetFolderAccessRequest; + + /** + * Creates a plain object from a GetFolderAccessRequest message. Also converts values to other types if specified. + * @param message GetFolderAccessRequest + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: folder.v3.GetFolderAccessRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this GetFolderAccessRequest to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for GetFolderAccessRequest + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a GetFolderAccessResponse. */ + interface IGetFolderAccessResponse { + + /** Per-folder results (either success with accessors or error) */ + folderAccessResults?: (folder.v3.IGetFolderAccessResult[]|null); + + /** Continuation token for the next page (only present if hasMore is true) */ + continuationToken?: (folder.v3.IContinuationToken|null); - /** - * Encodes the specified TrashcanRestoreResponse message. Does not implicitly {@link folder.v3.remove.TrashcanRestoreResponse.verify|verify} messages. - * @param message TrashcanRestoreResponse message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: folder.v3.remove.ITrashcanRestoreResponse, writer?: $protobuf.Writer): $protobuf.Writer; + /** True if more results exist beyond this page */ + hasMore?: (boolean|null); + } - /** - * Decodes a TrashcanRestoreResponse message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns TrashcanRestoreResponse - * @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): folder.v3.remove.TrashcanRestoreResponse; + /** Response containing folder accessors with pagination support. */ + class GetFolderAccessResponse implements IGetFolderAccessResponse { - /** - * Creates a TrashcanRestoreResponse message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns TrashcanRestoreResponse - */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.TrashcanRestoreResponse; + /** + * Constructs a new GetFolderAccessResponse. + * @param [properties] Properties to set + */ + constructor(properties?: folder.v3.IGetFolderAccessResponse); - /** - * Creates a plain object from a TrashcanRestoreResponse message. Also converts values to other types if specified. - * @param message TrashcanRestoreResponse - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: folder.v3.remove.TrashcanRestoreResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** Per-folder results (either success with accessors or error) */ + public folderAccessResults: folder.v3.IGetFolderAccessResult[]; - /** - * Converts this TrashcanRestoreResponse to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** Continuation token for the next page (only present if hasMore is true) */ + public continuationToken?: (folder.v3.IContinuationToken|null); - /** - * Gets the default type url for TrashcanRestoreResponse - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** True if more results exist beyond this page */ + public hasMore: boolean; - /** Properties of a RestoreRecord. */ - interface IRestoreRecord { + /** + * Creates a new GetFolderAccessResponse instance using the specified properties. + * @param [properties] Properties to set + * @returns GetFolderAccessResponse instance + */ + public static create(properties?: folder.v3.IGetFolderAccessResponse): folder.v3.GetFolderAccessResponse; - /** RestoreRecord recordUid */ - recordUid?: (Uint8Array|null); + /** + * Encodes the specified GetFolderAccessResponse message. Does not implicitly {@link folder.v3.GetFolderAccessResponse.verify|verify} messages. + * @param message GetFolderAccessResponse message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: folder.v3.IGetFolderAccessResponse, writer?: $protobuf.Writer): $protobuf.Writer; - /** RestoreRecord encryptedRecordKey */ - encryptedRecordKey?: (Uint8Array|null); + /** + * Decodes a GetFolderAccessResponse message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns GetFolderAccessResponse + * @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): folder.v3.GetFolderAccessResponse; - /** RestoreRecord sourceFolderUid */ - sourceFolderUid?: (Uint8Array|null); - } + /** + * Creates a GetFolderAccessResponse message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns GetFolderAccessResponse + */ + public static fromObject(object: { [k: string]: any }): folder.v3.GetFolderAccessResponse; - /** Represents a RestoreRecord. */ - class RestoreRecord implements IRestoreRecord { + /** + * Creates a plain object from a GetFolderAccessResponse message. Also converts values to other types if specified. + * @param message GetFolderAccessResponse + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: folder.v3.GetFolderAccessResponse, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** - * Constructs a new RestoreRecord. - * @param [properties] Properties to set - */ - constructor(properties?: folder.v3.remove.IRestoreRecord); + /** + * Converts this GetFolderAccessResponse to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** RestoreRecord recordUid. */ - public recordUid: Uint8Array; + /** + * Gets the default type url for GetFolderAccessResponse + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** RestoreRecord encryptedRecordKey. */ - public encryptedRecordKey: Uint8Array; + /** Properties of a ContinuationToken. */ + interface IContinuationToken { - /** RestoreRecord sourceFolderUid. */ - public sourceFolderUid: Uint8Array; + /** Unix timestamp in milliseconds of the last processed accessor */ + lastModified?: (number|null); + } - /** - * Creates a new RestoreRecord instance using the specified properties. - * @param [properties] Properties to set - * @returns RestoreRecord instance - */ - public static create(properties?: folder.v3.remove.IRestoreRecord): folder.v3.remove.RestoreRecord; + /** + * Cursor for cursor-based pagination. + * Contains the timestamp of the last processed item. + */ + class ContinuationToken implements IContinuationToken { - /** - * Encodes the specified RestoreRecord message. Does not implicitly {@link folder.v3.remove.RestoreRecord.verify|verify} messages. - * @param message RestoreRecord message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: folder.v3.remove.IRestoreRecord, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Constructs a new ContinuationToken. + * @param [properties] Properties to set + */ + constructor(properties?: folder.v3.IContinuationToken); - /** - * Decodes a RestoreRecord message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns RestoreRecord - * @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): folder.v3.remove.RestoreRecord; + /** Unix timestamp in milliseconds of the last processed accessor */ + public lastModified: number; - /** - * Creates a RestoreRecord message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns RestoreRecord - */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RestoreRecord; + /** + * Creates a new ContinuationToken instance using the specified properties. + * @param [properties] Properties to set + * @returns ContinuationToken instance + */ + public static create(properties?: folder.v3.IContinuationToken): folder.v3.ContinuationToken; - /** - * Creates a plain object from a RestoreRecord message. Also converts values to other types if specified. - * @param message RestoreRecord - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: folder.v3.remove.RestoreRecord, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** + * Encodes the specified ContinuationToken message. Does not implicitly {@link folder.v3.ContinuationToken.verify|verify} messages. + * @param message ContinuationToken message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: folder.v3.IContinuationToken, writer?: $protobuf.Writer): $protobuf.Writer; - /** - * Converts this RestoreRecord to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Decodes a ContinuationToken message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns ContinuationToken + * @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): folder.v3.ContinuationToken; - /** - * Gets the default type url for RestoreRecord - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Creates a ContinuationToken message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns ContinuationToken + */ + public static fromObject(object: { [k: string]: any }): folder.v3.ContinuationToken; - /** Properties of a RestoreFolder. */ - interface IRestoreFolder { + /** + * Creates a plain object from a ContinuationToken message. Also converts values to other types if specified. + * @param message ContinuationToken + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: folder.v3.ContinuationToken, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** RestoreFolder folderUid */ - folderUid?: (Uint8Array|null); + /** + * Converts this ContinuationToken to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** RestoreFolder encryptedFolderKey */ - encryptedFolderKey?: (Uint8Array|null); - } + /** + * Gets the default type url for ContinuationToken + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** Represents a RestoreFolder. */ - class RestoreFolder implements IRestoreFolder { + /** Properties of a GetFolderAccessResult. */ + interface IGetFolderAccessResult { - /** - * Constructs a new RestoreFolder. - * @param [properties] Properties to set - */ - constructor(properties?: folder.v3.remove.IRestoreFolder); + /** Folder UID this result applies to */ + folderUid?: (Uint8Array|null); - /** RestoreFolder folderUid. */ - public folderUid: Uint8Array; + /** List of users/teams with access to this folder (populated on success) */ + accessors?: (Folder.IFolderAccessData[]|null); - /** RestoreFolder encryptedFolderKey. */ - public encryptedFolderKey: Uint8Array; + /** Error information (populated on failure, mutually exclusive with accessors) */ + error?: (folder.v3.IFolderAccessError|null); + } - /** - * Creates a new RestoreFolder instance using the specified properties. - * @param [properties] Properties to set - * @returns RestoreFolder instance - */ - public static create(properties?: folder.v3.remove.IRestoreFolder): folder.v3.remove.RestoreFolder; + /** + * Result for a single folder. + * Contains either a list of accessors (success) or an error (failure). + */ + class GetFolderAccessResult implements IGetFolderAccessResult { - /** - * Encodes the specified RestoreFolder message. Does not implicitly {@link folder.v3.remove.RestoreFolder.verify|verify} messages. - * @param message RestoreFolder message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: folder.v3.remove.IRestoreFolder, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Constructs a new GetFolderAccessResult. + * @param [properties] Properties to set + */ + constructor(properties?: folder.v3.IGetFolderAccessResult); - /** - * Decodes a RestoreFolder message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns RestoreFolder - * @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): folder.v3.remove.RestoreFolder; + /** Folder UID this result applies to */ + public folderUid: Uint8Array; - /** - * Creates a RestoreFolder message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns RestoreFolder - */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.RestoreFolder; + /** List of users/teams with access to this folder (populated on success) */ + public accessors: Folder.IFolderAccessData[]; - /** - * Creates a plain object from a RestoreFolder message. Also converts values to other types if specified. - * @param message RestoreFolder - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: folder.v3.remove.RestoreFolder, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** Error information (populated on failure, mutually exclusive with accessors) */ + public error?: (folder.v3.IFolderAccessError|null); + + /** + * Creates a new GetFolderAccessResult instance using the specified properties. + * @param [properties] Properties to set + * @returns GetFolderAccessResult instance + */ + public static create(properties?: folder.v3.IGetFolderAccessResult): folder.v3.GetFolderAccessResult; + + /** + * Encodes the specified GetFolderAccessResult message. Does not implicitly {@link folder.v3.GetFolderAccessResult.verify|verify} messages. + * @param message GetFolderAccessResult message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: folder.v3.IGetFolderAccessResult, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a GetFolderAccessResult message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns GetFolderAccessResult + * @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): folder.v3.GetFolderAccessResult; - /** - * Converts this RestoreFolder to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Creates a GetFolderAccessResult message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns GetFolderAccessResult + */ + public static fromObject(object: { [k: string]: any }): folder.v3.GetFolderAccessResult; - /** - * Gets the default type url for RestoreFolder - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Creates a plain object from a GetFolderAccessResult message. Also converts values to other types if specified. + * @param message GetFolderAccessResult + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: folder.v3.GetFolderAccessResult, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** Properties of a TrashcanRestoreRequest. */ - interface ITrashcanRestoreRequest { + /** + * Converts this GetFolderAccessResult to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** TrashcanRestoreRequest records */ - records?: (folder.v3.remove.IRestoreRecord[]|null); + /** + * Gets the default type url for GetFolderAccessResult + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } - /** TrashcanRestoreRequest folders */ - folders?: (folder.v3.remove.IRestoreFolder[]|null); + /** Properties of a FolderAccessError. */ + interface IFolderAccessError { - /** TrashcanRestoreRequest targetFolderUid */ - targetFolderUid?: (Uint8Array|null); - } + /** Status code (e.g., NOT_FOUND, ACCESS_DENIED) */ + status?: (Folder.FolderModifyStatus|null); - /** Represents a TrashcanRestoreRequest. */ - class TrashcanRestoreRequest implements ITrashcanRestoreRequest { + /** Human-readable error message */ + message?: (string|null); + } - /** - * Constructs a new TrashcanRestoreRequest. - * @param [properties] Properties to set - */ - constructor(properties?: folder.v3.remove.ITrashcanRestoreRequest); + /** Error information for a folder that couldn't be processed. */ + class FolderAccessError implements IFolderAccessError { - /** TrashcanRestoreRequest records. */ - public records: folder.v3.remove.IRestoreRecord[]; + /** + * Constructs a new FolderAccessError. + * @param [properties] Properties to set + */ + constructor(properties?: folder.v3.IFolderAccessError); - /** TrashcanRestoreRequest folders. */ - public folders: folder.v3.remove.IRestoreFolder[]; + /** Status code (e.g., NOT_FOUND, ACCESS_DENIED) */ + public status: Folder.FolderModifyStatus; - /** TrashcanRestoreRequest targetFolderUid. */ - public targetFolderUid: Uint8Array; + /** Human-readable error message */ + public message: string; - /** - * Creates a new TrashcanRestoreRequest instance using the specified properties. - * @param [properties] Properties to set - * @returns TrashcanRestoreRequest instance - */ - public static create(properties?: folder.v3.remove.ITrashcanRestoreRequest): folder.v3.remove.TrashcanRestoreRequest; + /** + * Creates a new FolderAccessError instance using the specified properties. + * @param [properties] Properties to set + * @returns FolderAccessError instance + */ + public static create(properties?: folder.v3.IFolderAccessError): folder.v3.FolderAccessError; - /** - * Encodes the specified TrashcanRestoreRequest message. Does not implicitly {@link folder.v3.remove.TrashcanRestoreRequest.verify|verify} messages. - * @param message TrashcanRestoreRequest message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: folder.v3.remove.ITrashcanRestoreRequest, writer?: $protobuf.Writer): $protobuf.Writer; + /** + * Encodes the specified FolderAccessError message. Does not implicitly {@link folder.v3.FolderAccessError.verify|verify} messages. + * @param message FolderAccessError message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: folder.v3.IFolderAccessError, writer?: $protobuf.Writer): $protobuf.Writer; - /** - * Decodes a TrashcanRestoreRequest message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns TrashcanRestoreRequest - * @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): folder.v3.remove.TrashcanRestoreRequest; + /** + * Decodes a FolderAccessError message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns FolderAccessError + * @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): folder.v3.FolderAccessError; - /** - * Creates a TrashcanRestoreRequest message from a plain object. Also converts values to their respective internal types. - * @param object Plain object - * @returns TrashcanRestoreRequest - */ - public static fromObject(object: { [k: string]: any }): folder.v3.remove.TrashcanRestoreRequest; + /** + * Creates a FolderAccessError message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns FolderAccessError + */ + public static fromObject(object: { [k: string]: any }): folder.v3.FolderAccessError; - /** - * Creates a plain object from a TrashcanRestoreRequest message. Also converts values to other types if specified. - * @param message TrashcanRestoreRequest - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: folder.v3.remove.TrashcanRestoreRequest, options?: $protobuf.IConversionOptions): { [k: string]: any }; + /** + * Creates a plain object from a FolderAccessError message. Also converts values to other types if specified. + * @param message FolderAccessError + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: folder.v3.FolderAccessError, options?: $protobuf.IConversionOptions): { [k: string]: any }; - /** - * Converts this TrashcanRestoreRequest to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; + /** + * Converts this FolderAccessError to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; - /** - * Gets the default type url for TrashcanRestoreRequest - * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") - * @returns The default type url - */ - public static getTypeUrl(typeUrlPrefix?: string): string; - } + /** + * Gets the default type url for FolderAccessError + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; } } } @@ -92800,3 +94069,215 @@ export namespace Workflow { public static getTypeUrl(typeUrlPrefix?: string): string; } } + +/** Namespace keeper. */ +export namespace keeper { + + /** Namespace api. */ + namespace api { + + /** Namespace common. */ + namespace common { + + /** Properties of a Page. */ + interface IPage { + + /** + * Zero-indexed page number. + * Default: 0 (first page) + */ + pageNumber?: (number|null); + + /** Number of items per page. */ + pageSize?: (number|null); + + /** Use as cursor to the next page. */ + cursorToken?: (string|null); + } + + /** + * Pagination parameters for paginated requests. + * Used to specify which page of results to retrieve. + */ + class Page implements IPage { + + /** + * Constructs a new Page. + * @param [properties] Properties to set + */ + constructor(properties?: keeper.api.common.IPage); + + /** + * Zero-indexed page number. + * Default: 0 (first page) + */ + public pageNumber: number; + + /** Number of items per page. */ + public pageSize: number; + + /** Use as cursor to the next page. */ + public cursorToken: string; + + /** + * Creates a new Page instance using the specified properties. + * @param [properties] Properties to set + * @returns Page instance + */ + public static create(properties?: keeper.api.common.IPage): keeper.api.common.Page; + + /** + * Encodes the specified Page message. Does not implicitly {@link keeper.api.common.Page.verify|verify} messages. + * @param message Page message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: keeper.api.common.IPage, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a Page message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns Page + * @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): keeper.api.common.Page; + + /** + * Creates a Page message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns Page + */ + public static fromObject(object: { [k: string]: any }): keeper.api.common.Page; + + /** + * Creates a plain object from a Page message. Also converts values to other types if specified. + * @param message Page + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: keeper.api.common.Page, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this Page to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for Page + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + + /** Properties of a PageInfo. */ + interface IPageInfo { + + /** Current page number (zero-indexed). */ + pageNumber?: (number|null); + + /** Number of items per page. */ + pageSize?: (number|null); + + /** Total number of items available across all pages. */ + totalCount?: (number|null); + + /** + * Indicates whether more pages are available. + * True if (page_number + 1) * page_size < total_count + */ + hasMore?: (boolean|null); + + /** Use as cursor to the next page. */ + cursorToken?: (string|null); + } + + /** + * Pagination metadata included in paginated responses. + * Provides information about the current page and total available items. + */ + class PageInfo implements IPageInfo { + + /** + * Constructs a new PageInfo. + * @param [properties] Properties to set + */ + constructor(properties?: keeper.api.common.IPageInfo); + + /** Current page number (zero-indexed). */ + public pageNumber: number; + + /** Number of items per page. */ + public pageSize: number; + + /** Total number of items available across all pages. */ + public totalCount: number; + + /** + * Indicates whether more pages are available. + * True if (page_number + 1) * page_size < total_count + */ + public hasMore: boolean; + + /** Use as cursor to the next page. */ + public cursorToken: string; + + /** + * Creates a new PageInfo instance using the specified properties. + * @param [properties] Properties to set + * @returns PageInfo instance + */ + public static create(properties?: keeper.api.common.IPageInfo): keeper.api.common.PageInfo; + + /** + * Encodes the specified PageInfo message. Does not implicitly {@link keeper.api.common.PageInfo.verify|verify} messages. + * @param message PageInfo message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: keeper.api.common.IPageInfo, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a PageInfo message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns PageInfo + * @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): keeper.api.common.PageInfo; + + /** + * Creates a PageInfo message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns PageInfo + */ + public static fromObject(object: { [k: string]: any }): keeper.api.common.PageInfo; + + /** + * Creates a plain object from a PageInfo message. Also converts values to other types if specified. + * @param message PageInfo + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: keeper.api.common.PageInfo, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this PageInfo to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + + /** + * Gets the default type url for PageInfo + * @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns The default type url + */ + public static getTypeUrl(typeUrlPrefix?: string): string; + } + } + } +} diff --git a/keeperapi/src/proto/Remove.js b/keeperapi/src/proto/Remove.js index 82045ec2..a030f09a 100644 --- a/keeperapi/src/proto/Remove.js +++ b/keeperapi/src/proto/Remove.js @@ -4938,6 +4938,1255 @@ export const folder = $root.folder = (() => { return remove; })(); + v3.FolderService = (function() { + + /** + * Constructs a new FolderService service. + * @memberof folder.v3 + * @classdesc RPC service for folder operations. + * @extends $protobuf.rpc.Service + * @constructor + * @param {$protobuf.RPCImpl} rpcImpl RPC implementation + * @param {boolean} [requestDelimited=false] Whether requests are length-delimited + * @param {boolean} [responseDelimited=false] Whether responses are length-delimited + */ + function FolderService(rpcImpl, requestDelimited, responseDelimited) { + $protobuf.rpc.Service.call(this, rpcImpl, requestDelimited, responseDelimited); + } + + (FolderService.prototype = Object.create($protobuf.rpc.Service.prototype)).constructor = FolderService; + + /** + * Creates new FolderService service using the specified rpc implementation. + * @function create + * @memberof folder.v3.FolderService + * @static + * @param {$protobuf.RPCImpl} rpcImpl RPC implementation + * @param {boolean} [requestDelimited=false] Whether requests are length-delimited + * @param {boolean} [responseDelimited=false] Whether responses are length-delimited + * @returns {FolderService} RPC service. Useful where requests and/or responses are streamed. + */ + FolderService.create = function create(rpcImpl, requestDelimited, responseDelimited) { + return new this(rpcImpl, requestDelimited, responseDelimited); + }; + + /** + * Callback as used by {@link folder.v3.FolderService#getFolderAccess}. + * @memberof folder.v3.FolderService + * @typedef GetFolderAccessCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {folder.v3.GetFolderAccessResponse} [response] GetFolderAccessResponse + */ + + /** + * Retrieve users and teams with access to specified folders. + * Requires can_change_user_permissions permission or Share Admin/MC Admin privileges. + * @function getFolderAccess + * @memberof folder.v3.FolderService + * @instance + * @param {folder.v3.IGetFolderAccessRequest} request GetFolderAccessRequest message or plain object + * @param {folder.v3.FolderService.GetFolderAccessCallback} callback Node-style callback called with the error, if any, and GetFolderAccessResponse + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(FolderService.prototype.getFolderAccess = function getFolderAccess(request, callback) { + return $protobuf.rpc.Service.prototype.rpcCall.call(this, getFolderAccess, $root.folder.v3.GetFolderAccessRequest, $root.folder.v3.GetFolderAccessResponse, request, callback); + }, "name", { value: "GetFolderAccess" }); + + /** + * Retrieve users and teams with access to specified folders. + * Requires can_change_user_permissions permission or Share Admin/MC Admin privileges. + * @function getFolderAccess + * @memberof folder.v3.FolderService + * @instance + * @param {folder.v3.IGetFolderAccessRequest} request GetFolderAccessRequest message or plain object + * @returns {Promise} Promise + * @variation 2 + */ + + return FolderService; + })(); + + v3.GetFolderAccessRequest = (function() { + + /** + * Properties of a GetFolderAccessRequest. + * @memberof folder.v3 + * @interface IGetFolderAccessRequest + * @property {Array.|null} [folderUid] List of folder UIDs to query (max: 100) + * @property {folder.v3.IContinuationToken|null} [continuationToken] Continuation token for pagination. + * Contains the last_modified timestamp from the previous page. + * Omit for the first page. + * @property {number|null} [pageSize] Maximum number of accessors to return per page. + * Default: 100, Max: 1000 + */ + + /** + * Constructs a new GetFolderAccessRequest. + * @memberof folder.v3 + * @classdesc Request to retrieve folder accessors (users and teams with access to folders). + * Supports cursor-based pagination using last_modified timestamps. + * @implements IGetFolderAccessRequest + * @constructor + * @param {folder.v3.IGetFolderAccessRequest=} [properties] Properties to set + */ + function GetFolderAccessRequest(properties) { + this.folderUid = []; + 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]]; + } + + /** + * List of folder UIDs to query (max: 100) + * @member {Array.} folderUid + * @memberof folder.v3.GetFolderAccessRequest + * @instance + */ + GetFolderAccessRequest.prototype.folderUid = $util.emptyArray; + + /** + * Continuation token for pagination. + * Contains the last_modified timestamp from the previous page. + * Omit for the first page. + * @member {folder.v3.IContinuationToken|null|undefined} continuationToken + * @memberof folder.v3.GetFolderAccessRequest + * @instance + */ + GetFolderAccessRequest.prototype.continuationToken = null; + + /** + * Maximum number of accessors to return per page. + * Default: 100, Max: 1000 + * @member {number|null|undefined} pageSize + * @memberof folder.v3.GetFolderAccessRequest + * @instance + */ + GetFolderAccessRequest.prototype.pageSize = null; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + // Virtual OneOf for proto3 optional field + Object.defineProperty(GetFolderAccessRequest.prototype, "_continuationToken", { + get: $util.oneOfGetter($oneOfFields = ["continuationToken"]), + set: $util.oneOfSetter($oneOfFields) + }); + + // Virtual OneOf for proto3 optional field + Object.defineProperty(GetFolderAccessRequest.prototype, "_pageSize", { + get: $util.oneOfGetter($oneOfFields = ["pageSize"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Creates a new GetFolderAccessRequest instance using the specified properties. + * @function create + * @memberof folder.v3.GetFolderAccessRequest + * @static + * @param {folder.v3.IGetFolderAccessRequest=} [properties] Properties to set + * @returns {folder.v3.GetFolderAccessRequest} GetFolderAccessRequest instance + */ + GetFolderAccessRequest.create = function create(properties) { + return new GetFolderAccessRequest(properties); + }; + + /** + * Encodes the specified GetFolderAccessRequest message. Does not implicitly {@link folder.v3.GetFolderAccessRequest.verify|verify} messages. + * @function encode + * @memberof folder.v3.GetFolderAccessRequest + * @static + * @param {folder.v3.IGetFolderAccessRequest} message GetFolderAccessRequest message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + GetFolderAccessRequest.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.folderUid != null && message.folderUid.length) + for (let i = 0; i < message.folderUid.length; ++i) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.folderUid[i]); + if (message.continuationToken != null && Object.hasOwnProperty.call(message, "continuationToken")) + $root.folder.v3.ContinuationToken.encode(message.continuationToken, writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.pageSize != null && Object.hasOwnProperty.call(message, "pageSize")) + writer.uint32(/* id 3, wireType 0 =*/24).int32(message.pageSize); + return writer; + }; + + /** + * Decodes a GetFolderAccessRequest message from the specified reader or buffer. + * @function decode + * @memberof folder.v3.GetFolderAccessRequest + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {folder.v3.GetFolderAccessRequest} GetFolderAccessRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + GetFolderAccessRequest.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.folder.v3.GetFolderAccessRequest(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.folderUid && message.folderUid.length)) + message.folderUid = []; + message.folderUid.push(reader.bytes()); + break; + } + case 2: { + message.continuationToken = $root.folder.v3.ContinuationToken.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 3: { + message.pageSize = reader.int32(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a GetFolderAccessRequest message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof folder.v3.GetFolderAccessRequest + * @static + * @param {Object.} object Plain object + * @returns {folder.v3.GetFolderAccessRequest} GetFolderAccessRequest + */ + GetFolderAccessRequest.fromObject = function fromObject(object, long) { + if (object instanceof $root.folder.v3.GetFolderAccessRequest) + return object; + if (!$util.isObject(object)) + throw TypeError(".folder.v3.GetFolderAccessRequest: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.folder.v3.GetFolderAccessRequest(); + if (object.folderUid) { + if (!Array.isArray(object.folderUid)) + throw TypeError(".folder.v3.GetFolderAccessRequest.folderUid: array expected"); + message.folderUid = []; + for (let i = 0; i < object.folderUid.length; ++i) + if (typeof object.folderUid[i] === "string") + $util.base64.decode(object.folderUid[i], message.folderUid[i] = $util.newBuffer($util.base64.length(object.folderUid[i])), 0); + else if (object.folderUid[i].length >= 0) + message.folderUid[i] = object.folderUid[i]; + } + if (object.continuationToken != null) { + if (!$util.isObject(object.continuationToken)) + throw TypeError(".folder.v3.GetFolderAccessRequest.continuationToken: object expected"); + message.continuationToken = $root.folder.v3.ContinuationToken.fromObject(object.continuationToken, long + 1); + } + if (object.pageSize != null) + message.pageSize = object.pageSize | 0; + return message; + }; + + /** + * Creates a plain object from a GetFolderAccessRequest message. Also converts values to other types if specified. + * @function toObject + * @memberof folder.v3.GetFolderAccessRequest + * @static + * @param {folder.v3.GetFolderAccessRequest} message GetFolderAccessRequest + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + GetFolderAccessRequest.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.arrays || options.defaults) + object.folderUid = []; + if (message.folderUid && message.folderUid.length) { + object.folderUid = []; + for (let j = 0; j < message.folderUid.length; ++j) + object.folderUid[j] = options.bytes === String ? $util.base64.encode(message.folderUid[j], 0, message.folderUid[j].length) : options.bytes === Array ? Array.prototype.slice.call(message.folderUid[j]) : message.folderUid[j]; + } + if (message.continuationToken != null && Object.hasOwnProperty.call(message, "continuationToken")) { + object.continuationToken = $root.folder.v3.ContinuationToken.toObject(message.continuationToken, options, q + 1); + if (options.oneofs) + object._continuationToken = "continuationToken"; + } + if (message.pageSize != null && Object.hasOwnProperty.call(message, "pageSize")) { + object.pageSize = message.pageSize; + if (options.oneofs) + object._pageSize = "pageSize"; + } + return object; + }; + + /** + * Converts this GetFolderAccessRequest to JSON. + * @function toJSON + * @memberof folder.v3.GetFolderAccessRequest + * @instance + * @returns {Object.} JSON object + */ + GetFolderAccessRequest.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for GetFolderAccessRequest + * @function getTypeUrl + * @memberof folder.v3.GetFolderAccessRequest + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + GetFolderAccessRequest.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/folder.v3.GetFolderAccessRequest"; + }; + + return GetFolderAccessRequest; + })(); + + v3.GetFolderAccessResponse = (function() { + + /** + * Properties of a GetFolderAccessResponse. + * @memberof folder.v3 + * @interface IGetFolderAccessResponse + * @property {Array.|null} [folderAccessResults] Per-folder results (either success with accessors or error) + * @property {folder.v3.IContinuationToken|null} [continuationToken] Continuation token for the next page (only present if hasMore is true) + * @property {boolean|null} [hasMore] True if more results exist beyond this page + */ + + /** + * Constructs a new GetFolderAccessResponse. + * @memberof folder.v3 + * @classdesc Response containing folder accessors with pagination support. + * @implements IGetFolderAccessResponse + * @constructor + * @param {folder.v3.IGetFolderAccessResponse=} [properties] Properties to set + */ + function GetFolderAccessResponse(properties) { + this.folderAccessResults = []; + 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]]; + } + + /** + * Per-folder results (either success with accessors or error) + * @member {Array.} folderAccessResults + * @memberof folder.v3.GetFolderAccessResponse + * @instance + */ + GetFolderAccessResponse.prototype.folderAccessResults = $util.emptyArray; + + /** + * Continuation token for the next page (only present if hasMore is true) + * @member {folder.v3.IContinuationToken|null|undefined} continuationToken + * @memberof folder.v3.GetFolderAccessResponse + * @instance + */ + GetFolderAccessResponse.prototype.continuationToken = null; + + /** + * True if more results exist beyond this page + * @member {boolean} hasMore + * @memberof folder.v3.GetFolderAccessResponse + * @instance + */ + GetFolderAccessResponse.prototype.hasMore = false; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + // Virtual OneOf for proto3 optional field + Object.defineProperty(GetFolderAccessResponse.prototype, "_continuationToken", { + get: $util.oneOfGetter($oneOfFields = ["continuationToken"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Creates a new GetFolderAccessResponse instance using the specified properties. + * @function create + * @memberof folder.v3.GetFolderAccessResponse + * @static + * @param {folder.v3.IGetFolderAccessResponse=} [properties] Properties to set + * @returns {folder.v3.GetFolderAccessResponse} GetFolderAccessResponse instance + */ + GetFolderAccessResponse.create = function create(properties) { + return new GetFolderAccessResponse(properties); + }; + + /** + * Encodes the specified GetFolderAccessResponse message. Does not implicitly {@link folder.v3.GetFolderAccessResponse.verify|verify} messages. + * @function encode + * @memberof folder.v3.GetFolderAccessResponse + * @static + * @param {folder.v3.IGetFolderAccessResponse} message GetFolderAccessResponse message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + GetFolderAccessResponse.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.folderAccessResults != null && message.folderAccessResults.length) + for (let i = 0; i < message.folderAccessResults.length; ++i) + $root.folder.v3.GetFolderAccessResult.encode(message.folderAccessResults[i], writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.continuationToken != null && Object.hasOwnProperty.call(message, "continuationToken")) + $root.folder.v3.ContinuationToken.encode(message.continuationToken, writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.hasMore != null && Object.hasOwnProperty.call(message, "hasMore")) + writer.uint32(/* id 3, wireType 0 =*/24).bool(message.hasMore); + return writer; + }; + + /** + * Decodes a GetFolderAccessResponse message from the specified reader or buffer. + * @function decode + * @memberof folder.v3.GetFolderAccessResponse + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {folder.v3.GetFolderAccessResponse} GetFolderAccessResponse + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + GetFolderAccessResponse.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.folder.v3.GetFolderAccessResponse(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.folderAccessResults && message.folderAccessResults.length)) + message.folderAccessResults = []; + message.folderAccessResults.push($root.folder.v3.GetFolderAccessResult.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 2: { + message.continuationToken = $root.folder.v3.ContinuationToken.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 3: { + message.hasMore = reader.bool(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a GetFolderAccessResponse message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof folder.v3.GetFolderAccessResponse + * @static + * @param {Object.} object Plain object + * @returns {folder.v3.GetFolderAccessResponse} GetFolderAccessResponse + */ + GetFolderAccessResponse.fromObject = function fromObject(object, long) { + if (object instanceof $root.folder.v3.GetFolderAccessResponse) + return object; + if (!$util.isObject(object)) + throw TypeError(".folder.v3.GetFolderAccessResponse: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.folder.v3.GetFolderAccessResponse(); + if (object.folderAccessResults) { + if (!Array.isArray(object.folderAccessResults)) + throw TypeError(".folder.v3.GetFolderAccessResponse.folderAccessResults: array expected"); + message.folderAccessResults = []; + for (let i = 0; i < object.folderAccessResults.length; ++i) { + if (!$util.isObject(object.folderAccessResults[i])) + throw TypeError(".folder.v3.GetFolderAccessResponse.folderAccessResults: object expected"); + message.folderAccessResults[i] = $root.folder.v3.GetFolderAccessResult.fromObject(object.folderAccessResults[i], long + 1); + } + } + if (object.continuationToken != null) { + if (!$util.isObject(object.continuationToken)) + throw TypeError(".folder.v3.GetFolderAccessResponse.continuationToken: object expected"); + message.continuationToken = $root.folder.v3.ContinuationToken.fromObject(object.continuationToken, long + 1); + } + if (object.hasMore != null) + message.hasMore = Boolean(object.hasMore); + return message; + }; + + /** + * Creates a plain object from a GetFolderAccessResponse message. Also converts values to other types if specified. + * @function toObject + * @memberof folder.v3.GetFolderAccessResponse + * @static + * @param {folder.v3.GetFolderAccessResponse} message GetFolderAccessResponse + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + GetFolderAccessResponse.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.arrays || options.defaults) + object.folderAccessResults = []; + if (options.defaults) + object.hasMore = false; + if (message.folderAccessResults && message.folderAccessResults.length) { + object.folderAccessResults = []; + for (let j = 0; j < message.folderAccessResults.length; ++j) + object.folderAccessResults[j] = $root.folder.v3.GetFolderAccessResult.toObject(message.folderAccessResults[j], options, q + 1); + } + if (message.continuationToken != null && Object.hasOwnProperty.call(message, "continuationToken")) { + object.continuationToken = $root.folder.v3.ContinuationToken.toObject(message.continuationToken, options, q + 1); + if (options.oneofs) + object._continuationToken = "continuationToken"; + } + if (message.hasMore != null && Object.hasOwnProperty.call(message, "hasMore")) + object.hasMore = message.hasMore; + return object; + }; + + /** + * Converts this GetFolderAccessResponse to JSON. + * @function toJSON + * @memberof folder.v3.GetFolderAccessResponse + * @instance + * @returns {Object.} JSON object + */ + GetFolderAccessResponse.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for GetFolderAccessResponse + * @function getTypeUrl + * @memberof folder.v3.GetFolderAccessResponse + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + GetFolderAccessResponse.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/folder.v3.GetFolderAccessResponse"; + }; + + return GetFolderAccessResponse; + })(); + + v3.ContinuationToken = (function() { + + /** + * Properties of a ContinuationToken. + * @memberof folder.v3 + * @interface IContinuationToken + * @property {number|null} [lastModified] Unix timestamp in milliseconds of the last processed accessor + */ + + /** + * Constructs a new ContinuationToken. + * @memberof folder.v3 + * @classdesc Cursor for cursor-based pagination. + * Contains the timestamp of the last processed item. + * @implements IContinuationToken + * @constructor + * @param {folder.v3.IContinuationToken=} [properties] Properties to set + */ + function ContinuationToken(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]]; + } + + /** + * Unix timestamp in milliseconds of the last processed accessor + * @member {number} lastModified + * @memberof folder.v3.ContinuationToken + * @instance + */ + ContinuationToken.prototype.lastModified = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * Creates a new ContinuationToken instance using the specified properties. + * @function create + * @memberof folder.v3.ContinuationToken + * @static + * @param {folder.v3.IContinuationToken=} [properties] Properties to set + * @returns {folder.v3.ContinuationToken} ContinuationToken instance + */ + ContinuationToken.create = function create(properties) { + return new ContinuationToken(properties); + }; + + /** + * Encodes the specified ContinuationToken message. Does not implicitly {@link folder.v3.ContinuationToken.verify|verify} messages. + * @function encode + * @memberof folder.v3.ContinuationToken + * @static + * @param {folder.v3.IContinuationToken} message ContinuationToken message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + ContinuationToken.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.lastModified != null && Object.hasOwnProperty.call(message, "lastModified")) + writer.uint32(/* id 1, wireType 0 =*/8).int64(message.lastModified); + return writer; + }; + + /** + * Decodes a ContinuationToken message from the specified reader or buffer. + * @function decode + * @memberof folder.v3.ContinuationToken + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {folder.v3.ContinuationToken} ContinuationToken + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + ContinuationToken.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.folder.v3.ContinuationToken(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.lastModified = reader.int64(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a ContinuationToken message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof folder.v3.ContinuationToken + * @static + * @param {Object.} object Plain object + * @returns {folder.v3.ContinuationToken} ContinuationToken + */ + ContinuationToken.fromObject = function fromObject(object, long) { + if (object instanceof $root.folder.v3.ContinuationToken) + return object; + if (!$util.isObject(object)) + throw TypeError(".folder.v3.ContinuationToken: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.folder.v3.ContinuationToken(); + if (object.lastModified != null) + if ($util.Long) + message.lastModified = $util.Long.fromValue(object.lastModified, false); + else if (typeof object.lastModified === "string") + message.lastModified = parseInt(object.lastModified, 10); + else if (typeof object.lastModified === "number") + message.lastModified = object.lastModified; + else if (typeof object.lastModified === "object") + message.lastModified = new $util.LongBits(object.lastModified.low >>> 0, object.lastModified.high >>> 0).toNumber(); + return message; + }; + + /** + * Creates a plain object from a ContinuationToken message. Also converts values to other types if specified. + * @function toObject + * @memberof folder.v3.ContinuationToken + * @static + * @param {folder.v3.ContinuationToken} message ContinuationToken + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + ContinuationToken.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.lastModified = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.lastModified = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + if (message.lastModified != null && Object.hasOwnProperty.call(message, "lastModified")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.lastModified = typeof message.lastModified === "number" ? BigInt(message.lastModified) : $util.Long.fromBits(message.lastModified.low >>> 0, message.lastModified.high >>> 0, false).toBigInt(); + else if (typeof message.lastModified === "number") + object.lastModified = options.longs === String ? String(message.lastModified) : message.lastModified; + else + object.lastModified = options.longs === String ? $util.Long.prototype.toString.call(message.lastModified) : options.longs === Number ? new $util.LongBits(message.lastModified.low >>> 0, message.lastModified.high >>> 0).toNumber() : message.lastModified; + return object; + }; + + /** + * Converts this ContinuationToken to JSON. + * @function toJSON + * @memberof folder.v3.ContinuationToken + * @instance + * @returns {Object.} JSON object + */ + ContinuationToken.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for ContinuationToken + * @function getTypeUrl + * @memberof folder.v3.ContinuationToken + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + ContinuationToken.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/folder.v3.ContinuationToken"; + }; + + return ContinuationToken; + })(); + + v3.GetFolderAccessResult = (function() { + + /** + * Properties of a GetFolderAccessResult. + * @memberof folder.v3 + * @interface IGetFolderAccessResult + * @property {Uint8Array|null} [folderUid] Folder UID this result applies to + * @property {Array.|null} [accessors] List of users/teams with access to this folder (populated on success) + * @property {folder.v3.IFolderAccessError|null} [error] Error information (populated on failure, mutually exclusive with accessors) + */ + + /** + * Constructs a new GetFolderAccessResult. + * @memberof folder.v3 + * @classdesc Result for a single folder. + * Contains either a list of accessors (success) or an error (failure). + * @implements IGetFolderAccessResult + * @constructor + * @param {folder.v3.IGetFolderAccessResult=} [properties] Properties to set + */ + function GetFolderAccessResult(properties) { + this.accessors = []; + 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]]; + } + + /** + * Folder UID this result applies to + * @member {Uint8Array} folderUid + * @memberof folder.v3.GetFolderAccessResult + * @instance + */ + GetFolderAccessResult.prototype.folderUid = $util.newBuffer([]); + + /** + * List of users/teams with access to this folder (populated on success) + * @member {Array.} accessors + * @memberof folder.v3.GetFolderAccessResult + * @instance + */ + GetFolderAccessResult.prototype.accessors = $util.emptyArray; + + /** + * Error information (populated on failure, mutually exclusive with accessors) + * @member {folder.v3.IFolderAccessError|null|undefined} error + * @memberof folder.v3.GetFolderAccessResult + * @instance + */ + GetFolderAccessResult.prototype.error = null; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + // Virtual OneOf for proto3 optional field + Object.defineProperty(GetFolderAccessResult.prototype, "_error", { + get: $util.oneOfGetter($oneOfFields = ["error"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Creates a new GetFolderAccessResult instance using the specified properties. + * @function create + * @memberof folder.v3.GetFolderAccessResult + * @static + * @param {folder.v3.IGetFolderAccessResult=} [properties] Properties to set + * @returns {folder.v3.GetFolderAccessResult} GetFolderAccessResult instance + */ + GetFolderAccessResult.create = function create(properties) { + return new GetFolderAccessResult(properties); + }; + + /** + * Encodes the specified GetFolderAccessResult message. Does not implicitly {@link folder.v3.GetFolderAccessResult.verify|verify} messages. + * @function encode + * @memberof folder.v3.GetFolderAccessResult + * @static + * @param {folder.v3.IGetFolderAccessResult} message GetFolderAccessResult message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + GetFolderAccessResult.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.folderUid != null && Object.hasOwnProperty.call(message, "folderUid")) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.folderUid); + if (message.accessors != null && message.accessors.length) + for (let i = 0; i < message.accessors.length; ++i) + $root.Folder.FolderAccessData.encode(message.accessors[i], writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.error != null && Object.hasOwnProperty.call(message, "error")) + $root.folder.v3.FolderAccessError.encode(message.error, writer.uint32(/* id 3, wireType 2 =*/26).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a GetFolderAccessResult message from the specified reader or buffer. + * @function decode + * @memberof folder.v3.GetFolderAccessResult + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {folder.v3.GetFolderAccessResult} GetFolderAccessResult + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + GetFolderAccessResult.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.folder.v3.GetFolderAccessResult(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.folderUid = reader.bytes(); + break; + } + case 2: { + if (!(message.accessors && message.accessors.length)) + message.accessors = []; + message.accessors.push($root.Folder.FolderAccessData.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 3: { + message.error = $root.folder.v3.FolderAccessError.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a GetFolderAccessResult message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof folder.v3.GetFolderAccessResult + * @static + * @param {Object.} object Plain object + * @returns {folder.v3.GetFolderAccessResult} GetFolderAccessResult + */ + GetFolderAccessResult.fromObject = function fromObject(object, long) { + if (object instanceof $root.folder.v3.GetFolderAccessResult) + return object; + if (!$util.isObject(object)) + throw TypeError(".folder.v3.GetFolderAccessResult: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.folder.v3.GetFolderAccessResult(); + if (object.folderUid != null) + if (typeof object.folderUid === "string") + $util.base64.decode(object.folderUid, message.folderUid = $util.newBuffer($util.base64.length(object.folderUid)), 0); + else if (object.folderUid.length >= 0) + message.folderUid = object.folderUid; + if (object.accessors) { + if (!Array.isArray(object.accessors)) + throw TypeError(".folder.v3.GetFolderAccessResult.accessors: array expected"); + message.accessors = []; + for (let i = 0; i < object.accessors.length; ++i) { + if (!$util.isObject(object.accessors[i])) + throw TypeError(".folder.v3.GetFolderAccessResult.accessors: object expected"); + message.accessors[i] = $root.Folder.FolderAccessData.fromObject(object.accessors[i], long + 1); + } + } + if (object.error != null) { + if (!$util.isObject(object.error)) + throw TypeError(".folder.v3.GetFolderAccessResult.error: object expected"); + message.error = $root.folder.v3.FolderAccessError.fromObject(object.error, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a GetFolderAccessResult message. Also converts values to other types if specified. + * @function toObject + * @memberof folder.v3.GetFolderAccessResult + * @static + * @param {folder.v3.GetFolderAccessResult} message GetFolderAccessResult + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + GetFolderAccessResult.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.arrays || options.defaults) + object.accessors = []; + if (options.defaults) + if (options.bytes === String) + object.folderUid = ""; + else { + object.folderUid = []; + if (options.bytes !== Array) + object.folderUid = $util.newBuffer(object.folderUid); + } + 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; + if (message.accessors && message.accessors.length) { + object.accessors = []; + for (let j = 0; j < message.accessors.length; ++j) + object.accessors[j] = $root.Folder.FolderAccessData.toObject(message.accessors[j], options, q + 1); + } + if (message.error != null && Object.hasOwnProperty.call(message, "error")) { + object.error = $root.folder.v3.FolderAccessError.toObject(message.error, options, q + 1); + if (options.oneofs) + object._error = "error"; + } + return object; + }; + + /** + * Converts this GetFolderAccessResult to JSON. + * @function toJSON + * @memberof folder.v3.GetFolderAccessResult + * @instance + * @returns {Object.} JSON object + */ + GetFolderAccessResult.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for GetFolderAccessResult + * @function getTypeUrl + * @memberof folder.v3.GetFolderAccessResult + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + GetFolderAccessResult.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/folder.v3.GetFolderAccessResult"; + }; + + return GetFolderAccessResult; + })(); + + v3.FolderAccessError = (function() { + + /** + * Properties of a FolderAccessError. + * @memberof folder.v3 + * @interface IFolderAccessError + * @property {Folder.FolderModifyStatus|null} [status] Status code (e.g., NOT_FOUND, ACCESS_DENIED) + * @property {string|null} [message] Human-readable error message + */ + + /** + * Constructs a new FolderAccessError. + * @memberof folder.v3 + * @classdesc Error information for a folder that couldn't be processed. + * @implements IFolderAccessError + * @constructor + * @param {folder.v3.IFolderAccessError=} [properties] Properties to set + */ + function FolderAccessError(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]]; + } + + /** + * Status code (e.g., NOT_FOUND, ACCESS_DENIED) + * @member {Folder.FolderModifyStatus} status + * @memberof folder.v3.FolderAccessError + * @instance + */ + FolderAccessError.prototype.status = 0; + + /** + * Human-readable error message + * @member {string} message + * @memberof folder.v3.FolderAccessError + * @instance + */ + FolderAccessError.prototype.message = ""; + + /** + * Creates a new FolderAccessError instance using the specified properties. + * @function create + * @memberof folder.v3.FolderAccessError + * @static + * @param {folder.v3.IFolderAccessError=} [properties] Properties to set + * @returns {folder.v3.FolderAccessError} FolderAccessError instance + */ + FolderAccessError.create = function create(properties) { + return new FolderAccessError(properties); + }; + + /** + * Encodes the specified FolderAccessError message. Does not implicitly {@link folder.v3.FolderAccessError.verify|verify} messages. + * @function encode + * @memberof folder.v3.FolderAccessError + * @static + * @param {folder.v3.IFolderAccessError} message FolderAccessError message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + FolderAccessError.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.status != null && Object.hasOwnProperty.call(message, "status")) + writer.uint32(/* id 1, wireType 0 =*/8).int32(message.status); + if (message.message != null && Object.hasOwnProperty.call(message, "message")) + writer.uint32(/* id 2, wireType 2 =*/18).string(message.message); + return writer; + }; + + /** + * Decodes a FolderAccessError message from the specified reader or buffer. + * @function decode + * @memberof folder.v3.FolderAccessError + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {folder.v3.FolderAccessError} FolderAccessError + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + FolderAccessError.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.folder.v3.FolderAccessError(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.status = reader.int32(); + break; + } + case 2: { + message.message = reader.string(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a FolderAccessError message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof folder.v3.FolderAccessError + * @static + * @param {Object.} object Plain object + * @returns {folder.v3.FolderAccessError} FolderAccessError + */ + FolderAccessError.fromObject = function fromObject(object, long) { + if (object instanceof $root.folder.v3.FolderAccessError) + return object; + if (!$util.isObject(object)) + throw TypeError(".folder.v3.FolderAccessError: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.folder.v3.FolderAccessError(); + switch (object.status) { + default: + if (typeof object.status === "number") { + message.status = object.status; + break; + } + break; + case "SUCCESS": + case 0: + message.status = 0; + break; + case "BAD_REQUEST": + case 1: + message.status = 1; + break; + case "ACCESS_DENIED": + case 2: + message.status = 2; + break; + case "NOT_FOUND": + case 3: + message.status = 3; + break; + } + if (object.message != null) + message.message = String(object.message); + return message; + }; + + /** + * Creates a plain object from a FolderAccessError message. Also converts values to other types if specified. + * @function toObject + * @memberof folder.v3.FolderAccessError + * @static + * @param {folder.v3.FolderAccessError} message FolderAccessError + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + FolderAccessError.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.status = options.enums === String ? "SUCCESS" : 0; + object.message = ""; + } + if (message.status != null && Object.hasOwnProperty.call(message, "status")) + object.status = options.enums === String ? $root.Folder.FolderModifyStatus[message.status] === undefined ? message.status : $root.Folder.FolderModifyStatus[message.status] : message.status; + if (message.message != null && Object.hasOwnProperty.call(message, "message")) + object.message = message.message; + return object; + }; + + /** + * Converts this FolderAccessError to JSON. + * @function toJSON + * @memberof folder.v3.FolderAccessError + * @instance + * @returns {Object.} JSON object + */ + FolderAccessError.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for FolderAccessError + * @function getTypeUrl + * @memberof folder.v3.FolderAccessError + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + FolderAccessError.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/folder.v3.FolderAccessError"; + }; + + return FolderAccessError; + })(); + return v3; })(); diff --git a/keeperapi/src/proto/Router.js b/keeperapi/src/proto/Router.js index 58dd9eaa..cc89f289 100644 --- a/keeperapi/src/proto/Router.js +++ b/keeperapi/src/proto/Router.js @@ -2358,6 +2358,7 @@ export const Router = $root.Router = (() => { * @property {string|null} [scriptName] RouterRotationInfo scriptName * @property {string|null} [pwdComplexity] RouterRotationInfo pwdComplexity * @property {boolean|null} [disabled] RouterRotationInfo disabled + * @property {Array.|null} [scripts] RouterRotationInfo scripts */ /** @@ -2369,6 +2370,7 @@ export const Router = $root.Router = (() => { * @param {Router.IRouterRotationInfo=} [properties] Properties to set */ function RouterRotationInfo(properties) { + this.scripts = []; if (properties) for (let keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null && keys[i] !== "__proto__") @@ -2447,6 +2449,14 @@ export const Router = $root.Router = (() => { */ RouterRotationInfo.prototype.disabled = false; + /** + * RouterRotationInfo scripts. + * @member {Array.} scripts + * @memberof Router.RouterRotationInfo + * @instance + */ + RouterRotationInfo.prototype.scripts = $util.emptyArray; + /** * Creates a new RouterRotationInfo instance using the specified properties. * @function create @@ -2493,6 +2503,9 @@ export const Router = $root.Router = (() => { writer.uint32(/* id 8, wireType 2 =*/66).string(message.pwdComplexity); if (message.disabled != null && Object.hasOwnProperty.call(message, "disabled")) writer.uint32(/* id 9, wireType 0 =*/72).bool(message.disabled); + if (message.scripts != null && message.scripts.length) + for (let i = 0; i < message.scripts.length; ++i) + writer.uint32(/* id 10, wireType 2 =*/82).bytes(message.scripts[i]); return writer; }; @@ -2556,6 +2569,12 @@ export const Router = $root.Router = (() => { message.disabled = reader.bool(); break; } + case 10: { + if (!(message.scripts && message.scripts.length)) + message.scripts = []; + message.scripts.push(reader.bytes()); + break; + } default: reader.skipType(tag & 7, long); break; @@ -2638,6 +2657,16 @@ export const Router = $root.Router = (() => { message.pwdComplexity = String(object.pwdComplexity); if (object.disabled != null) message.disabled = Boolean(object.disabled); + if (object.scripts) { + if (!Array.isArray(object.scripts)) + throw TypeError(".Router.RouterRotationInfo.scripts: array expected"); + message.scripts = []; + for (let i = 0; i < object.scripts.length; ++i) + if (typeof object.scripts[i] === "string") + $util.base64.decode(object.scripts[i], message.scripts[i] = $util.newBuffer($util.base64.length(object.scripts[i])), 0); + else if (object.scripts[i].length >= 0) + message.scripts[i] = object.scripts[i]; + } return message; }; @@ -2658,6 +2687,8 @@ export const Router = $root.Router = (() => { if (q > $util.recursionLimit) throw Error("max depth exceeded"); let object = {}; + if (options.arrays || options.defaults) + object.scripts = []; if (options.defaults) { object.status = options.enums === String ? "RRS_ONLINE" : 0; if (options.bytes === String) @@ -2714,6 +2745,11 @@ export const Router = $root.Router = (() => { object.pwdComplexity = message.pwdComplexity; if (message.disabled != null && Object.hasOwnProperty.call(message, "disabled")) object.disabled = message.disabled; + if (message.scripts && message.scripts.length) { + object.scripts = []; + for (let j = 0; j < message.scripts.length; ++j) + object.scripts[j] = options.bytes === String ? $util.base64.encode(message.scripts[j], 0, message.scripts[j].length) : options.bytes === Array ? Array.prototype.slice.call(message.scripts[j]) : message.scripts[j]; + } return object; }; diff --git a/keeperapi/src/proto/Workflow.js b/keeperapi/src/proto/Workflow.js index 5cb05206..b2e5b099 100644 --- a/keeperapi/src/proto/Workflow.js +++ b/keeperapi/src/proto/Workflow.js @@ -4594,4 +4594,3 @@ export const Workflow = $root.Workflow = (() => { return Workflow; })(); - diff --git a/keeperapi/src/proto/index.js b/keeperapi/src/proto/index.js index f1788c17..25b1a451 100644 --- a/keeperapi/src/proto/index.js +++ b/keeperapi/src/proto/index.js @@ -24,3 +24,4 @@ export { Router } from './Router.js'; export { PAM } from './PAM.js'; export { folder } from './Remove.js'; export { Workflow } from './Workflow.js'; +export { keeper } from './keeper.js'; diff --git a/keeperapi/src/proto/keeper.js b/keeperapi/src/proto/keeper.js new file mode 100644 index 00000000..68b56721 --- /dev/null +++ b/keeperapi/src/proto/keeper.js @@ -0,0 +1,521 @@ +/*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars*/ +import { $protobuf, $Reader, $Writer, $util, $root } from './root.js'; + +export const keeper = $root.keeper = (() => { + + /** + * Namespace keeper. + * @exports keeper + * @namespace + */ + const keeper = {}; + + keeper.api = (function() { + + /** + * Namespace api. + * @memberof keeper + * @namespace + */ + const api = {}; + + api.common = (function() { + + /** + * Namespace common. + * @memberof keeper.api + * @namespace + */ + const common = {}; + + common.Page = (function() { + + /** + * Properties of a Page. + * @memberof keeper.api.common + * @interface IPage + * @property {number|null} [pageNumber] Zero-indexed page number. + * Default: 0 (first page) + * @property {number|null} [pageSize] Number of items per page. + * @property {string|null} [cursorToken] Use as cursor to the next page. + */ + + /** + * Constructs a new Page. + * @memberof keeper.api.common + * @classdesc Pagination parameters for paginated requests. + * Used to specify which page of results to retrieve. + * @implements IPage + * @constructor + * @param {keeper.api.common.IPage=} [properties] Properties to set + */ + function Page(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]]; + } + + /** + * Zero-indexed page number. + * Default: 0 (first page) + * @member {number} pageNumber + * @memberof keeper.api.common.Page + * @instance + */ + Page.prototype.pageNumber = 0; + + /** + * Number of items per page. + * @member {number} pageSize + * @memberof keeper.api.common.Page + * @instance + */ + Page.prototype.pageSize = 0; + + /** + * Use as cursor to the next page. + * @member {string} cursorToken + * @memberof keeper.api.common.Page + * @instance + */ + Page.prototype.cursorToken = ""; + + /** + * Creates a new Page instance using the specified properties. + * @function create + * @memberof keeper.api.common.Page + * @static + * @param {keeper.api.common.IPage=} [properties] Properties to set + * @returns {keeper.api.common.Page} Page instance + */ + Page.create = function create(properties) { + return new Page(properties); + }; + + /** + * Encodes the specified Page message. Does not implicitly {@link keeper.api.common.Page.verify|verify} messages. + * @function encode + * @memberof keeper.api.common.Page + * @static + * @param {keeper.api.common.IPage} message Page message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + Page.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.pageNumber != null && Object.hasOwnProperty.call(message, "pageNumber")) + writer.uint32(/* id 1, wireType 0 =*/8).int32(message.pageNumber); + if (message.pageSize != null && Object.hasOwnProperty.call(message, "pageSize")) + writer.uint32(/* id 2, wireType 0 =*/16).int32(message.pageSize); + if (message.cursorToken != null && Object.hasOwnProperty.call(message, "cursorToken")) + writer.uint32(/* id 3, wireType 2 =*/26).string(message.cursorToken); + return writer; + }; + + /** + * Decodes a Page message from the specified reader or buffer. + * @function decode + * @memberof keeper.api.common.Page + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {keeper.api.common.Page} Page + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + Page.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.keeper.api.common.Page(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.pageNumber = reader.int32(); + break; + } + case 2: { + message.pageSize = reader.int32(); + break; + } + case 3: { + message.cursorToken = reader.string(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a Page message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof keeper.api.common.Page + * @static + * @param {Object.} object Plain object + * @returns {keeper.api.common.Page} Page + */ + Page.fromObject = function fromObject(object, long) { + if (object instanceof $root.keeper.api.common.Page) + return object; + if (!$util.isObject(object)) + throw TypeError(".keeper.api.common.Page: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.keeper.api.common.Page(); + if (object.pageNumber != null) + message.pageNumber = object.pageNumber | 0; + if (object.pageSize != null) + message.pageSize = object.pageSize | 0; + if (object.cursorToken != null) + message.cursorToken = String(object.cursorToken); + return message; + }; + + /** + * Creates a plain object from a Page message. Also converts values to other types if specified. + * @function toObject + * @memberof keeper.api.common.Page + * @static + * @param {keeper.api.common.Page} message Page + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + Page.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.pageNumber = 0; + object.pageSize = 0; + object.cursorToken = ""; + } + if (message.pageNumber != null && Object.hasOwnProperty.call(message, "pageNumber")) + object.pageNumber = message.pageNumber; + if (message.pageSize != null && Object.hasOwnProperty.call(message, "pageSize")) + object.pageSize = message.pageSize; + if (message.cursorToken != null && Object.hasOwnProperty.call(message, "cursorToken")) + object.cursorToken = message.cursorToken; + return object; + }; + + /** + * Converts this Page to JSON. + * @function toJSON + * @memberof keeper.api.common.Page + * @instance + * @returns {Object.} JSON object + */ + Page.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for Page + * @function getTypeUrl + * @memberof keeper.api.common.Page + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + Page.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/keeper.api.common.Page"; + }; + + return Page; + })(); + + common.PageInfo = (function() { + + /** + * Properties of a PageInfo. + * @memberof keeper.api.common + * @interface IPageInfo + * @property {number|null} [pageNumber] Current page number (zero-indexed). + * @property {number|null} [pageSize] Number of items per page. + * @property {number|null} [totalCount] Total number of items available across all pages. + * @property {boolean|null} [hasMore] Indicates whether more pages are available. + * True if (page_number + 1) * page_size < total_count + * @property {string|null} [cursorToken] Use as cursor to the next page. + */ + + /** + * Constructs a new PageInfo. + * @memberof keeper.api.common + * @classdesc Pagination metadata included in paginated responses. + * Provides information about the current page and total available items. + * @implements IPageInfo + * @constructor + * @param {keeper.api.common.IPageInfo=} [properties] Properties to set + */ + function PageInfo(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]]; + } + + /** + * Current page number (zero-indexed). + * @member {number} pageNumber + * @memberof keeper.api.common.PageInfo + * @instance + */ + PageInfo.prototype.pageNumber = 0; + + /** + * Number of items per page. + * @member {number} pageSize + * @memberof keeper.api.common.PageInfo + * @instance + */ + PageInfo.prototype.pageSize = 0; + + /** + * Total number of items available across all pages. + * @member {number} totalCount + * @memberof keeper.api.common.PageInfo + * @instance + */ + PageInfo.prototype.totalCount = 0; + + /** + * Indicates whether more pages are available. + * True if (page_number + 1) * page_size < total_count + * @member {boolean} hasMore + * @memberof keeper.api.common.PageInfo + * @instance + */ + PageInfo.prototype.hasMore = false; + + /** + * Use as cursor to the next page. + * @member {string} cursorToken + * @memberof keeper.api.common.PageInfo + * @instance + */ + PageInfo.prototype.cursorToken = ""; + + /** + * Creates a new PageInfo instance using the specified properties. + * @function create + * @memberof keeper.api.common.PageInfo + * @static + * @param {keeper.api.common.IPageInfo=} [properties] Properties to set + * @returns {keeper.api.common.PageInfo} PageInfo instance + */ + PageInfo.create = function create(properties) { + return new PageInfo(properties); + }; + + /** + * Encodes the specified PageInfo message. Does not implicitly {@link keeper.api.common.PageInfo.verify|verify} messages. + * @function encode + * @memberof keeper.api.common.PageInfo + * @static + * @param {keeper.api.common.IPageInfo} message PageInfo message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + PageInfo.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.pageNumber != null && Object.hasOwnProperty.call(message, "pageNumber")) + writer.uint32(/* id 1, wireType 0 =*/8).int32(message.pageNumber); + if (message.pageSize != null && Object.hasOwnProperty.call(message, "pageSize")) + writer.uint32(/* id 2, wireType 0 =*/16).int32(message.pageSize); + if (message.totalCount != null && Object.hasOwnProperty.call(message, "totalCount")) + writer.uint32(/* id 3, wireType 0 =*/24).int32(message.totalCount); + if (message.hasMore != null && Object.hasOwnProperty.call(message, "hasMore")) + writer.uint32(/* id 4, wireType 0 =*/32).bool(message.hasMore); + if (message.cursorToken != null && Object.hasOwnProperty.call(message, "cursorToken")) + writer.uint32(/* id 5, wireType 2 =*/42).string(message.cursorToken); + return writer; + }; + + /** + * Decodes a PageInfo message from the specified reader or buffer. + * @function decode + * @memberof keeper.api.common.PageInfo + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {keeper.api.common.PageInfo} PageInfo + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + PageInfo.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.keeper.api.common.PageInfo(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.pageNumber = reader.int32(); + break; + } + case 2: { + message.pageSize = reader.int32(); + break; + } + case 3: { + message.totalCount = reader.int32(); + break; + } + case 4: { + message.hasMore = reader.bool(); + break; + } + case 5: { + message.cursorToken = reader.string(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a PageInfo message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof keeper.api.common.PageInfo + * @static + * @param {Object.} object Plain object + * @returns {keeper.api.common.PageInfo} PageInfo + */ + PageInfo.fromObject = function fromObject(object, long) { + if (object instanceof $root.keeper.api.common.PageInfo) + return object; + if (!$util.isObject(object)) + throw TypeError(".keeper.api.common.PageInfo: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.keeper.api.common.PageInfo(); + if (object.pageNumber != null) + message.pageNumber = object.pageNumber | 0; + if (object.pageSize != null) + message.pageSize = object.pageSize | 0; + if (object.totalCount != null) + message.totalCount = object.totalCount | 0; + if (object.hasMore != null) + message.hasMore = Boolean(object.hasMore); + if (object.cursorToken != null) + message.cursorToken = String(object.cursorToken); + return message; + }; + + /** + * Creates a plain object from a PageInfo message. Also converts values to other types if specified. + * @function toObject + * @memberof keeper.api.common.PageInfo + * @static + * @param {keeper.api.common.PageInfo} message PageInfo + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + PageInfo.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.pageNumber = 0; + object.pageSize = 0; + object.totalCount = 0; + object.hasMore = false; + object.cursorToken = ""; + } + if (message.pageNumber != null && Object.hasOwnProperty.call(message, "pageNumber")) + object.pageNumber = message.pageNumber; + if (message.pageSize != null && Object.hasOwnProperty.call(message, "pageSize")) + object.pageSize = message.pageSize; + if (message.totalCount != null && Object.hasOwnProperty.call(message, "totalCount")) + object.totalCount = message.totalCount; + if (message.hasMore != null && Object.hasOwnProperty.call(message, "hasMore")) + object.hasMore = message.hasMore; + if (message.cursorToken != null && Object.hasOwnProperty.call(message, "cursorToken")) + object.cursorToken = message.cursorToken; + return object; + }; + + /** + * Converts this PageInfo to JSON. + * @function toJSON + * @memberof keeper.api.common.PageInfo + * @instance + * @returns {Object.} JSON object + */ + PageInfo.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for PageInfo + * @function getTypeUrl + * @memberof keeper.api.common.PageInfo + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + PageInfo.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/keeper.api.common.PageInfo"; + }; + + return PageInfo; + })(); + + return common; + })(); + + return api; + })(); + + return keeper; +})(); + diff --git a/keeperapi/src/proto/record.js b/keeperapi/src/proto/record.js index ce1db49e..0fc3a988 100644 --- a/keeperapi/src/proto/record.js +++ b/keeperapi/src/proto/record.js @@ -2542,6 +2542,1954 @@ export const record = $root.record = (() => { return RecordAdd; })(); + v3.details = (function() { + + /** + * Namespace details. + * @memberof record.v3 + * @namespace + */ + const details = {}; + + details.RecordDetailsService = (function() { + + /** + * Constructs a new RecordDetailsService service. + * @memberof record.v3.details + * @classdesc Represents a RecordDetailsService + * @extends $protobuf.rpc.Service + * @constructor + * @param {$protobuf.RPCImpl} rpcImpl RPC implementation + * @param {boolean} [requestDelimited=false] Whether requests are length-delimited + * @param {boolean} [responseDelimited=false] Whether responses are length-delimited + */ + function RecordDetailsService(rpcImpl, requestDelimited, responseDelimited) { + $protobuf.rpc.Service.call(this, rpcImpl, requestDelimited, responseDelimited); + } + + (RecordDetailsService.prototype = Object.create($protobuf.rpc.Service.prototype)).constructor = RecordDetailsService; + + /** + * Creates new RecordDetailsService service using the specified rpc implementation. + * @function create + * @memberof record.v3.details.RecordDetailsService + * @static + * @param {$protobuf.RPCImpl} rpcImpl RPC implementation + * @param {boolean} [requestDelimited=false] Whether requests are length-delimited + * @param {boolean} [responseDelimited=false] Whether responses are length-delimited + * @returns {RecordDetailsService} RPC service. Useful where requests and/or responses are streamed. + */ + RecordDetailsService.create = function create(rpcImpl, requestDelimited, responseDelimited) { + return new this(rpcImpl, requestDelimited, responseDelimited); + }; + + /** + * Callback as used by {@link record.v3.details.RecordDetailsService#getRecordData}. + * @memberof record.v3.details.RecordDetailsService + * @typedef GetRecordDataCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {record.v3.details.RecordDataResponse} [response] RecordDataResponse + */ + + /** + * Calls GetRecordData. + * @function getRecordData + * @memberof record.v3.details.RecordDetailsService + * @instance + * @param {record.v3.details.IRecordDataRequest} request RecordDataRequest message or plain object + * @param {record.v3.details.RecordDetailsService.GetRecordDataCallback} callback Node-style callback called with the error, if any, and RecordDataResponse + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(RecordDetailsService.prototype.getRecordData = function getRecordData(request, callback) { + return $protobuf.rpc.Service.prototype.rpcCall.call(this, getRecordData, $root.record.v3.details.RecordDataRequest, $root.record.v3.details.RecordDataResponse, request, callback); + }, "name", { value: "GetRecordData" }); + + /** + * Calls GetRecordData. + * @function getRecordData + * @memberof record.v3.details.RecordDetailsService + * @instance + * @param {record.v3.details.IRecordDataRequest} request RecordDataRequest message or plain object + * @returns {Promise} Promise + * @variation 2 + */ + + /** + * Callback as used by {@link record.v3.details.RecordDetailsService#getRecordAccessors}. + * @memberof record.v3.details.RecordDetailsService + * @typedef GetRecordAccessorsCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {record.v3.details.RecordAccessResponse} [response] RecordAccessResponse + */ + + /** + * Calls GetRecordAccessors. + * @function getRecordAccessors + * @memberof record.v3.details.RecordDetailsService + * @instance + * @param {record.v3.details.IRecordAccessRequest} request RecordAccessRequest message or plain object + * @param {record.v3.details.RecordDetailsService.GetRecordAccessorsCallback} callback Node-style callback called with the error, if any, and RecordAccessResponse + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(RecordDetailsService.prototype.getRecordAccessors = function getRecordAccessors(request, callback) { + return $protobuf.rpc.Service.prototype.rpcCall.call(this, getRecordAccessors, $root.record.v3.details.RecordAccessRequest, $root.record.v3.details.RecordAccessResponse, request, callback); + }, "name", { value: "GetRecordAccessors" }); + + /** + * Calls GetRecordAccessors. + * @function getRecordAccessors + * @memberof record.v3.details.RecordDetailsService + * @instance + * @param {record.v3.details.IRecordAccessRequest} request RecordAccessRequest message or plain object + * @returns {Promise} Promise + * @variation 2 + */ + + /** + * Callback as used by {@link record.v3.details.RecordDetailsService#getRecordAccessorDetails}. + * @memberof record.v3.details.RecordDetailsService + * @typedef GetRecordAccessorDetailsCallback + * @type {function} + * @param {Error|null} error Error, if any + * @param {record.v3.details.RecordAccessorDetailsResponse} [response] RecordAccessorDetailsResponse + */ + + /** + * Calls GetRecordAccessorDetails. + * @function getRecordAccessorDetails + * @memberof record.v3.details.RecordDetailsService + * @instance + * @param {record.v3.details.IRecordAccessorDetailsRequest} request RecordAccessorDetailsRequest message or plain object + * @param {record.v3.details.RecordDetailsService.GetRecordAccessorDetailsCallback} callback Node-style callback called with the error, if any, and RecordAccessorDetailsResponse + * @returns {undefined} + * @variation 1 + */ + Object.defineProperty(RecordDetailsService.prototype.getRecordAccessorDetails = function getRecordAccessorDetails(request, callback) { + return $protobuf.rpc.Service.prototype.rpcCall.call(this, getRecordAccessorDetails, $root.record.v3.details.RecordAccessorDetailsRequest, $root.record.v3.details.RecordAccessorDetailsResponse, request, callback); + }, "name", { value: "GetRecordAccessorDetails" }); + + /** + * Calls GetRecordAccessorDetails. + * @function getRecordAccessorDetails + * @memberof record.v3.details.RecordDetailsService + * @instance + * @param {record.v3.details.IRecordAccessorDetailsRequest} request RecordAccessorDetailsRequest message or plain object + * @returns {Promise} Promise + * @variation 2 + */ + + return RecordDetailsService; + })(); + + details.RecordDataRequest = (function() { + + /** + * Properties of a RecordDataRequest. + * @memberof record.v3.details + * @interface IRecordDataRequest + * @property {number|null} [clientTime] represents the client time in milliseconds. Client time is used to + * adjust the record client_modified_time for each record. + * @property {Array.|null} [recordUids] the list of record UIDs to retrieve information for. + */ + + /** + * Constructs a new RecordDataRequest. + * @memberof record.v3.details + * @classdesc Represents a record data request. Record details include the record [meta]data (title, color, etc.) + * @implements IRecordDataRequest + * @constructor + * @param {record.v3.details.IRecordDataRequest=} [properties] Properties to set + */ + function RecordDataRequest(properties) { + this.recordUids = []; + 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]]; + } + + /** + * represents the client time in milliseconds. Client time is used to + * adjust the record client_modified_time for each record. + * @member {number} clientTime + * @memberof record.v3.details.RecordDataRequest + * @instance + */ + RecordDataRequest.prototype.clientTime = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * the list of record UIDs to retrieve information for. + * @member {Array.} recordUids + * @memberof record.v3.details.RecordDataRequest + * @instance + */ + RecordDataRequest.prototype.recordUids = $util.emptyArray; + + /** + * Creates a new RecordDataRequest instance using the specified properties. + * @function create + * @memberof record.v3.details.RecordDataRequest + * @static + * @param {record.v3.details.IRecordDataRequest=} [properties] Properties to set + * @returns {record.v3.details.RecordDataRequest} RecordDataRequest instance + */ + RecordDataRequest.create = function create(properties) { + return new RecordDataRequest(properties); + }; + + /** + * Encodes the specified RecordDataRequest message. Does not implicitly {@link record.v3.details.RecordDataRequest.verify|verify} messages. + * @function encode + * @memberof record.v3.details.RecordDataRequest + * @static + * @param {record.v3.details.IRecordDataRequest} message RecordDataRequest message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RecordDataRequest.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.clientTime != null && Object.hasOwnProperty.call(message, "clientTime")) + writer.uint32(/* id 1, wireType 0 =*/8).int64(message.clientTime); + if (message.recordUids != null && message.recordUids.length) + for (let i = 0; i < message.recordUids.length; ++i) + writer.uint32(/* id 3, wireType 2 =*/26).bytes(message.recordUids[i]); + return writer; + }; + + /** + * Decodes a RecordDataRequest message from the specified reader or buffer. + * @function decode + * @memberof record.v3.details.RecordDataRequest + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {record.v3.details.RecordDataRequest} RecordDataRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RecordDataRequest.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.record.v3.details.RecordDataRequest(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.clientTime = reader.int64(); + break; + } + case 3: { + if (!(message.recordUids && message.recordUids.length)) + message.recordUids = []; + message.recordUids.push(reader.bytes()); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a RecordDataRequest message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof record.v3.details.RecordDataRequest + * @static + * @param {Object.} object Plain object + * @returns {record.v3.details.RecordDataRequest} RecordDataRequest + */ + RecordDataRequest.fromObject = function fromObject(object, long) { + if (object instanceof $root.record.v3.details.RecordDataRequest) + return object; + if (!$util.isObject(object)) + throw TypeError(".record.v3.details.RecordDataRequest: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.record.v3.details.RecordDataRequest(); + if (object.clientTime != null) + if ($util.Long) + message.clientTime = $util.Long.fromValue(object.clientTime, false); + else if (typeof object.clientTime === "string") + message.clientTime = parseInt(object.clientTime, 10); + else if (typeof object.clientTime === "number") + message.clientTime = object.clientTime; + else if (typeof object.clientTime === "object") + message.clientTime = new $util.LongBits(object.clientTime.low >>> 0, object.clientTime.high >>> 0).toNumber(); + if (object.recordUids) { + if (!Array.isArray(object.recordUids)) + throw TypeError(".record.v3.details.RecordDataRequest.recordUids: array expected"); + message.recordUids = []; + for (let i = 0; i < object.recordUids.length; ++i) + if (typeof object.recordUids[i] === "string") + $util.base64.decode(object.recordUids[i], message.recordUids[i] = $util.newBuffer($util.base64.length(object.recordUids[i])), 0); + else if (object.recordUids[i].length >= 0) + message.recordUids[i] = object.recordUids[i]; + } + return message; + }; + + /** + * Creates a plain object from a RecordDataRequest message. Also converts values to other types if specified. + * @function toObject + * @memberof record.v3.details.RecordDataRequest + * @static + * @param {record.v3.details.RecordDataRequest} message RecordDataRequest + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RecordDataRequest.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.arrays || options.defaults) + object.recordUids = []; + if (options.defaults) + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.clientTime = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : typeof BigInt !== "undefined" && options.longs === BigInt ? long.toBigInt() : long; + } else + object.clientTime = options.longs === String ? "0" : typeof BigInt !== "undefined" && options.longs === BigInt ? BigInt("0") : 0; + if (message.clientTime != null && Object.hasOwnProperty.call(message, "clientTime")) + if (typeof BigInt !== "undefined" && options.longs === BigInt) + object.clientTime = typeof message.clientTime === "number" ? BigInt(message.clientTime) : $util.Long.fromBits(message.clientTime.low >>> 0, message.clientTime.high >>> 0, false).toBigInt(); + else if (typeof message.clientTime === "number") + object.clientTime = options.longs === String ? String(message.clientTime) : message.clientTime; + else + object.clientTime = options.longs === String ? $util.Long.prototype.toString.call(message.clientTime) : options.longs === Number ? new $util.LongBits(message.clientTime.low >>> 0, message.clientTime.high >>> 0).toNumber() : message.clientTime; + if (message.recordUids && message.recordUids.length) { + object.recordUids = []; + for (let j = 0; j < message.recordUids.length; ++j) + object.recordUids[j] = options.bytes === String ? $util.base64.encode(message.recordUids[j], 0, message.recordUids[j].length) : options.bytes === Array ? Array.prototype.slice.call(message.recordUids[j]) : message.recordUids[j]; + } + return object; + }; + + /** + * Converts this RecordDataRequest to JSON. + * @function toJSON + * @memberof record.v3.details.RecordDataRequest + * @instance + * @returns {Object.} JSON object + */ + RecordDataRequest.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for RecordDataRequest + * @function getTypeUrl + * @memberof record.v3.details.RecordDataRequest + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RecordDataRequest.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/record.v3.details.RecordDataRequest"; + }; + + return RecordDataRequest; + })(); + + details.RecordDataResponse = (function() { + + /** + * Properties of a RecordDataResponse. + * @memberof record.v3.details + * @interface IRecordDataResponse + * @property {Array.|null} [data] The data associated with the record. + * @property {Array.|null} [forbiddenRecords] A list of record UIDs from the request that the calling user does not have access to. + * Each UID in this list corresponds to a record the user has no permission to access. + */ + + /** + * Constructs a new RecordDataResponse. + * @memberof record.v3.details + * @classdesc Response message containing records' data and a list of inaccessible records for the calling user. + * @implements IRecordDataResponse + * @constructor + * @param {record.v3.details.IRecordDataResponse=} [properties] Properties to set + */ + function RecordDataResponse(properties) { + this.data = []; + this.forbiddenRecords = []; + 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]]; + } + + /** + * The data associated with the record. + * @member {Array.} data + * @memberof record.v3.details.RecordDataResponse + * @instance + */ + RecordDataResponse.prototype.data = $util.emptyArray; + + /** + * A list of record UIDs from the request that the calling user does not have access to. + * Each UID in this list corresponds to a record the user has no permission to access. + * @member {Array.} forbiddenRecords + * @memberof record.v3.details.RecordDataResponse + * @instance + */ + RecordDataResponse.prototype.forbiddenRecords = $util.emptyArray; + + /** + * Creates a new RecordDataResponse instance using the specified properties. + * @function create + * @memberof record.v3.details.RecordDataResponse + * @static + * @param {record.v3.details.IRecordDataResponse=} [properties] Properties to set + * @returns {record.v3.details.RecordDataResponse} RecordDataResponse instance + */ + RecordDataResponse.create = function create(properties) { + return new RecordDataResponse(properties); + }; + + /** + * Encodes the specified RecordDataResponse message. Does not implicitly {@link record.v3.details.RecordDataResponse.verify|verify} messages. + * @function encode + * @memberof record.v3.details.RecordDataResponse + * @static + * @param {record.v3.details.IRecordDataResponse} message RecordDataResponse message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RecordDataResponse.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.data != null && message.data.length) + for (let i = 0; i < message.data.length; ++i) + $root.Records.RecordData.encode(message.data[i], writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.forbiddenRecords != null && message.forbiddenRecords.length) + for (let i = 0; i < message.forbiddenRecords.length; ++i) + writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.forbiddenRecords[i]); + return writer; + }; + + /** + * Decodes a RecordDataResponse message from the specified reader or buffer. + * @function decode + * @memberof record.v3.details.RecordDataResponse + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {record.v3.details.RecordDataResponse} RecordDataResponse + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RecordDataResponse.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.record.v3.details.RecordDataResponse(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.data && message.data.length)) + message.data = []; + message.data.push($root.Records.RecordData.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 2: { + if (!(message.forbiddenRecords && message.forbiddenRecords.length)) + message.forbiddenRecords = []; + message.forbiddenRecords.push(reader.bytes()); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a RecordDataResponse message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof record.v3.details.RecordDataResponse + * @static + * @param {Object.} object Plain object + * @returns {record.v3.details.RecordDataResponse} RecordDataResponse + */ + RecordDataResponse.fromObject = function fromObject(object, long) { + if (object instanceof $root.record.v3.details.RecordDataResponse) + return object; + if (!$util.isObject(object)) + throw TypeError(".record.v3.details.RecordDataResponse: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.record.v3.details.RecordDataResponse(); + if (object.data) { + if (!Array.isArray(object.data)) + throw TypeError(".record.v3.details.RecordDataResponse.data: array expected"); + message.data = []; + for (let i = 0; i < object.data.length; ++i) { + if (!$util.isObject(object.data[i])) + throw TypeError(".record.v3.details.RecordDataResponse.data: object expected"); + message.data[i] = $root.Records.RecordData.fromObject(object.data[i], long + 1); + } + } + if (object.forbiddenRecords) { + if (!Array.isArray(object.forbiddenRecords)) + throw TypeError(".record.v3.details.RecordDataResponse.forbiddenRecords: array expected"); + message.forbiddenRecords = []; + for (let i = 0; i < object.forbiddenRecords.length; ++i) + if (typeof object.forbiddenRecords[i] === "string") + $util.base64.decode(object.forbiddenRecords[i], message.forbiddenRecords[i] = $util.newBuffer($util.base64.length(object.forbiddenRecords[i])), 0); + else if (object.forbiddenRecords[i].length >= 0) + message.forbiddenRecords[i] = object.forbiddenRecords[i]; + } + return message; + }; + + /** + * Creates a plain object from a RecordDataResponse message. Also converts values to other types if specified. + * @function toObject + * @memberof record.v3.details.RecordDataResponse + * @static + * @param {record.v3.details.RecordDataResponse} message RecordDataResponse + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RecordDataResponse.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.arrays || options.defaults) { + object.data = []; + object.forbiddenRecords = []; + } + if (message.data && message.data.length) { + object.data = []; + for (let j = 0; j < message.data.length; ++j) + object.data[j] = $root.Records.RecordData.toObject(message.data[j], options, q + 1); + } + if (message.forbiddenRecords && message.forbiddenRecords.length) { + object.forbiddenRecords = []; + for (let j = 0; j < message.forbiddenRecords.length; ++j) + object.forbiddenRecords[j] = options.bytes === String ? $util.base64.encode(message.forbiddenRecords[j], 0, message.forbiddenRecords[j].length) : options.bytes === Array ? Array.prototype.slice.call(message.forbiddenRecords[j]) : message.forbiddenRecords[j]; + } + return object; + }; + + /** + * Converts this RecordDataResponse to JSON. + * @function toJSON + * @memberof record.v3.details.RecordDataResponse + * @instance + * @returns {Object.} JSON object + */ + RecordDataResponse.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for RecordDataResponse + * @function getTypeUrl + * @memberof record.v3.details.RecordDataResponse + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RecordDataResponse.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/record.v3.details.RecordDataResponse"; + }; + + return RecordDataResponse; + })(); + + details.RecordAccessRequest = (function() { + + /** + * Properties of a RecordAccessRequest. + * @memberof record.v3.details + * @interface IRecordAccessRequest + * @property {Array.|null} [recordUids] the list of record UIDs to retrieve information for. + * @property {keeper.api.common.IPage|null} [page] Pagination parameters. + * If not provided, uses defaults (page 0, size 100). + */ + + /** + * Constructs a new RecordAccessRequest. + * @memberof record.v3.details + * @classdesc Represents a record accessors request. Record details include whom the record has been + * shared with (user or team), and what role and permissions the accessors have over the record. + * @implements IRecordAccessRequest + * @constructor + * @param {record.v3.details.IRecordAccessRequest=} [properties] Properties to set + */ + function RecordAccessRequest(properties) { + this.recordUids = []; + 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]]; + } + + /** + * the list of record UIDs to retrieve information for. + * @member {Array.} recordUids + * @memberof record.v3.details.RecordAccessRequest + * @instance + */ + RecordAccessRequest.prototype.recordUids = $util.emptyArray; + + /** + * Pagination parameters. + * If not provided, uses defaults (page 0, size 100). + * @member {keeper.api.common.IPage|null|undefined} page + * @memberof record.v3.details.RecordAccessRequest + * @instance + */ + RecordAccessRequest.prototype.page = null; + + /** + * Creates a new RecordAccessRequest instance using the specified properties. + * @function create + * @memberof record.v3.details.RecordAccessRequest + * @static + * @param {record.v3.details.IRecordAccessRequest=} [properties] Properties to set + * @returns {record.v3.details.RecordAccessRequest} RecordAccessRequest instance + */ + RecordAccessRequest.create = function create(properties) { + return new RecordAccessRequest(properties); + }; + + /** + * Encodes the specified RecordAccessRequest message. Does not implicitly {@link record.v3.details.RecordAccessRequest.verify|verify} messages. + * @function encode + * @memberof record.v3.details.RecordAccessRequest + * @static + * @param {record.v3.details.IRecordAccessRequest} message RecordAccessRequest message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RecordAccessRequest.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.page != null && Object.hasOwnProperty.call(message, "page")) + $root.keeper.api.common.Page.encode(message.page, writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.recordUids != null && message.recordUids.length) + for (let i = 0; i < message.recordUids.length; ++i) + writer.uint32(/* id 3, wireType 2 =*/26).bytes(message.recordUids[i]); + return writer; + }; + + /** + * Decodes a RecordAccessRequest message from the specified reader or buffer. + * @function decode + * @memberof record.v3.details.RecordAccessRequest + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {record.v3.details.RecordAccessRequest} RecordAccessRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RecordAccessRequest.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.record.v3.details.RecordAccessRequest(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 3: { + if (!(message.recordUids && message.recordUids.length)) + message.recordUids = []; + message.recordUids.push(reader.bytes()); + break; + } + case 2: { + message.page = $root.keeper.api.common.Page.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a RecordAccessRequest message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof record.v3.details.RecordAccessRequest + * @static + * @param {Object.} object Plain object + * @returns {record.v3.details.RecordAccessRequest} RecordAccessRequest + */ + RecordAccessRequest.fromObject = function fromObject(object, long) { + if (object instanceof $root.record.v3.details.RecordAccessRequest) + return object; + if (!$util.isObject(object)) + throw TypeError(".record.v3.details.RecordAccessRequest: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.record.v3.details.RecordAccessRequest(); + if (object.recordUids) { + if (!Array.isArray(object.recordUids)) + throw TypeError(".record.v3.details.RecordAccessRequest.recordUids: array expected"); + message.recordUids = []; + for (let i = 0; i < object.recordUids.length; ++i) + if (typeof object.recordUids[i] === "string") + $util.base64.decode(object.recordUids[i], message.recordUids[i] = $util.newBuffer($util.base64.length(object.recordUids[i])), 0); + else if (object.recordUids[i].length >= 0) + message.recordUids[i] = object.recordUids[i]; + } + if (object.page != null) { + if (!$util.isObject(object.page)) + throw TypeError(".record.v3.details.RecordAccessRequest.page: object expected"); + message.page = $root.keeper.api.common.Page.fromObject(object.page, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a RecordAccessRequest message. Also converts values to other types if specified. + * @function toObject + * @memberof record.v3.details.RecordAccessRequest + * @static + * @param {record.v3.details.RecordAccessRequest} message RecordAccessRequest + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RecordAccessRequest.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.arrays || options.defaults) + object.recordUids = []; + if (options.defaults) + object.page = null; + if (message.page != null && Object.hasOwnProperty.call(message, "page")) + object.page = $root.keeper.api.common.Page.toObject(message.page, options, q + 1); + if (message.recordUids && message.recordUids.length) { + object.recordUids = []; + for (let j = 0; j < message.recordUids.length; ++j) + object.recordUids[j] = options.bytes === String ? $util.base64.encode(message.recordUids[j], 0, message.recordUids[j].length) : options.bytes === Array ? Array.prototype.slice.call(message.recordUids[j]) : message.recordUids[j]; + } + return object; + }; + + /** + * Converts this RecordAccessRequest to JSON. + * @function toJSON + * @memberof record.v3.details.RecordAccessRequest + * @instance + * @returns {Object.} JSON object + */ + RecordAccessRequest.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for RecordAccessRequest + * @function getTypeUrl + * @memberof record.v3.details.RecordAccessRequest + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RecordAccessRequest.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/record.v3.details.RecordAccessRequest"; + }; + + return RecordAccessRequest; + })(); + + details.RecordAccessResponse = (function() { + + /** + * Properties of a RecordAccessResponse. + * @memberof record.v3.details + * @interface IRecordAccessResponse + * @property {Array.|null} [recordAccesses] List of record access permissions, detailing the accessors and their roles. + * @property {Array.|null} [forbiddenRecords] A list of record UIDs from the request that the calling user does not have access to. + * Each UID in this list corresponds to a record the user has no permission to access. + * @property {keeper.api.common.IPageInfo|null} [pageInfo] Pagination metadata for this response. + * Contains current page info, total count, and whether more pages exist. + */ + + /** + * Constructs a new RecordAccessResponse. + * @memberof record.v3.details + * @classdesc Response message containing records' accesses and a list of inaccessible records for the calling user. + * @implements IRecordAccessResponse + * @constructor + * @param {record.v3.details.IRecordAccessResponse=} [properties] Properties to set + */ + function RecordAccessResponse(properties) { + this.recordAccesses = []; + this.forbiddenRecords = []; + 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]]; + } + + /** + * List of record access permissions, detailing the accessors and their roles. + * @member {Array.} recordAccesses + * @memberof record.v3.details.RecordAccessResponse + * @instance + */ + RecordAccessResponse.prototype.recordAccesses = $util.emptyArray; + + /** + * A list of record UIDs from the request that the calling user does not have access to. + * Each UID in this list corresponds to a record the user has no permission to access. + * @member {Array.} forbiddenRecords + * @memberof record.v3.details.RecordAccessResponse + * @instance + */ + RecordAccessResponse.prototype.forbiddenRecords = $util.emptyArray; + + /** + * Pagination metadata for this response. + * Contains current page info, total count, and whether more pages exist. + * @member {keeper.api.common.IPageInfo|null|undefined} pageInfo + * @memberof record.v3.details.RecordAccessResponse + * @instance + */ + RecordAccessResponse.prototype.pageInfo = null; + + /** + * Creates a new RecordAccessResponse instance using the specified properties. + * @function create + * @memberof record.v3.details.RecordAccessResponse + * @static + * @param {record.v3.details.IRecordAccessResponse=} [properties] Properties to set + * @returns {record.v3.details.RecordAccessResponse} RecordAccessResponse instance + */ + RecordAccessResponse.create = function create(properties) { + return new RecordAccessResponse(properties); + }; + + /** + * Encodes the specified RecordAccessResponse message. Does not implicitly {@link record.v3.details.RecordAccessResponse.verify|verify} messages. + * @function encode + * @memberof record.v3.details.RecordAccessResponse + * @static + * @param {record.v3.details.IRecordAccessResponse} message RecordAccessResponse message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RecordAccessResponse.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.recordAccesses != null && message.recordAccesses.length) + for (let i = 0; i < message.recordAccesses.length; ++i) + $root.record.v3.details.RecordAccess.encode(message.recordAccesses[i], writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.forbiddenRecords != null && message.forbiddenRecords.length) + for (let i = 0; i < message.forbiddenRecords.length; ++i) + writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.forbiddenRecords[i]); + if (message.pageInfo != null && Object.hasOwnProperty.call(message, "pageInfo")) + $root.keeper.api.common.PageInfo.encode(message.pageInfo, writer.uint32(/* id 3, wireType 2 =*/26).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a RecordAccessResponse message from the specified reader or buffer. + * @function decode + * @memberof record.v3.details.RecordAccessResponse + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {record.v3.details.RecordAccessResponse} RecordAccessResponse + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RecordAccessResponse.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.record.v3.details.RecordAccessResponse(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + if (!(message.recordAccesses && message.recordAccesses.length)) + message.recordAccesses = []; + message.recordAccesses.push($root.record.v3.details.RecordAccess.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 2: { + if (!(message.forbiddenRecords && message.forbiddenRecords.length)) + message.forbiddenRecords = []; + message.forbiddenRecords.push(reader.bytes()); + break; + } + case 3: { + message.pageInfo = $root.keeper.api.common.PageInfo.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a RecordAccessResponse message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof record.v3.details.RecordAccessResponse + * @static + * @param {Object.} object Plain object + * @returns {record.v3.details.RecordAccessResponse} RecordAccessResponse + */ + RecordAccessResponse.fromObject = function fromObject(object, long) { + if (object instanceof $root.record.v3.details.RecordAccessResponse) + return object; + if (!$util.isObject(object)) + throw TypeError(".record.v3.details.RecordAccessResponse: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.record.v3.details.RecordAccessResponse(); + if (object.recordAccesses) { + if (!Array.isArray(object.recordAccesses)) + throw TypeError(".record.v3.details.RecordAccessResponse.recordAccesses: array expected"); + message.recordAccesses = []; + for (let i = 0; i < object.recordAccesses.length; ++i) { + if (!$util.isObject(object.recordAccesses[i])) + throw TypeError(".record.v3.details.RecordAccessResponse.recordAccesses: object expected"); + message.recordAccesses[i] = $root.record.v3.details.RecordAccess.fromObject(object.recordAccesses[i], long + 1); + } + } + if (object.forbiddenRecords) { + if (!Array.isArray(object.forbiddenRecords)) + throw TypeError(".record.v3.details.RecordAccessResponse.forbiddenRecords: array expected"); + message.forbiddenRecords = []; + for (let i = 0; i < object.forbiddenRecords.length; ++i) + if (typeof object.forbiddenRecords[i] === "string") + $util.base64.decode(object.forbiddenRecords[i], message.forbiddenRecords[i] = $util.newBuffer($util.base64.length(object.forbiddenRecords[i])), 0); + else if (object.forbiddenRecords[i].length >= 0) + message.forbiddenRecords[i] = object.forbiddenRecords[i]; + } + if (object.pageInfo != null) { + if (!$util.isObject(object.pageInfo)) + throw TypeError(".record.v3.details.RecordAccessResponse.pageInfo: object expected"); + message.pageInfo = $root.keeper.api.common.PageInfo.fromObject(object.pageInfo, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a RecordAccessResponse message. Also converts values to other types if specified. + * @function toObject + * @memberof record.v3.details.RecordAccessResponse + * @static + * @param {record.v3.details.RecordAccessResponse} message RecordAccessResponse + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RecordAccessResponse.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.arrays || options.defaults) { + object.recordAccesses = []; + object.forbiddenRecords = []; + } + if (options.defaults) + object.pageInfo = null; + if (message.recordAccesses && message.recordAccesses.length) { + object.recordAccesses = []; + for (let j = 0; j < message.recordAccesses.length; ++j) + object.recordAccesses[j] = $root.record.v3.details.RecordAccess.toObject(message.recordAccesses[j], options, q + 1); + } + if (message.forbiddenRecords && message.forbiddenRecords.length) { + object.forbiddenRecords = []; + for (let j = 0; j < message.forbiddenRecords.length; ++j) + object.forbiddenRecords[j] = options.bytes === String ? $util.base64.encode(message.forbiddenRecords[j], 0, message.forbiddenRecords[j].length) : options.bytes === Array ? Array.prototype.slice.call(message.forbiddenRecords[j]) : message.forbiddenRecords[j]; + } + if (message.pageInfo != null && Object.hasOwnProperty.call(message, "pageInfo")) + object.pageInfo = $root.keeper.api.common.PageInfo.toObject(message.pageInfo, options, q + 1); + return object; + }; + + /** + * Converts this RecordAccessResponse to JSON. + * @function toJSON + * @memberof record.v3.details.RecordAccessResponse + * @instance + * @returns {Object.} JSON object + */ + RecordAccessResponse.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for RecordAccessResponse + * @function getTypeUrl + * @memberof record.v3.details.RecordAccessResponse + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RecordAccessResponse.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/record.v3.details.RecordAccessResponse"; + }; + + return RecordAccessResponse; + })(); + + details.RecordAccessorDetailsRequest = (function() { + + /** + * Properties of a RecordAccessorDetailsRequest. + * @memberof record.v3.details + * @interface IRecordAccessorDetailsRequest + * @property {Uint8Array|null} [recordUid] The record UID to retrieve information for. + * @property {Uint8Array|null} [accessorUid] The accessor UID (user or team) to retrieve information for. + * @property {keeper.api.common.IPage|null} [page] Pagination parameters. + * If not provided, uses defaults (page 0, size 100). + */ + + /** + * Constructs a new RecordAccessorDetailsRequest. + * @memberof record.v3.details + * @classdesc Represents a record accessor details request. + * @implements IRecordAccessorDetailsRequest + * @constructor + * @param {record.v3.details.IRecordAccessorDetailsRequest=} [properties] Properties to set + */ + function RecordAccessorDetailsRequest(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]]; + } + + /** + * The record UID to retrieve information for. + * @member {Uint8Array} recordUid + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @instance + */ + RecordAccessorDetailsRequest.prototype.recordUid = $util.newBuffer([]); + + /** + * The accessor UID (user or team) to retrieve information for. + * @member {Uint8Array} accessorUid + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @instance + */ + RecordAccessorDetailsRequest.prototype.accessorUid = $util.newBuffer([]); + + /** + * Pagination parameters. + * If not provided, uses defaults (page 0, size 100). + * @member {keeper.api.common.IPage|null|undefined} page + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @instance + */ + RecordAccessorDetailsRequest.prototype.page = null; + + /** + * Creates a new RecordAccessorDetailsRequest instance using the specified properties. + * @function create + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @static + * @param {record.v3.details.IRecordAccessorDetailsRequest=} [properties] Properties to set + * @returns {record.v3.details.RecordAccessorDetailsRequest} RecordAccessorDetailsRequest instance + */ + RecordAccessorDetailsRequest.create = function create(properties) { + return new RecordAccessorDetailsRequest(properties); + }; + + /** + * Encodes the specified RecordAccessorDetailsRequest message. Does not implicitly {@link record.v3.details.RecordAccessorDetailsRequest.verify|verify} messages. + * @function encode + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @static + * @param {record.v3.details.IRecordAccessorDetailsRequest} message RecordAccessorDetailsRequest message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RecordAccessorDetailsRequest.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.recordUid != null && Object.hasOwnProperty.call(message, "recordUid")) + writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.recordUid); + if (message.accessorUid != null && Object.hasOwnProperty.call(message, "accessorUid")) + writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.accessorUid); + if (message.page != null && Object.hasOwnProperty.call(message, "page")) + $root.keeper.api.common.Page.encode(message.page, writer.uint32(/* id 3, wireType 2 =*/26).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a RecordAccessorDetailsRequest message from the specified reader or buffer. + * @function decode + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {record.v3.details.RecordAccessorDetailsRequest} RecordAccessorDetailsRequest + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RecordAccessorDetailsRequest.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.record.v3.details.RecordAccessorDetailsRequest(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.recordUid = reader.bytes(); + break; + } + case 2: { + message.accessorUid = reader.bytes(); + break; + } + case 3: { + message.page = $root.keeper.api.common.Page.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a RecordAccessorDetailsRequest message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @static + * @param {Object.} object Plain object + * @returns {record.v3.details.RecordAccessorDetailsRequest} RecordAccessorDetailsRequest + */ + RecordAccessorDetailsRequest.fromObject = function fromObject(object, long) { + if (object instanceof $root.record.v3.details.RecordAccessorDetailsRequest) + return object; + if (!$util.isObject(object)) + throw TypeError(".record.v3.details.RecordAccessorDetailsRequest: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.record.v3.details.RecordAccessorDetailsRequest(); + if (object.recordUid != null) + if (typeof object.recordUid === "string") + $util.base64.decode(object.recordUid, message.recordUid = $util.newBuffer($util.base64.length(object.recordUid)), 0); + else if (object.recordUid.length >= 0) + message.recordUid = object.recordUid; + if (object.accessorUid != null) + if (typeof object.accessorUid === "string") + $util.base64.decode(object.accessorUid, message.accessorUid = $util.newBuffer($util.base64.length(object.accessorUid)), 0); + else if (object.accessorUid.length >= 0) + message.accessorUid = object.accessorUid; + if (object.page != null) { + if (!$util.isObject(object.page)) + throw TypeError(".record.v3.details.RecordAccessorDetailsRequest.page: object expected"); + message.page = $root.keeper.api.common.Page.fromObject(object.page, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a RecordAccessorDetailsRequest message. Also converts values to other types if specified. + * @function toObject + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @static + * @param {record.v3.details.RecordAccessorDetailsRequest} message RecordAccessorDetailsRequest + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RecordAccessorDetailsRequest.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 (options.bytes === String) + object.recordUid = ""; + else { + object.recordUid = []; + if (options.bytes !== Array) + object.recordUid = $util.newBuffer(object.recordUid); + } + if (options.bytes === String) + object.accessorUid = ""; + else { + object.accessorUid = []; + if (options.bytes !== Array) + object.accessorUid = $util.newBuffer(object.accessorUid); + } + object.page = null; + } + 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; + if (message.accessorUid != null && Object.hasOwnProperty.call(message, "accessorUid")) + object.accessorUid = options.bytes === String ? $util.base64.encode(message.accessorUid, 0, message.accessorUid.length) : options.bytes === Array ? Array.prototype.slice.call(message.accessorUid) : message.accessorUid; + if (message.page != null && Object.hasOwnProperty.call(message, "page")) + object.page = $root.keeper.api.common.Page.toObject(message.page, options, q + 1); + return object; + }; + + /** + * Converts this RecordAccessorDetailsRequest to JSON. + * @function toJSON + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @instance + * @returns {Object.} JSON object + */ + RecordAccessorDetailsRequest.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for RecordAccessorDetailsRequest + * @function getTypeUrl + * @memberof record.v3.details.RecordAccessorDetailsRequest + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RecordAccessorDetailsRequest.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/record.v3.details.RecordAccessorDetailsRequest"; + }; + + return RecordAccessorDetailsRequest; + })(); + + details.RecordAccessorDetailsResponse = (function() { + + /** + * Properties of a RecordAccessorDetailsResponse. + * @memberof record.v3.details + * @interface IRecordAccessorDetailsResponse + * @property {Folder.IRecordAccessData|null} [recordAccessData] Set if has direct access to the record. + * @property {Array.|null} [folderAccessData] The list of folder the user has access and that contain the record. + * @property {keeper.api.common.IPageInfo|null} [pageInfo] * Pagination metadata for this response. + * * Contains current page info, total count, and whether more pages exist. + */ + + /** + * Constructs a new RecordAccessorDetailsResponse. + * @memberof record.v3.details + * @classdesc Represents a record accessor details response. + * Record accessor details include information on how a specific accessor obtained access to a record. + * @implements IRecordAccessorDetailsResponse + * @constructor + * @param {record.v3.details.IRecordAccessorDetailsResponse=} [properties] Properties to set + */ + function RecordAccessorDetailsResponse(properties) { + this.folderAccessData = []; + 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]]; + } + + /** + * Set if has direct access to the record. + * @member {Folder.IRecordAccessData|null|undefined} recordAccessData + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @instance + */ + RecordAccessorDetailsResponse.prototype.recordAccessData = null; + + /** + * The list of folder the user has access and that contain the record. + * @member {Array.} folderAccessData + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @instance + */ + RecordAccessorDetailsResponse.prototype.folderAccessData = $util.emptyArray; + + /** + * * Pagination metadata for this response. + * * Contains current page info, total count, and whether more pages exist. + * @member {keeper.api.common.IPageInfo|null|undefined} pageInfo + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @instance + */ + RecordAccessorDetailsResponse.prototype.pageInfo = null; + + /** + * Creates a new RecordAccessorDetailsResponse instance using the specified properties. + * @function create + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @static + * @param {record.v3.details.IRecordAccessorDetailsResponse=} [properties] Properties to set + * @returns {record.v3.details.RecordAccessorDetailsResponse} RecordAccessorDetailsResponse instance + */ + RecordAccessorDetailsResponse.create = function create(properties) { + return new RecordAccessorDetailsResponse(properties); + }; + + /** + * Encodes the specified RecordAccessorDetailsResponse message. Does not implicitly {@link record.v3.details.RecordAccessorDetailsResponse.verify|verify} messages. + * @function encode + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @static + * @param {record.v3.details.IRecordAccessorDetailsResponse} message RecordAccessorDetailsResponse message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RecordAccessorDetailsResponse.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.recordAccessData != null && Object.hasOwnProperty.call(message, "recordAccessData")) + $root.Folder.RecordAccessData.encode(message.recordAccessData, writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.folderAccessData != null && message.folderAccessData.length) + for (let i = 0; i < message.folderAccessData.length; ++i) + $root.Folder.FolderAccessData.encode(message.folderAccessData[i], writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + if (message.pageInfo != null && Object.hasOwnProperty.call(message, "pageInfo")) + $root.keeper.api.common.PageInfo.encode(message.pageInfo, writer.uint32(/* id 3, wireType 2 =*/26).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a RecordAccessorDetailsResponse message from the specified reader or buffer. + * @function decode + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {record.v3.details.RecordAccessorDetailsResponse} RecordAccessorDetailsResponse + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RecordAccessorDetailsResponse.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.record.v3.details.RecordAccessorDetailsResponse(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.recordAccessData = $root.Folder.RecordAccessData.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 2: { + if (!(message.folderAccessData && message.folderAccessData.length)) + message.folderAccessData = []; + message.folderAccessData.push($root.Folder.FolderAccessData.decode(reader, reader.uint32(), undefined, long + 1)); + break; + } + case 3: { + message.pageInfo = $root.keeper.api.common.PageInfo.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a RecordAccessorDetailsResponse message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @static + * @param {Object.} object Plain object + * @returns {record.v3.details.RecordAccessorDetailsResponse} RecordAccessorDetailsResponse + */ + RecordAccessorDetailsResponse.fromObject = function fromObject(object, long) { + if (object instanceof $root.record.v3.details.RecordAccessorDetailsResponse) + return object; + if (!$util.isObject(object)) + throw TypeError(".record.v3.details.RecordAccessorDetailsResponse: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.record.v3.details.RecordAccessorDetailsResponse(); + if (object.recordAccessData != null) { + if (!$util.isObject(object.recordAccessData)) + throw TypeError(".record.v3.details.RecordAccessorDetailsResponse.recordAccessData: object expected"); + message.recordAccessData = $root.Folder.RecordAccessData.fromObject(object.recordAccessData, long + 1); + } + if (object.folderAccessData) { + if (!Array.isArray(object.folderAccessData)) + throw TypeError(".record.v3.details.RecordAccessorDetailsResponse.folderAccessData: array expected"); + message.folderAccessData = []; + for (let i = 0; i < object.folderAccessData.length; ++i) { + if (!$util.isObject(object.folderAccessData[i])) + throw TypeError(".record.v3.details.RecordAccessorDetailsResponse.folderAccessData: object expected"); + message.folderAccessData[i] = $root.Folder.FolderAccessData.fromObject(object.folderAccessData[i], long + 1); + } + } + if (object.pageInfo != null) { + if (!$util.isObject(object.pageInfo)) + throw TypeError(".record.v3.details.RecordAccessorDetailsResponse.pageInfo: object expected"); + message.pageInfo = $root.keeper.api.common.PageInfo.fromObject(object.pageInfo, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a RecordAccessorDetailsResponse message. Also converts values to other types if specified. + * @function toObject + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @static + * @param {record.v3.details.RecordAccessorDetailsResponse} message RecordAccessorDetailsResponse + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RecordAccessorDetailsResponse.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.arrays || options.defaults) + object.folderAccessData = []; + if (options.defaults) { + object.recordAccessData = null; + object.pageInfo = null; + } + if (message.recordAccessData != null && Object.hasOwnProperty.call(message, "recordAccessData")) + object.recordAccessData = $root.Folder.RecordAccessData.toObject(message.recordAccessData, options, q + 1); + if (message.folderAccessData && message.folderAccessData.length) { + object.folderAccessData = []; + for (let j = 0; j < message.folderAccessData.length; ++j) + object.folderAccessData[j] = $root.Folder.FolderAccessData.toObject(message.folderAccessData[j], options, q + 1); + } + if (message.pageInfo != null && Object.hasOwnProperty.call(message, "pageInfo")) + object.pageInfo = $root.keeper.api.common.PageInfo.toObject(message.pageInfo, options, q + 1); + return object; + }; + + /** + * Converts this RecordAccessorDetailsResponse to JSON. + * @function toJSON + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @instance + * @returns {Object.} JSON object + */ + RecordAccessorDetailsResponse.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for RecordAccessorDetailsResponse + * @function getTypeUrl + * @memberof record.v3.details.RecordAccessorDetailsResponse + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RecordAccessorDetailsResponse.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/record.v3.details.RecordAccessorDetailsResponse"; + }; + + return RecordAccessorDetailsResponse; + })(); + + details.RecordAccess = (function() { + + /** + * Properties of a RecordAccess. + * @memberof record.v3.details + * @interface IRecordAccess + * @property {Folder.IRecordAccessData|null} [data] Core access details including permissions, role, and metadata. + * @property {record.v3.details.IAccessorInfo|null} [accessorInfo] The record accessor. + */ + + /** + * Constructs a new RecordAccess. + * @memberof record.v3.details + * @classdesc Describes the access a user has to a specific record. + * Includes ownership, access roles, and additional sharing metadata. + * @implements IRecordAccess + * @constructor + * @param {record.v3.details.IRecordAccess=} [properties] Properties to set + */ + function RecordAccess(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]]; + } + + /** + * Core access details including permissions, role, and metadata. + * @member {Folder.IRecordAccessData|null|undefined} data + * @memberof record.v3.details.RecordAccess + * @instance + */ + RecordAccess.prototype.data = null; + + /** + * The record accessor. + * @member {record.v3.details.IAccessorInfo|null|undefined} accessorInfo + * @memberof record.v3.details.RecordAccess + * @instance + */ + RecordAccess.prototype.accessorInfo = null; + + /** + * Creates a new RecordAccess instance using the specified properties. + * @function create + * @memberof record.v3.details.RecordAccess + * @static + * @param {record.v3.details.IRecordAccess=} [properties] Properties to set + * @returns {record.v3.details.RecordAccess} RecordAccess instance + */ + RecordAccess.create = function create(properties) { + return new RecordAccess(properties); + }; + + /** + * Encodes the specified RecordAccess message. Does not implicitly {@link record.v3.details.RecordAccess.verify|verify} messages. + * @function encode + * @memberof record.v3.details.RecordAccess + * @static + * @param {record.v3.details.IRecordAccess} message RecordAccess message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + RecordAccess.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.data != null && Object.hasOwnProperty.call(message, "data")) + $root.Folder.RecordAccessData.encode(message.data, writer.uint32(/* id 1, wireType 2 =*/10).fork(), q + 1).ldelim(); + if (message.accessorInfo != null && Object.hasOwnProperty.call(message, "accessorInfo")) + $root.record.v3.details.AccessorInfo.encode(message.accessorInfo, writer.uint32(/* id 2, wireType 2 =*/18).fork(), q + 1).ldelim(); + return writer; + }; + + /** + * Decodes a RecordAccess message from the specified reader or buffer. + * @function decode + * @memberof record.v3.details.RecordAccess + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {record.v3.details.RecordAccess} RecordAccess + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + RecordAccess.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.record.v3.details.RecordAccess(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.data = $root.Folder.RecordAccessData.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + case 2: { + message.accessorInfo = $root.record.v3.details.AccessorInfo.decode(reader, reader.uint32(), undefined, long + 1); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates a RecordAccess message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof record.v3.details.RecordAccess + * @static + * @param {Object.} object Plain object + * @returns {record.v3.details.RecordAccess} RecordAccess + */ + RecordAccess.fromObject = function fromObject(object, long) { + if (object instanceof $root.record.v3.details.RecordAccess) + return object; + if (!$util.isObject(object)) + throw TypeError(".record.v3.details.RecordAccess: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.record.v3.details.RecordAccess(); + if (object.data != null) { + if (!$util.isObject(object.data)) + throw TypeError(".record.v3.details.RecordAccess.data: object expected"); + message.data = $root.Folder.RecordAccessData.fromObject(object.data, long + 1); + } + if (object.accessorInfo != null) { + if (!$util.isObject(object.accessorInfo)) + throw TypeError(".record.v3.details.RecordAccess.accessorInfo: object expected"); + message.accessorInfo = $root.record.v3.details.AccessorInfo.fromObject(object.accessorInfo, long + 1); + } + return message; + }; + + /** + * Creates a plain object from a RecordAccess message. Also converts values to other types if specified. + * @function toObject + * @memberof record.v3.details.RecordAccess + * @static + * @param {record.v3.details.RecordAccess} message RecordAccess + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + RecordAccess.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.data = null; + object.accessorInfo = null; + } + if (message.data != null && Object.hasOwnProperty.call(message, "data")) + object.data = $root.Folder.RecordAccessData.toObject(message.data, options, q + 1); + if (message.accessorInfo != null && Object.hasOwnProperty.call(message, "accessorInfo")) + object.accessorInfo = $root.record.v3.details.AccessorInfo.toObject(message.accessorInfo, options, q + 1); + return object; + }; + + /** + * Converts this RecordAccess to JSON. + * @function toJSON + * @memberof record.v3.details.RecordAccess + * @instance + * @returns {Object.} JSON object + */ + RecordAccess.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for RecordAccess + * @function getTypeUrl + * @memberof record.v3.details.RecordAccess + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + RecordAccess.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/record.v3.details.RecordAccess"; + }; + + return RecordAccess; + })(); + + details.AccessorInfo = (function() { + + /** + * Properties of an AccessorInfo. + * @memberof record.v3.details + * @interface IAccessorInfo + * @property {string|null} [name] accessor name + */ + + /** + * Constructs a new AccessorInfo. + * @memberof record.v3.details + * @classdesc The entity representing the record accessor. Either a team or a user + * @implements IAccessorInfo + * @constructor + * @param {record.v3.details.IAccessorInfo=} [properties] Properties to set + */ + function AccessorInfo(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]]; + } + + /** + * accessor name + * @member {string} name + * @memberof record.v3.details.AccessorInfo + * @instance + */ + AccessorInfo.prototype.name = ""; + + /** + * Creates a new AccessorInfo instance using the specified properties. + * @function create + * @memberof record.v3.details.AccessorInfo + * @static + * @param {record.v3.details.IAccessorInfo=} [properties] Properties to set + * @returns {record.v3.details.AccessorInfo} AccessorInfo instance + */ + AccessorInfo.create = function create(properties) { + return new AccessorInfo(properties); + }; + + /** + * Encodes the specified AccessorInfo message. Does not implicitly {@link record.v3.details.AccessorInfo.verify|verify} messages. + * @function encode + * @memberof record.v3.details.AccessorInfo + * @static + * @param {record.v3.details.IAccessorInfo} message AccessorInfo message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + AccessorInfo.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.name != null && Object.hasOwnProperty.call(message, "name")) + writer.uint32(/* id 1, wireType 2 =*/10).string(message.name); + return writer; + }; + + /** + * Decodes an AccessorInfo message from the specified reader or buffer. + * @function decode + * @memberof record.v3.details.AccessorInfo + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {record.v3.details.AccessorInfo} AccessorInfo + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + AccessorInfo.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.record.v3.details.AccessorInfo(); + while (reader.pos < end) { + let tag = reader.uint32(); + if (tag === error) + break; + switch (tag >>> 3) { + case 1: { + message.name = reader.string(); + break; + } + default: + reader.skipType(tag & 7, long); + break; + } + } + return message; + }; + + /** + * Creates an AccessorInfo message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof record.v3.details.AccessorInfo + * @static + * @param {Object.} object Plain object + * @returns {record.v3.details.AccessorInfo} AccessorInfo + */ + AccessorInfo.fromObject = function fromObject(object, long) { + if (object instanceof $root.record.v3.details.AccessorInfo) + return object; + if (!$util.isObject(object)) + throw TypeError(".record.v3.details.AccessorInfo: object expected"); + if (long === undefined) + long = 0; + if (long > $util.recursionLimit) + throw Error("maximum nesting depth exceeded"); + let message = new $root.record.v3.details.AccessorInfo(); + if (object.name != null) + message.name = String(object.name); + return message; + }; + + /** + * Creates a plain object from an AccessorInfo message. Also converts values to other types if specified. + * @function toObject + * @memberof record.v3.details.AccessorInfo + * @static + * @param {record.v3.details.AccessorInfo} message AccessorInfo + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + AccessorInfo.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.name = ""; + if (message.name != null && Object.hasOwnProperty.call(message, "name")) + object.name = message.name; + return object; + }; + + /** + * Converts this AccessorInfo to JSON. + * @function toJSON + * @memberof record.v3.details.AccessorInfo + * @instance + * @returns {Object.} JSON object + */ + AccessorInfo.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for AccessorInfo + * @function getTypeUrl + * @memberof record.v3.details.AccessorInfo + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + AccessorInfo.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/record.v3.details.AccessorInfo"; + }; + + return AccessorInfo; + })(); + + return details; + })(); + return v3; })(); From ee34d763cfcab2cf2178bcaa1663f2c9bedd3bf0 Mon Sep 17 00:00:00 2001 From: Hoseong Lee <154545063+hleekeeper@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:31:23 -0500 Subject: [PATCH 29/48] Added the Modifying the generation script in the Protobuf Regeneration document part (#200) --- keeperapi/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/keeperapi/README.md b/keeperapi/README.md index 757a686c..a089223d 100644 --- a/keeperapi/README.md +++ b/keeperapi/README.md @@ -25,3 +25,7 @@ Manual protobuf regeneration is not recommended. Instead, use the **Update Proto Trigger it manually via Actions → Update Protobuf → Run workflow. If there are changes, the workflow opens a PR from `bot/update-proto`. If a PR is already open, it updates the branch in place instead of creating a duplicate. + +### Modifying the generation script + +If you need to change the protobuf generation script itself (e.g. `scripts/generate-proto.mjs`), push your changes to a feature branch first, then trigger the **Update Protobuf** workflow from that branch. This ensures the workflow runs with your updated script and the generated output reflects your changes correctly. See [#199](https://github.com/Keeper-Security/keeper-sdk-javascript/pull/199) as an example. From bfd4a8c288cc4b6fb634a6b4a0ab6502ce53977d Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 7 Jul 2026 00:26:47 +0530 Subject: [PATCH 30/48] Rest message update for nsf commands (#201) --- keeperapi/src/restMessages.ts | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 022984ff..e8301747 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -1074,3 +1074,66 @@ 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 +): RestMessage => + createMessage(data, 'vault/folders/v3/access', folder.v3.GetFolderAccessRequest, folder.v3.GetFolderAccessResponse) + +export const getRecordAccessMessage = ( + data: record.v3.details.IRecordAccessRequest +): RestMessage => + createMessage( + data, + 'vault/records/v3/details/access', + 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 const recordsShareV3Message = ( + data: record.v3.sharing.IRequest +): RestMessage => + createMessage(data, 'vault/records/v3/share', record.v3.sharing.Request, record.v3.sharing.Response) + +export const recordsTransferV3Message = ( + data: Records.IRecordsOnwershipTransferRequest +): RestMessage => + createMessage( + data, + 'vault/records/v3/transfer', + Records.RecordsOnwershipTransferRequest, + Records.RecordsOnwershipTransferResponse + ) From 77c5578b9b6111075b80d8e1f5f6aa8d3d2a1abb Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Fri, 10 Jul 2026 13:08:32 +0530 Subject: [PATCH 31/48] NSF record (add, update and details) and folder (add and remove) functionality along with records bug fix (#202) --- KeeperSdk/src/index.ts | 59 + .../NestedShareFolderManager.ts | 105 +- .../src/nestedShareFolders/addNsfRecord.ts | 193 +++ KeeperSdk/src/nestedShareFolders/getNsf.ts | 996 ++++++++------- .../nestedShareFolders/getNsfRecordDetails.ts | 111 ++ KeeperSdk/src/nestedShareFolders/index.ts | 209 +++- .../src/nestedShareFolders/linkNsfRecord.ts | 258 ++-- KeeperSdk/src/nestedShareFolders/listNsf.ts | 275 ++-- KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 224 ++++ .../src/nestedShareFolders/nsfConstants.ts | 139 ++- .../src/nestedShareFolders/nsfHelpers.ts | 1108 ++++++++++------- .../src/nestedShareFolders/nsfRecordCrypto.ts | 110 ++ .../src/nestedShareFolders/nsfRecordData.ts | 385 ++++++ .../src/nestedShareFolders/nsfRecordTypes.ts | 95 ++ KeeperSdk/src/nestedShareFolders/nsfTypes.ts | 395 ++++++ .../src/nestedShareFolders/removeNsfFolder.ts | 231 ++++ .../src/nestedShareFolders/removeNsfRecord.ts | 486 +++----- .../src/nestedShareFolders/updateNsfRecord.ts | 196 +++ KeeperSdk/src/utils/constants.ts | 10 + KeeperSdk/src/vault/KeeperVault.ts | 94 +- 20 files changed, 3996 insertions(+), 1683 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/addNsfRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts create mode 100644 KeeperSdk/src/nestedShareFolders/mkdirNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordData.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfTypes.ts create mode 100644 KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts create mode 100644 KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 58511323..26917b28 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -575,16 +575,45 @@ export { 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, + NsfRemoveFolderOperation, removeNestedShareRecords, formatRemoveNsfPreview, + collectRemoveNsfWarnings, + mkdirNestedShareFolder, + NSF_FOLDER_COLORS, + removeNestedShareFolders, + formatRemoveNsfFolderPreview, + GetNsfRecordDetailsFormat, + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, + updateNestedShareRecords, + updateNestedShareRecord, + addNestedShareRecord, + addNestedShareRecords, + buildNsfRecordData, + parseNsfFieldInput, + parseNsfFieldSpaceInput, + resolveNsfFieldValue, + clearNsfRecordTypeCache, + checkRecordEditPermission, NestedShareFolderManager, } from "./nestedShareFolders"; export type { @@ -597,6 +626,10 @@ export type { GetNsfResult, NsfFolderView, NsfRecordView, + NsfRecordFieldView, + NsfRecordFolderView, + NsfRecordJsonView, + NsfRecordJsonUserPermission, NsfFolderPermission, NsfFolderAccessRow, NsfRecordPermission, @@ -605,6 +638,32 @@ export type { 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"; export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index 84b4feda..b9070ed8 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -6,31 +6,54 @@ import { formatListNsfTable, listNestedShareFolders, renderListNsfAsciiTable, - type FormattedListNsfTable, - type ListNsfFormatInput, - type ListNsfOptions, - type ListNsfRow, } from "./listNsf"; import { - formatNsfDetail as renderNsfDetail, - formatNsfFolderDetail as renderNsfFolderDetail, - formatNsfRecordDetail as renderNsfRecordDetail, + formatNsfDetail, + formatNsfFolderDetail, + formatNsfRecordDetail, getNestedShareFolder, - type GetNsfOptions, - type GetNsfResult, - type NsfFolderView, - type NsfRecordView, } from "./getNsf"; -import { - linkNestedShareRecord, - type LinkNsfRecordResult, -} from "./linkNsfRecord"; +import { linkNestedShareRecord } from "./linkNsfRecord"; import { formatRemoveNsfPreview, removeNestedShareRecords, - type RemoveNsfRecordInput, - type RemoveNsfRecordResult, } 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 { + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + FormattedListNsfTable, + GetNsfOptions, + GetNsfResult, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + LinkNsfRecordResult, + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderView, + NsfRecordView, + RemoveNsfFolderInput, + RemoveNsfFolderResult, + RemoveNsfRecordInput, + RemoveNsfRecordResult, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, +} from "./nsfTypes"; export type AuthProvider = () => Auth; @@ -92,15 +115,15 @@ export class NestedShareFolderManager { } public formatNsfDetail(result: GetNsfResult, verbose = false): string { - return renderNsfDetail(result, verbose); + return formatNsfDetail(result, verbose); } public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { - return renderNsfFolderDetail(view, verbose); + return formatNsfFolderDetail(view, verbose); } public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { - return renderNsfRecordDetail(view, verbose); + return formatNsfRecordDetail(view, verbose); } public async linkNestedShareRecord( @@ -126,4 +149,46 @@ export class NestedShareFolderManager { ): string { return formatRemoveNsfPreview(preview); } + + public async mkdirNestedShareFolder( + input: MkdirNsfInput, + ): Promise { + return mkdirNestedShareFolder(this.storage, this.requireAuth(), input); + } + + public async removeNestedShareFolders( + input: RemoveNsfFolderInput, + ): Promise { + return removeNestedShareFolders(this.storage, this.requireAuth(), input); + } + + public async getNestedShareRecordDetails( + input: GetNsfRecordDetailsInput, + ): Promise { + return getNestedShareRecordDetails(this.storage, this.requireAuth(), input); + } + + 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); + } } diff --git a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts new file mode 100644 index 00000000..e6e26009 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts @@ -0,0 +1,193 @@ +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' +import { + buildNsfRecordData, + getPaddedJsonBytes, +} from './nsfRecordData' +import { + 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 +} + +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 +): 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 } +} + +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 + ) + } +} + +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 +): 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 + ) + } +} + +export async function addNestedShareRecord( + 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] +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts index 5de5cc07..91ce48d7 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -1,580 +1,568 @@ -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, extractErrorMessage } from "../utils"; + getRecordFields, + getRecordLogin, + getRecordPassword, + getRecordTitle, + getRecordType, + getRecordUrl, +} from '../records/RecordUtils' +import { KeeperSdkError, ResultCodes } from '../utils' import { - buildFolderPath, - collectRecordsInFolder, - findRecordFolderLocation, - folderAccessDisplayRole, - formatAccessType, - getFolderAccessEntries, - getKeeperDriveFolder, - getKeeperDriveRecord, - isFolderShareAdministrator, - isFolderUserPermission, - isSensitiveFieldType, - normalizeParentUid, - resolveAccessUsername, - resolveNsfFolderIdentifier, - resolveNsfRecordIdentifier, -} from "./nsfHelpers"; + buildFolderPath, + collectRecordsInFolder, + 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_FOLDER_USER_PERMISSIONS_HEADING, - NSF_MASKED_VALUE, - NSF_RECORD_LABEL_WIDTH, - NSF_RECORD_USER_PERMISSIONS_HEADING, - NSF_TOP_LEVEL_FIELD_TYPES, - NSF_UNKNOWN_RECORD_TITLES, -} from "./nsfConstants"; - -function longToNumber( - value: number | { toNumber: () => number } | null | undefined, -): number | undefined { - if (value == null) return undefined; - return typeof value === "number" ? value : value.toNumber(); -} + 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' 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); -} - -export enum GetNsfFormat { - Detail = "detail", - JSON = "json", + 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) } -export type GetNsfFormatInput = GetNsfFormat | `${GetNsfFormat}`; - -export type GetNsfOptions = { - format?: GetNsfFormatInput; - verbose?: boolean; - unmask?: boolean; -}; - -export type NsfFolderAccessRow = { - username: string; - role: string; -}; - -export type NsfFolderPermission = { - accessTypeUid: string; - accessType: string; - accessRoleType: string; - inherited?: boolean; - hidden?: boolean; - canAdd?: boolean; - canRemove?: boolean; - canDelete?: boolean; - canListAccess?: boolean; - canUpdateAccess?: boolean; - canEditRecords?: boolean; - canViewRecords?: boolean; - canListRecords?: boolean; -}; - -export type NsfRecordPermission = { - username: string; - accountUid?: string; - owner: boolean; - shareAdmin: boolean; - shareable: boolean; - editable: boolean; - awaitingApproval: boolean; - expiration?: number; -}; - -export type NsfFolderView = { - objectType: "folder"; - folderUid: string; - name: string; - parentUid: string; - path: string; - userPermissions: NsfFolderAccessRow[]; - shareAdmins: NsfFolderAccessRow[]; - teamPermissions: NsfFolderPermission[]; - records: { uid: string; title: string; type: string }[]; -}; - -export type NsfRecordView = { - objectType: "record"; - recordUid: string; - title: string; - type: string; - revision: number; - version: number; - folderLocation: string; - login?: string; - password?: string; - url?: string; - notes?: string; - fields: { type: string; label?: string; value: string }[]; - userPermissions: NsfRecordPermission[]; - shareAdmins: string[]; -}; - -export type GetNsfResult = - | { kind: "folder"; view: NsfFolderView } - | { kind: "record"; view: NsfRecordView }; - 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 bytesToUid(bytes: Uint8Array | null | undefined): string | undefined { - return bytes?.length ? webSafe64FromBytes(bytes) : undefined; +function recordDetailsMessage(recordUid: string, include: Records.RecordDetailsInclude) { + return getRecordsDetailsMessage({ + clientTime: Date.now(), + recordUid: [normal64Bytes(recordUid)], + recordDetailsInclude: include, + }) } function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { - const permission = entry.permission; - return { - accessTypeUid: entry.accessTypeUid, - accessType: formatAccessType(entry.accessType), - accessRoleType: folderAccessDisplayRole(entry), - inherited: entry.inherited, - hidden: entry.hidden, - canAdd: permission?.canAdd ?? undefined, - canRemove: permission?.canRemove ?? undefined, - canDelete: permission?.canDelete ?? undefined, - canListAccess: permission?.canListAccess ?? undefined, - canUpdateAccess: permission?.canUpdateAccess ?? undefined, - canEditRecords: permission?.canEditRecords ?? undefined, - canViewRecords: permission?.canViewRecords ?? undefined, - canListRecords: permission?.canListRecords ?? undefined, - }; + 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, + storage: InMemoryStorage, + folder: DKdFolder, + entry: DKdFolderAccess, + shareUsers: Map ): NsfFolderAccessRow { - return { - username: resolveAccessUsername(storage, entry.accessTypeUid, folder), - role: folderAccessDisplayRole(entry), - }; + 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) { - const entries = getFolderAccessEntries(storage, folder.uid); - const userPermissions: NsfFolderAccessRow[] = []; - const shareAdmins: NsfFolderAccessRow[] = []; - const teamPermissions: NsfFolderPermission[] = []; - for (const entry of entries) { - if (isFolderUserPermission(entry)) { - userPermissions.push(buildFolderAccessRow(storage, folder, entry)); +function splitFolderPermissions( + 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)) + } } - if (isFolderShareAdministrator(entry)) { - shareAdmins.push(buildFolderAccessRow(storage, folder, entry)); + + 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 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), } - if (entry.accessType === Folder.AccessType.AT_TEAM) { - teamPermissions.push(mapFolderPermission(entry)); +} + +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) + }) +} + +function resolveRecordFolder( + storage: InMemoryStorage, + recordUid: string +): NsfRecordFolderView | undefined { + const folderUids = findNestedShareFoldersForRecord(storage, recordUid) + if (folderUids.length === 0) return undefined + + const uid = folderUids[0] + return { + uid, + path: buildFolderPath(storage, uid).replace(/^\//, ''), } - } - return { userPermissions, shareAdmins, teamPermissions }; } -function prependOwnerRow( - rows: NsfFolderAccessRow[], - ownerUsername: string, -): NsfFolderAccessRow[] { - if (rows.some((entry) => entry.username === ownerUsername)) return rows; - return [{ username: ownerUsername, role: "owner" }, ...rows]; +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 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), - }; +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 buildRecordFields( - record: DRecord, - unmask: boolean, -): NsfRecordView["fields"] { - return getRecordFields(record) - .filter((field) => !NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) - .map((field) => { - const rawValues = Array.isArray(field.value) - ? field.value - : [field.value]; - const displayValue = formatNsfFieldParts(rawValues).join(", "); - return { field, displayValue }; +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 + } + } + return part }) - .filter(({ displayValue }) => displayValue.length > 0) - .map(({ field, displayValue }) => ({ - type: field.type, - label: field.label, - value: - !unmask && isSensitiveFieldType(field.type) - ? NSF_MASKED_VALUE - : displayValue, - })); +} + +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): string { + return JSON.stringify(toNsfRecordJsonView(view), 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"); - lines.push(` Shareable: ${entry.shareable ? "Yes" : "No"}`); - lines.push(` Read-Only: ${entry.editable ? "No" : "Yes"}`); - return lines; + 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 } async function fetchRecordPermissions( - auth: Auth, - recordUid: string, + auth: Auth, + storage: InMemoryStorage, + recordUid: string ): Promise { - try { - const response = await auth.executeRest( - recordDetailsMessage(recordUid, Records.RecordDetailsInclude.SHARE_ONLY), - ); - const detail = response.recordDataWithAccessInfo?.[0]; - return (detail?.userPermission ?? []).map((entry) => ({ - username: entry.username || "", - accountUid: bytesToUid(entry.accountUid), - owner: !!entry.owner, - shareAdmin: !!entry.shareAdmin, - shareable: !!entry.sharable, - editable: !!entry.editable, - awaitingApproval: !!entry.awaitingApproval, - expiration: longToNumber(entry.expiration as number | null | undefined), - })); - } catch (err) { - throw new KeeperSdkError( - `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}`, - ); - } + 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 []; - } +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 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 + } } -function buildFolderView( - storage: InMemoryStorage, - folderUid: string, -): NsfFolderView { - const folder = getKeeperDriveFolder(storage, folderUid); - if (!folder) { - throw new KeeperSdkError( - `Nested share folder not found: ${folderUid}`, - ResultCodes.NSF_NOT_FOUND, - ); - } - - const split = splitFolderPermissions(storage, folder); - const { userPermissions, shareAdmins } = ensureFolderOwnerListed( - folder, - split.userPermissions, - split.shareAdmins, - ); - - return { - objectType: "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), - })), - }; +async function buildFolderView( + 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), + })), + } } 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); + 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, } - } - - const password = getRecordPassword(record); - const [userPermissions, shareAdmins] = await Promise.all([ - fetchRecordPermissions(auth, recordUid), - fetchRecordShareAdmins(auth, recordUid), - ]); - const notes = - typeof record.data?.notes === "string" && record.data.notes.trim() - ? record.data.notes.trim() - : undefined; - - return { - objectType: "record", - recordUid, - title, - type: getRecordType(record), - revision: record.revision, - version: record.version, - 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) { + 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 } + } + throw new KeeperSdkError( - "UID or title is required.", - ResultCodes.NSF_NOT_FOUND, - ); - } - - const folderUid = resolveNsfFolder(storage, trimmed); - if (folderUid) { - return { kind: "folder", view: buildFolderView(storage, folderUid) }; - } - - const recordUid = resolveNsfRecord(storage, trimmed); - if (recordUid) { - const view = await buildRecordView( - auth, - storage, - recordUid, - options.unmask ?? false, - ); - return { kind: "record", view }; - } - - throw new KeeperSdkError( - `Cannot find any Nested Share Folder object with UID or title: ${trimmed}`, - ResultCodes.NSF_NOT_FOUND, - ); + `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), - "", - NSF_FOLDER_USER_PERMISSIONS_HEADING, - ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), - "", - 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), - ); - if (view.records.length > 0) { - lines.push("", "Records:"); - for (const record of view.records) { - lines.push(` ${record.uid} ${record.title} (${record.type})`); +export function formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { + const lines = [ + folderDetailRow('Nested Share Folder UID', view.folderUid), + folderDetailRow('Name', view.name), + '', + 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}`), + ] + + if (!verbose) return lines.join('\n') + + lines.push('', 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})`) + } } - } - if (view.teamPermissions.length > 0) { - lines.push("", "Team Permissions:"); - for (const entry of view.teamPermissions) { - lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`); + if (view.teamPermissions.length > 0) { + lines.push('', 'Team Permissions:') + for (const entry of view.teamPermissions) { + lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`) + } } - } - return lines.join("\n"); + 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), - ]; - - 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) { - const label = field.label || field.type; - lines.push( - recordDetailRow( - label.charAt(0).toUpperCase() + label.slice(1), - field.value, - ), - ); - } - - if (view.userPermissions.length > 0) { - lines.push("", NSF_RECORD_USER_PERMISSIONS_HEADING); - for (const entry of view.userPermissions) { - lines.push("", ...formatRecordUserPermissionBlock(entry)); +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.shareAdmins.length > 0) { - lines.push("", `Share Admins (${view.shareAdmins.length}):`); - for (const admin of view.shareAdmins) { - lines.push(` ${admin}`); + if (view.userPermissions.length > 0) { + lines.push('', NSF_USER_PERMISSIONS_HEADING) + for (const entry of view.userPermissions) { + lines.push('', ...formatRecordUserPermissionBlock(entry)) + } } - } - - if (verbose) { - lines.push( - "", - recordDetailRow("Folder", view.folderLocation), - recordDetailRow("Revision", String(view.revision)), - recordDetailRow("Version", String(view.version)), - ); - } - - return lines.join("\n"); + + 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 (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') } export function formatNsfDetail(result: GetNsfResult, verbose = false): string { - return result.kind === "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) } diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts new file mode 100644 index 00000000..a5b5c46f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -0,0 +1,111 @@ +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 { + 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) + } + + 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 +): Promise { + 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, + } +} + +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('') + } + lines.push(`Total records retrieved: ${result.data.length}`) + return lines.join('\n').trimEnd() +} + +export function formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table +): string { + 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 +): Promise { + const recordUids = resolveRecordUids(storage, input.records) + + 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 + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index 6988791c..e4d87594 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -1,78 +1,151 @@ export { - KeeperDriveKind, - NsfItemType, - formatAccessRoleType, - formatAccessType, - normalizeParentUid, - isRootFolderUid, - resolveKeeperDriveRootParentUid, - getKeeperDriveFolders, - getKeeperDriveRecords, - findRecordFolderLocation, - buildFolderPath, - isSensitiveFieldType, - resolveAccessUsername, - folderAccessDisplayRole, - isNestedShareRecord, - isNestedShareFolder, - ensureNestedShareRecord, - ensureNestedShareFolder, - resolveNsfRecordIdentifier, - resolveNsfFolderIdentifier, - findNestedShareFoldersForRecord, - checkFolderRemovePermission, - checkRecordDeletePermission, -} from "./nsfHelpers"; + 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 { - ListNsfFormat, - listNestedShareFolders, - formatListNsfTable, - renderListNsfAsciiTable, - formatListNsfCsv, - formatListNsfJson, - formatListNsfOutput, -} from "./listNsf"; -export type { - ListNsfFormatInput, - ListNsfOptions, - ListNsfRow, - FormattedListNsfTable, -} from "./listNsf"; + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, +} from './listNsf' + +export { + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, + formatNsfRecordJson, + formatNsfJson, + toNsfRecordJsonView, +} from './getNsf' export { - GetNsfFormat, - resolveNsfFolder, - resolveNsfRecord, - getNestedShareFolder, - formatNsfFolderDetail, - formatNsfRecordDetail, - formatNsfDetail, -} from "./getNsf"; + ListNsfFormat, + GetNsfFormat, + NsfAccessRoleLabel, + NsfObjectKind, + NsfRemoveOperation, + NsfRemoveFolderOperation, + GetNsfRecordDetailsFormat, + NSF_ACCESS_ROLE_LABELS, + resolveRecordPermissionRole, + toNsfAccessRoleLabel, +} from './nsfTypes' + export type { - GetNsfFormatInput, - GetNsfOptions, - GetNsfResult, - NsfFolderView, - NsfRecordView, - NsfFolderPermission, - NsfFolderAccessRow, - NsfRecordPermission, -} from "./getNsf"; + 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 type { LinkNsfRecordResult } from "./linkNsfRecord"; +export { linkNestedShareRecord } from './linkNsfRecord' export { - NsfRemoveOperation, - removeNestedShareRecords, - formatRemoveNsfPreview, -} from "./removeNsfRecord"; -export type { - NsfRemoveOperationInput, - RemoveNsfRecordInput, - NsfRemovePreviewItem, - RemoveNsfRecordResult, -} from "./removeNsfRecord"; + 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"; +export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts index fb3069ad..d00aea38 100644 --- a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -1,176 +1,132 @@ -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"; + ensureNestedShareFolder, + ensureNestedShareRecord, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import type { LinkNsfRecordResult } from './nsfTypes' 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) { + 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, + } + } return { - encryptionType: "cbc", - keyType: Folder.EncryptedKeyType.encrypted_by_data_key, - }; - } - return { - encryptionType: "gcm", - keyType: Folder.EncryptedKeyType.encrypted_by_data_key_gcm, - }; + 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) { + 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 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; - 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, + 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); + 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 + ) } - 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 485bacb1..0e21e178 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -1,185 +1,162 @@ -import type { InMemoryStorage } from "../storage/InMemoryStorage"; +import type { InMemoryStorage } from '../storage/InMemoryStorage' import { - NsfItemType, - findRecordFolderLocation, - getKeeperDriveFolders, - getKeeperDriveRecords, - getRecordDescription, - normalizeParentUid, -} from "./nsfHelpers"; -import { getRecordTitle, getRecordType } from "../records/RecordUtils"; + NsfItemType, + findRecordFolderLocation, + getKeeperDriveFolders, + getKeeperDriveRecords, + getRecordDescription, + normalizeParentUid, + resolveKeeperDriveRootParentUid, +} 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"; + 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' -export enum ListNsfFormat { - Table = "table", - CSV = "csv", - JSON = "json", +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 type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}`; - -export type ListNsfOptions = { - folders?: boolean; - records?: boolean; - format?: ListNsfFormatInput; -}; - -export type ListNsfRow = { - itemType: NsfItemType; - uid: string; - title: string; - type: string; - description: string; - parentOrFolder: string; -}; - -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 resolveListParentOrFolder(storage: InMemoryStorage, value: string): string { + if (value !== 'root') return value + return resolveKeeperDriveRootParentUid(storage) ?? value } function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { - return getKeeperDriveFolders(storage).map((folder) => ({ - itemType: NsfItemType.Folder, - uid: folder.uid, - title: folder.data.name || "Unnamed", - type: "", - description: "", - parentOrFolder: normalizeParentUid(storage, folder.parentUid), - })); + 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)), + })) } 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: 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: resolveListParentOrFolder( + storage, + findRecordFolderLocation(storage, record.uid) || 'root' + ), + })) } -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 rows: ListNsfRow[] = [] + if (showFolders) rows.push(...collectFolderRows(storage)) + if (showRecords) rows.push(...collectRecordRows(storage)) + 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"); + 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 } export function formatListNsfJson(rows: ListNsfRow[]): string { - return JSON.stringify( - rows.map((row) => ({ - item_type: row.itemType, - uid: row.uid, - title: row.title, - type: row.type, - description: row.description, - parent_or_folder: row.parentOrFolder, - })), - null, - 2, - ); + return JSON.stringify(rows.map(toListNsfJsonRow), 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 new file mode 100644 index 00000000..b8350d7b --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -0,0 +1,224 @@ +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' +import { + buildFolderOwnerInfo, + cacheNewNsfFolder, + findExistingChildFolder, + isNestedShareFolder, + parseFolderModifyStatus, + parseNsfPath, + requireAuthDataKey, + resolveKeeperDriveParentUid, +} from './nsfHelpers' +import type { + FolderCreatePayload, + FolderSegmentCreateSpec, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, +} from './nsfTypes' + +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 +): string | null { + if (!baseFolderUid) return null + return isNestedShareFolder(storage, baseFolderUid) ? baseFolderUid : null +} + +async function resolveFolderKeyEncryptionKey( + storage: InMemoryStorage, + auth: Auth, + parentUid: string | null +): Promise { + 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 +): 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), + }), + } +} + +async function createFolderSegmentsBatch( + 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 } +} + +export async function mkdirNestedShareFolder( + 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, + }) + } + + await flushPendingSegments() + + if (!createdUid) { + throw new KeeperSdkError('Folder creation did not return a UID.', ResultCodes.NSF_MKDIR_FAILED) + } + + 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 + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index 769d8924..b8d09c45 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -1,76 +1,87 @@ -import { Folder } from "@keeper-security/keeperapi"; +import { Folder } from '@keeper-security/keeperapi' 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_ACCESS_ROLE_LABELS: Record = { - [Folder.AccessRoleType.NAVIGATOR]: "navigator", - [Folder.AccessRoleType.REQUESTOR]: "requestor", - [Folder.AccessRoleType.VIEWER]: "viewer", - [Folder.AccessRoleType.SHARED_MANAGER]: "shared-manager", - [Folder.AccessRoleType.CONTENT_MANAGER]: "content-manager", - [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: "content-share-manager", - [Folder.AccessRoleType.MANAGER]: "manager", - [Folder.AccessRoleType.UNRESOLVED]: "unresolved", -}; +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_FOLDER_USER_PERMISSIONS_HEADING = "User Permissions:"; -export const NSF_FOLDER_SHARE_ADMINS_HEADING = "Share Administrators:"; -export const NSF_RECORD_USER_PERMISSIONS_HEADING = "User Permissions:"; +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_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; + '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_REMOVALS = 500; +export const NSF_MAX_RECORD_BATCH = 500 +export const NSF_MAX_FOLDER_REMOVALS = 100 + +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', +]) + + +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']), +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index f0b14db8..7e42605d 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,554 +1,804 @@ import type { - DRecord, - DUser, - DKdFolder, - DKdFolderAccess, - DKdFolderRecord, - DKdRecordAccess, -} from "@keeper-security/keeperapi"; -import { Folder, webSafe64FromBytes } from "@keeper-security/keeperapi"; -import type { InMemoryStorage } from "../storage/InMemoryStorage"; -import { VaultObjectKind } from "../folders/folderHelpers"; -import { KeeperSdkError, ResultCodes } from "../utils"; -import { getRecordTitle } from "../records/RecordUtils"; + Auth, + DRecord, + DUser, + DKdFolder, + DKdFolderAccess, + DKdFolderRecord, + DKdRecordAccess, +} from '@keeper-security/keeperapi' import { - NSF_ACCESS_ROLE_LABELS, - NSF_ACCESS_TYPE_LABELS, - NSF_LEGACY_FOLDER_MSG, - NSF_LEGACY_RECORD_MSG, - NSF_NOTE_FIELD_TYPES, - NSF_RECORD_DESCRIPTION_MAX_LENGTH, - NSF_SENSITIVE_FIELD_TYPES, -} from "./nsfConstants"; + 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' +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' 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 isNestedShareRecord( - storage: InMemoryStorage, - recordUid: string, -): boolean { - return !!getKeeperDriveRecord(storage, recordUid); +export function nsfToNumber( + value: number | { toNumber: () => number } | null | undefined, + fallback?: number +): number | undefined { + 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 +} + +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 +} + +export function buildFolderOwnerInfo(auth: Auth): Folder.IUserInfo | undefined { + if (!auth.accountUid?.length) return undefined + return { + accountUid: auth.accountUid, + username: auth.username, + } +} + +export function parseFolderModifyStatus( + 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' +} + +export function parseRecordModifyStatus( + 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', + } +} + +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)) } -/** Per-account drive root UID inferred from sync (parentUid of top-level folders). */ -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 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 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 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) } 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 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 } -function resolveFolderByPath( - storage: InMemoryStorage, - identifier: string, -): string | undefined { - const trimmed = identifier.trim().replace(/^\/+/, ""); - if (!trimmed) return resolveKeeperDriveRootParentUid(storage) ?? ""; +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; + 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 + + 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 resolveNsfFolderUidOrName(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim() + if (!trimmed) return undefined + + if (getKeeperDriveFolder(storage, trimmed)) return trimmed + + 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 +} + +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) +} - const anyRecord = storage.getByUid(VaultObjectKind.Record, trimmed); - if (anyRecord) return anyRecord.uid; +function isRootLevelParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { + return isVirtualDriveRootParent(storage, parentUid) +} - return resolveRecordByTitleSearch(storage, trimmed)?.uid; +function folderParentsMatch( + 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) } -export function resolveNsfFolderIdentifier( - storage: InMemoryStorage, - identifier: string, +export function findExistingChildFolder( + storage: InMemoryStorage, + segment: string, + parentUid: string | null | undefined ): string | undefined { - const trimmed = identifier.trim(); - if (!trimmed) 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 + } - if (trimmed.toLowerCase() === "root" || trimmed === "/") { - return resolveKeeperDriveRootParentUid(storage) ?? ""; - } - const driveRoot = resolveKeeperDriveRootParentUid(storage); - if (driveRoot && trimmed === driveRoot) return driveRoot; + 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 + + if (normalizeParentUid(storage, folder.parentUid) === normalizeParentUid(storage, parentUid)) { + exactMatches.push(folder.uid) + continue + } + if (folderParentsMatch(storage, folder.parentUid, parentUid)) { + rootMatches.push(folder.uid) + } + } - const byUidOrName = resolveByUidOrName( - getKeeperDriveFolders(storage), - trimmed, - (folder) => folder.uid, - (folder) => folder.data.name || "", - ); - if (byUidOrName) return byUidOrName.uid; + 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 +): string | 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 +): 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, + }) +} + +export function checkFolderDeletePermission( + 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) + } - return resolveFolderByPath(storage, trimmed); + 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 findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { + return storage + .getAll(KeeperDriveKind.FolderRecord) + .filter((entry) => entry.recordUid === recordUid) + .map((entry) => entry.folderUid) } 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, - )) - ); + 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 + )) + ) } 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, - )) - ); -} - -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; + 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 } -type FolderPermissionFlag = "canRemove" | "canDelete"; +type FolderPermissionFlag = 'canRemove' | 'canDelete' 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 + } + return false } -function getRecordAccessEntries( - storage: InMemoryStorage, - recordUid: string, -): DKdRecordAccess[] { - return storage - .getAll(KeeperDriveKind.RecordAccess) - .filter((entry) => entry.recordUid === recordUid); +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, + 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; + 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") - ); + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') + ) } 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; - // Folder-trash and unlink are less destructive than owner-trash. Allow when the user - // can permanently delete the record, or owns it without explicit record-access entries - // (common for records in a personal drive root with no folder-access sync data). - 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 formatAccessRoleType( - role: Folder.AccessRoleType | null | undefined, -): string { - if (role == null) return "unknown"; - return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}`; +export function checkRecordEditPermission( + 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 + ) + } + throw new KeeperSdkError( + 'You do not have permission to edit this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) } -export function formatAccessType( - type: Folder.AccessType | null | undefined, -): string { - if (type == null) return "unknown"; - return NSF_ACCESS_TYPE_LABELS[type] ?? `type-${type}`; +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 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 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 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 getRecordDescription(record: DRecord): string { - const data = record.data; - if (!data || typeof data !== "object") return ""; +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 +} - 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); +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) + } } - } - 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, + storage: InMemoryStorage, + accessTypeUid: string, + folder?: DKdFolder, + shareUsers?: Map ): string { - for (const user of storage.getAll("user")) { - if (webSafe64FromBytes(user.accountUid) === accessTypeUid) { - return user.username; + const fromShare = shareUsers?.get(accessTypeUid) + if (fromShare) return fromShare + + 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 +} + +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) + } + } + + 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 { + } + + return map +} + +export function isFolderOwnerAccessor( + 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 +} + +export function recordAccessDisplayRole(data: Folder.IRecordAccessData): NsfAccessRoleLabel { + return formatAccessRoleType(data.accessRoleType) +} + +export async function fetchLiveRecordAccessEntries( + auth: Auth, + storage: InMemoryStorage, + recordUid: string +): Promise< + { + 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 + ) } - } - if ( - folder?.ownerInfo?.accountUid === accessTypeUid && - folder.ownerInfo.username - ) { - return folder.ownerInfo.username; - } - return accessTypeUid; } -export function folderAccessDisplayRole(entry: DKdFolderAccess): string { - if (entry.accessType === Folder.AccessType.AT_OWNER) return "owner"; - return formatAccessRoleType(entry.accessRoleType); +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 - ); + 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 - ); + return ( + entry.accessType === Folder.AccessType.AT_USER || + entry.accessType === Folder.AccessType.AT_OWNER + ) } diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts new file mode 100644 index 00000000..ffb33c22 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts @@ -0,0 +1,110 @@ +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' + +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 +): 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 + } + } + } + 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 +): Promise { + const cached = await storage.getKeyBytes(recordUid) + if (cached) return cached + + 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) + } +} + +export async function decryptRecordTitleAndType( + 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 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 } + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts new file mode 100644 index 00000000..b664d279 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts @@ -0,0 +1,385 @@ +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 + +export type RecordFieldEntry = { + type: string + label?: string + value: unknown[] +} + +export type BuildNsfRecordDataInput = { + title: string + recordType: string + notes?: string + fieldEntries?: RecordFieldEntry[] + customEntries?: RecordFieldEntry[] +} + +export type ParsedNsfFields = { + fieldEntries: RecordFieldEntry[] + customEntries: RecordFieldEntry[] + hasFileFields: boolean +} + +type NsfFieldValue = string | string[] | Record | Record[] + +type FieldAssignment = { + section: 'fields' | 'custom' + fieldType: string + fieldLabel: string + value: NsfFieldValue +} + +type ParsedFieldKey = { + 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 +} + +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 + ) + } + } + return trimmed +} + +function toFieldValueArray(value: NsfFieldValue): unknown[] { + 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] : [], + })) +} + +function fieldEntryKey(field: RecordFieldEntry): string { + return `${field.type}\0${field.label ?? ''}` +} + +function isStructuredSubkey(fieldType: string, fieldLabel: string): boolean { + 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() + if (NSF_KNOWN_FIELD_TYPES.has(fieldType)) { + return { section: 'fields', fieldType, fieldLabel: '' } + } + + 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 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 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[] +} { + 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 + } + + 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[] +): 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()] +} + +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) + } + + 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, ' ')) +} + +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 +): 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 +} + +export function parseNsfFieldInput(input: string): ParsedNsfFields { + return parseNsfFields(splitFieldTokens(input, 'comma')) +} + +export function parseNsfFieldSpaceInput(input: string): ParsedNsfFields { + return parseNsfFields(splitFieldTokens(input, 'space')) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts new file mode 100644 index 00000000..2d95a7cf --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts @@ -0,0 +1,95 @@ +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[] +} + +const RECORD_TYPE_CACHE_TTL_MS = 5 * 60 * 1000 + +let cachedAuth: Auth | undefined +let cachedRecordTypes: Records.IRecordType[] | undefined +let cachedAt = 0 + +export function clearNsfRecordTypeCache(): void { + 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 + } +} + +async function loadRecordTypes(auth: Auth): Promise { + 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 + ) + } +} + +export async function getNsfRecordTypeFields( + auth: Auth, + recordType: string +): Promise { + 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 + } + } + return undefined +} + +export async function validateNsfRecordType( + 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 fields = await getNsfRecordTypeFields(auth, normalized) + if (!fields?.length) { + throw new KeeperSdkError(`Record type "${normalized}" cannot be found.`, resultCode) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts new file mode 100644 index 00000000..9a6383d4 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -0,0 +1,395 @@ +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', +} + +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, +} + +export enum NsfObjectKind { + Folder = 'folder', + Record = 'record', +} + +export enum GetNsfFormat { + Detail = 'detail', + JSON = 'json', +} + +export type GetNsfFormatInput = GetNsfFormat | `${GetNsfFormat}` + +export type GetNsfOptions = { + format?: GetNsfFormatInput + verbose?: boolean + unmask?: boolean +} + +export type NsfFolderAccessRow = { + username: string + role: NsfAccessRoleLabel +} + +export type NsfFolderPermission = { + 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 +} + +export type NsfFolderSummary = { + 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[] +} + +export type NsfRecordFieldView = { + type: string + label?: string + value: string[] +} + +export type NsfRecordFolderView = { + uid: string + path: string +} + +export type NsfRecordJsonUserPermission = { + 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[] +} + +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[] +} + +export type GetNsfResult = + | { kind: NsfObjectKind.Folder; view: NsfFolderView } + | { kind: NsfObjectKind.Record; view: NsfRecordView } + +export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` + +export type MkdirNsfInput = { + folder: string + color?: NsfFolderColorInput + noInheritPermissions?: boolean + baseFolderUid?: string | null +} + +export type MkdirNsfResult = { + folderUid: string + created: boolean + message?: string +} + +export type FolderSegmentCreateSpec = { + segmentName: string + color?: NsfFolderColor + inheritPermissions: boolean +} + +export type FolderCreatePayload = { + 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 +} + +export type AddNsfRecordsInput = { + records: AddNsfRecordInput[] +} + +export type NsfRecordOperationResult = { + recordUid: string + success: boolean + status: string + message?: string + revision?: number +} + +export type AddNsfRecordResult = NsfRecordOperationResult + +export type AddNsfRecordsResult = { + added: AddNsfRecordResult[] + revision?: number +} + +export type RecordAddPayload = { + recordUid: string + recordAdd: RecordProto.v3.IRecordAdd +} + +export type UpdateNsfRecordInput = { + 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[] +} + +export type UpdateNsfRecordsInput = { + records: UpdateNsfRecordItemInput[] +} + +export type UpdateNsfRecordResultItem = NsfRecordOperationResult + +export type UpdateNsfRecordResult = { + updated: UpdateNsfRecordResultItem[] +} + +export type RecordUpdatePayload = { + recordUid: string + storedRecord: DRecord | undefined + mergedRecordData: Record + recordUpdate: Records.IRecordUpdate +} + +export enum ListNsfFormat { + Table = 'table', + CSV = 'csv', + JSON = 'json', +} + +export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}` + +export type ListNsfOptions = { + folders?: boolean + records?: boolean + format?: ListNsfFormatInput +} + +export type ListNsfRow = { + itemType: NsfItemType + uid: string + title: string + type: string + description: string + parentOrFolder: string +} + +export type FormattedListNsfTable = { + headers: string[] + rows: string[][] +} + +export enum NsfRemoveOperation { + OwnerTrash = 'owner-trash', + FolderTrash = 'folder-trash', + Unlink = 'unlink', +} + +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 +} + +export enum NsfRemoveFolderOperation { + FolderTrash = 'folder-trash', + DeletePermanent = 'delete-permanent', +} + +export type NsfRemoveFolderOperationInput = NsfRemoveFolderOperation | `${NsfRemoveFolderOperation}` + +export type RemoveNsfFolderInput = { + 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 } +} + +export type RemoveNsfFolderResult = { + confirmed: boolean + dryRun: boolean + operation: NsfRemoveFolderOperation + preview: NsfRemoveFolderPreviewItem[] + message?: string +} + +export enum GetNsfRecordDetailsFormat { + Table = 'table', + JSON = 'json', +} + +export type GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat | `${GetNsfRecordDetailsFormat}` + +export type NsfRecordDetailsItem = { + recordUid: string + title: string + type: string + revision: number + version: number +} + +export type GetNsfRecordDetailsResult = { + data: NsfRecordDetailsItem[] + forbiddenRecords: string[] +} + +export type GetNsfRecordDetailsInput = { + records: string[] + format?: GetNsfRecordDetailsFormatInput +} + +export type LinkNsfRecordResult = { + success: boolean + recordUid: string + folderUid: string + status: string + message: string +} + +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 +} + +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 +} diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts new file mode 100644 index 00000000..6e34567d --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -0,0 +1,231 @@ +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 { + 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, +} + +function normalizeOperation( + 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 + ) +} + +type RemovalSpec = { + folderUid: string + name: string + operation: FolderProto.v3.remove.FolderOperationType +} + +function buildRemovals( + 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 + ) + } + + 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], + }) + } + return removals +} + +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 +): Promise { + 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 mapPreview( + 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) + }) +} + +function hasPreviewErrors(preview: NsfRemoveFolderPreviewItem[]): boolean { + return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) +} + +export function formatRemoveNsfFolderPreview( + 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}`) + } + } + + return lines.join('\n').trimEnd() +} + +export async function removeNestedShareFolders( + 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 + ) + } + + 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 11dec177..2afae04e 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -1,330 +1,234 @@ -import type { Auth, folder as FolderProto } from "@keeper-security/keeperapi"; +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 { - folder, - normal64Bytes, - removeRecordMessage, - webSafe64FromBytes, -} from "@keeper-security/keeperapi"; -import type { InMemoryStorage } from "../storage/InMemoryStorage"; -import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; + checkFolderRemovePermission, + checkRecordDeletePermission, + ensureNestedShareRecord, + findNestedShareFoldersForRecord, + isRootFolderUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { NSF_MAX_RECORD_BATCH } from './nsfConstants' import { - checkFolderRemovePermission, - checkRecordDeletePermission, - ensureNestedShareRecord, - findNestedShareFoldersForRecord, - isRootFolderUid, - resolveNsfFolderIdentifier, - resolveNsfRecordIdentifier, -} from "./nsfHelpers"; -import { NSF_MAX_REMOVALS } 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", + 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, } -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 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_REMOVALS) { - throw new KeeperSdkError( - `Maximum ${NSF_MAX_REMOVALS} 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 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, - ); + if (recordIdentifiers.length === 0) { + throw new KeeperSdkError('At least one record UID or title is required.', 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) { + 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( - `No folder context for record '${identifier}'. Use folder option or owner-trash operation.`, - ResultCodes.NSF_NOT_FOUND, - ); - } - ctxFolder = folders[0]; + '--folder is required when operation is "unlink".', + ResultCodes.NSF_FOLDER_REQUIRED + ) } - 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, - ); + const folderUid = folderIdentifier ? resolveNsfFolderIdentifier(storage, folderIdentifier) : undefined + if (folderIdentifier && !folderUid) { + throw new KeeperSdkError(`Folder '${folderIdentifier}' not found`, ResultCodes.NSF_NOT_FOUND) } - removals.push({ - recordUid, - folderUid: ctxFolder, - operation: OPERATION_MAP[operation], - }); - } - return removals; + 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] + } + + 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) + } + + 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 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}`); - } +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) + } } - if (item.error?.message) { - lines.push(` Error: ${item.error.message}`); + 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('') } - lines.push(""); - } - return lines.join("\n").trimEnd(); + 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, - ); + 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); + try { + const previewResponse = await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_PREVIEW) + const preview = (previewResponse.results ?? []).map(mapPreviewItem) - if (hasPreviewErrors(preview)) { - throw new KeeperSdkError( - formatRemoveNsfPreview(preview) || "Removal preview failed.", - ResultCodes.NSF_REMOVE_FAILED, - ); - } + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError(formatRemoveNsfPreview(preview) || 'Removal preview failed.', ResultCodes.NSF_REMOVE_FAILED) + } - if (dryRun || !previewResponse.confirmationToken?.length) { - return { confirmed: false, dryRun, preview }; - } + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, preview } + } - if (!input.force) { - return { - confirmed: false, - dryRun: false, - preview, - message: "Confirmation required. Set force=true to proceed.", - }; - } + 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, - ); - } + 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/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts new file mode 100644 index 00000000..8829ed38 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -0,0 +1,196 @@ +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 { + 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: [] } +} + +function isPerRecordUpdateInput( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput +): input is UpdateNsfRecordsInput { + 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 hasUpdateChanges(changes: UpdateNsfRecordChanges): boolean { + 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 +): 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), + }, + } +} + +export async function updateNestedShareRecords( + 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 + ) + } + + 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()) + } + } + 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) { + 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 + ) + } +} + +export async function updateNestedShareRecord( + 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] +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 755d0c26..d73e5410 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -64,7 +64,12 @@ export enum NsfErrorCode { 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", } export enum TeamErrorCode { @@ -173,7 +178,12 @@ export const ResultCodes = { 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, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index bc699708..ef2ccbc0 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -125,18 +125,33 @@ import { } 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, + LinkNsfRecordResult, + ListNsfFormatInput, ListNsfOptions, ListNsfRow, - ListNsfFormatInput, - FormattedListNsfTable, -} from "../nestedShareFolders/listNsf"; -import type { GetNsfOptions, GetNsfResult } from "../nestedShareFolders/getNsf"; -import type { LinkNsfRecordResult } from "../nestedShareFolders/linkNsfRecord"; -import type { + MkdirNsfInput, + MkdirNsfResult, + RemoveNsfFolderInput, + RemoveNsfFolderResult, RemoveNsfRecordInput, RemoveNsfRecordResult, -} from "../nestedShareFolders/removeNsfRecord"; + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, +} from "../nestedShareFolders/nsfTypes"; import type { ListUserRow, ListUsersOptions, @@ -1133,6 +1148,71 @@ export class KeeperVault { 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 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 { From 20e3d19475a20bbea0389d3d54ecc49e76c1697a Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Fri, 10 Jul 2026 22:22:04 +0530 Subject: [PATCH 32/48] NSF sdk examples implemented (#205) --- examples/sdk_example/package.json | 5 + .../src/nestedShareFolders/add_nsf_record.ts | 137 ++++++++++++++++ .../src/nestedShareFolders/get_nsf.ts | 19 +-- .../get_nsf_record_details.ts | 40 +++++ .../src/nestedShareFolders/link_nsf.ts | 10 +- .../src/nestedShareFolders/list_nsf.ts | 20 +-- .../src/nestedShareFolders/mkdir_nsf.ts | 58 +++++++ .../src/nestedShareFolders/remove_nsf.ts | 57 +++---- .../src/nestedShareFolders/rmdir_nsf.ts | 118 ++++++++++++++ .../nestedShareFolders/update_nsf_record.ts | 152 ++++++++++++++++++ examples/sdk_example/src/utils/format.ts | 17 ++ .../sdk_example/src/utils/promptCommands.ts | 79 +++++++++ 12 files changed, 654 insertions(+), 58 deletions(-) create mode 100644 examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts create mode 100644 examples/sdk_example/src/utils/promptCommands.ts diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 65441eca..016577d4 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -33,6 +33,11 @@ "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", + "nsf:record-details": "ts-node src/nestedShareFolders/get_nsf_record_details.ts", + "nsf:record-update": "ts-node src/nestedShareFolders/update_nsf_record.ts", + "nsf:record-add": "ts-node src/nestedShareFolders/add_nsf_record.ts", + "nsf:mkdir": "ts-node src/nestedShareFolders/mkdir_nsf.ts", + "nsf:rmdir": "ts-node src/nestedShareFolders/rmdir_nsf.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts new file mode 100644 index 00000000..7327073c --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts @@ -0,0 +1,137 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + parseNsfFieldSpaceInput, + prompt, + type AddNsfRecordInput, + type AddNsfRecordResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' +import { promptChoice, promptRequiredList, promptYesNo, yesNoPrompt } from '../utils/promptCommands' + +type AddMode = 'single' | 'bulk' | 'multi' + +const MODE_CHOICES: Record = { + '': 'single', + '1': 'single', + '2': 'bulk', + '3': 'multi', + single: 'single', + bulk: 'bulk', + multi: 'multi', +} + +async function promptSharedRecordOptions(): Promise<{ + recordType: string + folder?: string + notes?: string + fieldEntries?: AddNsfRecordInput['fieldEntries'] + customEntries?: AddNsfRecordInput['customEntries'] + hasFileFields?: boolean + force: boolean +}> { + const recordType = (await prompt('Record type (e.g. login, general): ')).trim() + const folder = (await prompt('Folder name or UID (optional, Enter for root): ')).trim() + const notes = (await prompt('Notes (optional): ')).trim() + const fieldsInput = (await prompt('Fields (field=value, space-separated, optional): ')).trim() + const force = await promptYesNo(yesNoPrompt('Ignore warnings (e.g. attachments)?')) + const parsed = fieldsInput ? parseNsfFieldSpaceInput(fieldsInput) : undefined + + return { + recordType, + folder: folder || undefined, + notes: notes || undefined, + fieldEntries: parsed?.fieldEntries, + customEntries: parsed?.customEntries, + hasFileFields: parsed?.hasFileFields, + force, + } +} + +async function promptOneRecord(shared?: Awaited>): Promise { + const title = (await prompt('Record title: ')).trim() + if (!title) { + throw new Error('Record title is required.') + } + + if (shared) { + return { title, ...shared } + } + + const options = await promptSharedRecordOptions() + return { title, ...options } +} + +function printAddResults(added: AddNsfRecordResult[], revision?: number): void { + logger.info('') + for (const item of added) { + logger.info(`Record: ${item.recordUid}`) + logger.info(` Status: ${item.status}`) + if (item.message) logger.info(` Message: ${item.message}`) + if (item.revision != null) logger.info(` Revision: ${item.revision}`) + logger.info('') + } + if (revision != null) { + logger.info(`Batch revision: ${revision}`) + } + logger.info(`Total: ${added.length} record(s) added`) + logger.info('') +} + +async function addSingleRecord(vault: Awaited>): Promise { + const input = await promptOneRecord() + const result = await withSuppressedLogs(() => vault.addNestedShareRecord(input)) + printAddResults([result], result.revision) +} + +async function addBulkSameTemplate(vault: Awaited>): Promise { + const titles = await promptRequiredList('Record title(s), comma-separated: ', splitCommaSeparated) + const shared = await promptSharedRecordOptions() + + const records = titles.map((title) => ({ title, ...shared })) + const result = await withSuppressedLogs(() => vault.addNestedShareRecords({ records })) + printAddResults(result.added, result.revision) +} + +async function addMultipleRecords(vault: Awaited>): Promise { + const records: AddNsfRecordInput[] = [] + + logger.info('Enter each record (different title, type, folder, or fields allowed).') + while (true) { + logger.info(`--- Record ${records.length + 1} ---`) + records.push(await promptOneRecord()) + if (!(await promptYesNo(yesNoPrompt('Add another record?')))) { + break + } + } + + const result = await withSuppressedLogs(() => vault.addNestedShareRecords({ records })) + printAddResults(result.added, result.revision) +} + +async function addNsfRecord() { + const vault = await login() + + try { + logger.info('Mode: 1) single 2) bulk (same type/folder/fields, multiple titles) 3) multiple (enter each)') + const mode = await promptChoice('Choose [1]: ', MODE_CHOICES) + + if (mode === 'single') { + await addSingleRecord(vault) + } else if (mode === 'bulk') { + await addBulkSameTemplate(vault) + } else { + await addMultipleRecords(vault) + } + } catch (err) { + logger.error(`Record add failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(addNsfRecord) diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts index a997403b..1da71772 100644 --- a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts @@ -1,28 +1,25 @@ import { cleanup, extractErrorMessage, + formatNsfDetail, + formatNsfJson, GetNsfFormat, login, logger, - prompt, suppressLogs, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { isYes } from '../utils/format' +import { promptRequired, promptYesNo, yesNoPrompt } from '../utils/promptCommands' async function getNsf() { const vault = await login() try { - const identifier = (await prompt('Record UID, folder UID, or title: ')).trim() - if (!identifier) { - logger.info('No UID or title given.') - return - } + const identifier = await promptRequired('Record UID, folder UID, or title: ', 'No UID or title given.') - const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) - const verbose = isYes(await prompt('Verbose permissions? [y/N]: ')) - const unmask = isYes(await prompt('Unmask secrets? [y/N]: ')) + const asJson = await promptYesNo(yesNoPrompt('Output as JSON?')) + const verbose = await promptYesNo(yesNoPrompt('Verbose permissions?')) + const unmask = await promptYesNo(yesNoPrompt('Unmask secrets?')) const restore = suppressLogs() let result @@ -37,7 +34,7 @@ async function getNsf() { } logger.info('') - const output = asJson ? JSON.stringify(result.view, null, 2) : vault.formatNsfDetail(result, verbose) + const output = asJson ? formatNsfJson(result) : formatNsfDetail(result, verbose) process.stdout.write(`${output}\n`) logger.info('') } catch (err) { diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts new file mode 100644 index 00000000..8e8828d1 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts @@ -0,0 +1,40 @@ +import { + cleanup, + extractErrorMessage, + formatNsfRecordDetailsOutput, + GetNsfRecordDetailsFormat, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +async function getNsfRecordDetails() { + 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 formatInput = (await prompt('Output format (table/json) [table]: ')).trim().toLowerCase() + const format = + formatInput === 'json' ? GetNsfRecordDetailsFormat.JSON : GetNsfRecordDetailsFormat.Table + + const result = await withSuppressedLogs(() => vault.getNestedShareRecordDetails({ records, format })) + + logger.info('') + logger.info(formatNsfRecordDetailsOutput(result, format)) + logger.info('') + } catch (err) { + logger.error(`Record details failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(getNsfRecordDetails) diff --git a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts index 896ed6bb..63e40124 100644 --- a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts @@ -3,21 +3,17 @@ import { extractErrorMessage, login, logger, - prompt, suppressLogs, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' +import { promptRequired } from '../utils/promptCommands' async function linkNsf() { const vault = await login() try { - const recordIdentifier = (await prompt('Record UID or title: ')).trim() - const folderIdentifier = (await prompt('Destination folder UID or name: ')).trim() - if (!recordIdentifier || !folderIdentifier) { - logger.info('Both record and folder are required.') - return - } + const recordIdentifier = await promptRequired('Record UID or title: ') + const folderIdentifier = await promptRequired('Destination folder UID or name: ') const restore = suppressLogs() let result diff --git a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts index 20896bbc..735b8597 100644 --- a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts @@ -2,17 +2,17 @@ import fs from 'fs/promises' import { cleanup, extractErrorMessage, + formatListNsfOutput, ListNsfFormat, login, logger, - prompt, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { isYes } from '../utils/format' +import { promptChoice, promptOptional, promptYesNo, yesNoPrompt } from '../utils/promptCommands' type ListMode = 'all' | 'folders' | 'records' -const MODE_BY_INPUT: Record = { +const LIST_MODE_CHOICES: Record = { '': 'all', '1': 'all', '2': 'folders', @@ -22,19 +22,15 @@ const MODE_BY_INPUT: Record = { records: 'records', } -function parseMode(input: string): ListMode { - return MODE_BY_INPUT[input.trim().toLowerCase()] ?? 'all' -} - async function listNsf() { const vault = await login() try { logger.info('Show: 1) all 2) folders only 3) records only') - const mode = parseMode(await prompt('Choose [1]: ')) - 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 mode = await promptChoice('Choose [1]: ', LIST_MODE_CHOICES) + const asJson = await promptYesNo(yesNoPrompt('Output as JSON?')) + const asCsv = !asJson && (await promptYesNo(yesNoPrompt('Output as CSV?'))) + const outputPath = await promptOptional('Output file path (Enter for stdout): ') const format = asJson ? ListNsfFormat.JSON : asCsv ? ListNsfFormat.CSV : ListNsfFormat.Table const rows = vault.listNestedShareFolders({ @@ -47,7 +43,7 @@ async function listNsf() { return } - const output = vault.formatListNsfOutput(rows, format) + const output = formatListNsfOutput(rows, format) if (outputPath && format !== ListNsfFormat.Table) { await fs.writeFile(outputPath, output, 'utf-8') logger.info(`Wrote ${rows.length} row(s) to ${outputPath}`) diff --git a/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts new file mode 100644 index 00000000..c57be31a --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts @@ -0,0 +1,58 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_FOLDER_COLORS, + prompt, + type MkdirNsfInput, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +async function runMkdirNsf(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.some((candidate) => candidate === 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 mkdirNsf() { + const vault = await login() + + try { + await runMkdirNsf(vault) + } catch (err) { + logger.error(`Folder create failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(mkdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts index d4ce1c7d..e15aec28 100644 --- a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts @@ -1,18 +1,27 @@ import { cleanup, + collectRemoveNsfWarnings, extractErrorMessage, + formatRemoveNsfPreview, login, logger, NsfRemoveOperation, - prompt, suppressLogs, type RemoveNsfRecordInput, type RemoveNsfRecordResult, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { isYes } from '../utils/format' +import { splitCommaSeparated } from '../utils/format' +import { + promptChoice, + promptOptional, + promptRequired, + promptRequiredList, + promptYesNo, + yesNoPrompt, +} from '../utils/promptCommands' -const OPERATION_BY_INPUT: Record = { +const OPERATION_CHOICES: Record = { '': NsfRemoveOperation.OwnerTrash, '1': NsfRemoveOperation.OwnerTrash, '2': NsfRemoveOperation.FolderTrash, @@ -22,22 +31,16 @@ const OPERATION_BY_INPUT: Record = { unlink: NsfRemoveOperation.Unlink, } -function parseOperation(input: string): NsfRemoveOperation { - return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveOperation.OwnerTrash -} - -function printPreview(vault: Awaited>, result: RemoveNsfRecordResult): void { +function printPreview(result: RemoveNsfRecordResult): void { if (result.preview.length === 0) return logger.info('') - logger.info(vault.formatRemoveNsfPreview(result.preview)) + logger.info(formatRemoveNsfPreview(result.preview)) logger.info('') } function printPreviewWarnings(result: RemoveNsfRecordResult): void { - for (const item of result.preview) { - for (const warning of item.impact?.warnings ?? []) { - logger.info(`Warning: ${warning}`) - } + for (const warning of collectRemoveNsfWarnings(result.preview)) { + logger.info(`Warning: ${warning}`) } } @@ -57,21 +60,19 @@ async function removeNsf() { const vault = await login() try { - const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() - const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) - if (records.length === 0) { - logger.info('At least one record is required.') - return - } + const records = await promptRequiredList( + 'Record UID(s) or title(s), comma-separated: ', + splitCommaSeparated + ) logger.info('Operation: 1) owner-trash 2) folder-trash 3) unlink') - const operation = parseOperation(await prompt('Choose [1]: ')) + const operation = await promptChoice('Choose [1]: ', OPERATION_CHOICES) const folder = operation === NsfRemoveOperation.Unlink - ? (await prompt('Folder UID or name (required for unlink): ')).trim() - : (await prompt('Folder UID or name (optional): ')).trim() - const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) - const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + ? await promptRequired('Folder UID or name (required for unlink): ') + : await promptOptional('Folder UID or name (optional): ') + const dryRun = await promptYesNo(yesNoPrompt('Dry run (preview only)?')) + const force = dryRun ? false : await promptYesNo(yesNoPrompt('Force confirm without prompt?')) const baseInput: RemoveNsfRecordInput = { records, @@ -81,14 +82,14 @@ async function removeNsf() { if (dryRun) { const result = await removeNestedShareRecords(vault, { ...baseInput, dryRun: true }) - printPreview(vault, result) + printPreview(result) logger.info('[Dry-run] No records were removed.') return } if (force) { const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) - printPreview(vault, result) + printPreview(result) if (result.confirmed && result.message) { logger.info(result.message) } @@ -96,10 +97,10 @@ async function removeNsf() { } const preview = await removeNestedShareRecords(vault, { ...baseInput, force: false }) - printPreview(vault, preview) + printPreview(preview) printPreviewWarnings(preview) - if (!isYes(await prompt('Do you want to proceed with deletion? [y/n]: '))) { + if (!(await promptYesNo(yesNoPrompt('Do you want to proceed with deletion?')))) { logger.info('Removal cancelled.') return } diff --git a/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts new file mode 100644 index 00000000..641e3418 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts @@ -0,0 +1,118 @@ +import { + cleanup, + extractErrorMessage, + formatRemoveNsfFolderPreview, + login, + logger, + NsfRemoveFolderOperation, + prompt, + 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 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 parseOperation(input: string): NsfRemoveFolderOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash +} + +function logPreview(result: RemoveNsfFolderResult, quiet: boolean): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(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 runRmdirNsf(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(result, quiet) + logger.info('[Dry-run] No folders were deleted.') + return + } + + if (force) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + logPreview(result, quiet) + if (result.confirmed && result.message) logger.info(result.message) + return + } + + const preview = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: false })) + logPreview(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 rmdirNsf() { + const vault = await login() + + try { + await runRmdirNsf(vault) + } catch (err) { + logger.error(`Folder remove failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rmdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts new file mode 100644 index 00000000..d5d7fb0f --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts @@ -0,0 +1,152 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_MAX_RECORD_BATCH, + parseNsfFieldInput, + prompt, + type UpdateNsfRecordItemInput, + type UpdateNsfRecordResultItem, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' +import { promptChoice, promptRequired, promptRequiredList, promptYesNo, yesNoPrompt } from '../utils/promptCommands' + +type UpdateMode = 'single' | 'bulk' | 'multi' + +const MODE_CHOICES: Record = { + '': 'single', + '1': 'single', + '2': 'bulk', + '3': 'multi', + single: 'single', + bulk: 'bulk', + multi: 'multi', +} + +function hasUpdateChanges(item: UpdateNsfRecordItemInput): boolean { + return !!( + item.title?.trim() || + item.recordType?.trim() || + item.notes?.trim() || + item.fieldEntries?.length || + item.customEntries?.length + ) +} + +async function promptRecordChanges(recordLabel = 'Record'): Promise> { + const title = (await prompt('New title (optional): ')).trim() + const recordType = (await prompt('Record type (optional): ')).trim() + const notes = (await prompt('Notes (optional): ')).trim() + const fieldsInput = (await prompt('Fields (type=value, comma-separated, optional): ')).trim() + const parsed = fieldsInput ? parseNsfFieldInput(fieldsInput) : undefined + + const changes: Omit = { + title: title || undefined, + recordType: recordType || undefined, + notes: notes || undefined, + fieldEntries: parsed?.fieldEntries, + customEntries: parsed?.customEntries, + } + + if (!hasUpdateChanges({ record: recordLabel, ...changes })) { + throw new Error('At least one field to update is required (title, type, notes, or fields).') + } + + return changes +} + +function printUpdateResults(updated: UpdateNsfRecordResultItem[]): void { + logger.info('') + for (const item of updated) { + logger.info(`Record: ${item.recordUid}`) + logger.info(` Status: ${item.status}`) + if (item.message) logger.info(` Message: ${item.message}`) + if (item.revision != null) logger.info(` Revision: ${item.revision}`) + logger.info('') + } + logger.info(`Total: ${updated.length} record(s) updated`) + logger.info('') +} + +async function updateSingleRecord(vault: Awaited>): Promise { + const record = await promptRequired('Record UID or title: ') + const changes = await promptRecordChanges(record) + const result = await withSuppressedLogs(() => vault.updateNestedShareRecord({ record, ...changes })) + printUpdateResults([result]) +} + +async function updateBulkSameChanges(vault: Awaited>): Promise { + const records = await promptRequiredList( + 'Record UID(s) or title(s), comma-separated: ', + splitCommaSeparated + ) + + if (records.length > NSF_MAX_RECORD_BATCH) { + logger.error(`Maximum ${NSF_MAX_RECORD_BATCH} records per request.`) + return + } + + logger.info( + `Updating ${records.length} record(s) in one batch. The same title, type, notes, and fields apply to all.` + ) + + const changes = await promptRecordChanges() + const result = await withSuppressedLogs(() => + vault.updateNestedShareRecords({ + records, + ...changes, + }) + ) + printUpdateResults(result.updated) +} + +async function updateMultipleRecords(vault: Awaited>): Promise { + const items: UpdateNsfRecordItemInput[] = [] + + logger.info('Enter each record update (different record and changes allowed).') + while (true) { + logger.info(`--- Record ${items.length + 1} ---`) + const record = await promptRequired('Record UID or title: ') + const changes = await promptRecordChanges(record) + items.push({ record, ...changes }) + + if (items.length >= NSF_MAX_RECORD_BATCH) { + logger.info(`Maximum ${NSF_MAX_RECORD_BATCH} records per request.`) + break + } + if (!(await promptYesNo(yesNoPrompt('Update another record?')))) { + break + } + } + + const result = await withSuppressedLogs(() => vault.updateNestedShareRecords({ records: items })) + printUpdateResults(result.updated) +} + +async function updateNsfRecord() { + const vault = await login() + + try { + logger.info( + 'Mode: 1) single 2) bulk (same changes, multiple records) 3) multiple (enter each)' + ) + const mode = await promptChoice('Choose [1]: ', MODE_CHOICES) + + if (mode === 'single') { + await updateSingleRecord(vault) + } else if (mode === 'bulk') { + await updateBulkSameChanges(vault) + } else { + await updateMultipleRecords(vault) + } + } catch (err) { + logger.error(`Record update failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(updateNsfRecord) diff --git a/examples/sdk_example/src/utils/format.ts b/examples/sdk_example/src/utils/format.ts index b9482be6..9d332b01 100644 --- a/examples/sdk_example/src/utils/format.ts +++ b/examples/sdk_example/src/utils/format.ts @@ -2,10 +2,20 @@ import { EMAIL_LIST_SEPARATOR_PATTERN, EMAIL_PATTERN, isValidEmail, + suppressLogs, } from '@keeper-security/keeper-sdk-javascript' export { EMAIL_PATTERN } +export async function withSuppressedLogs(fn: () => T | Promise): Promise { + const restore = suppressLogs() + try { + return await fn() + } finally { + restore() + } +} + export function padRight(str: string, len: number): string { if (str.length > len) { return len > 1 ? str.substring(0, len - 1) + '\u2026' : str.substring(0, len) @@ -40,6 +50,13 @@ export function isYes(answer: string): boolean { return normalized === 'y' || normalized === 'yes' } +export function splitCommaSeparated(input: string): string[] { + return input + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) +} + export function parseEmails(raw: string): { emails: string[]; invalid: string[] } { const tokens = raw .split(EMAIL_LIST_SEPARATOR_PATTERN) diff --git a/examples/sdk_example/src/utils/promptCommands.ts b/examples/sdk_example/src/utils/promptCommands.ts new file mode 100644 index 00000000..7650731c --- /dev/null +++ b/examples/sdk_example/src/utils/promptCommands.ts @@ -0,0 +1,79 @@ +import { logger, prompt } from '@keeper-security/keeper-sdk-javascript' + +export const YES_NO_PROMPT_SUFFIX = '[y/n]: ' + +const YES_ANSWERS = new Set(['y', 'yes']) +const NO_ANSWERS = new Set(['n', 'no']) + +export function yesNoPrompt(label: string): string { + return `${label} ${YES_NO_PROMPT_SUFFIX}` +} + +export function isYes(answer: string): boolean { + const normalized = answer.trim().toLowerCase() + return YES_ANSWERS.has(normalized) +} + +function parseYesNo(answer: string): boolean | null | undefined { + const normalized = answer.trim().toLowerCase() + if (normalized === '') return undefined + if (YES_ANSWERS.has(normalized)) return true + if (NO_ANSWERS.has(normalized)) return false + return null +} + +export async function promptYesNo(question: string, defaultValue = false): Promise { + while (true) { + const answer = await prompt(question) + const parsed = parseYesNo(answer) + if (parsed === null) { + logger.warn('Invalid input. Enter y, yes, n, or no.') + continue + } + if (parsed === undefined) return defaultValue + return parsed + } +} + +export async function promptChoice( + question: string, + choices: Readonly>, + defaultKey = '' +): Promise { + while (true) { + const raw = await prompt(question) + const key = raw.trim().toLowerCase() + const lookupKey = key === '' ? defaultKey : key + if (lookupKey in choices) { + return choices[lookupKey] + } + + const validKeys = [...new Set(Object.keys(choices).filter((k) => k !== ''))].join(', ') + const defaultHint = defaultKey in choices ? ` (default: ${defaultKey || 'Enter'})` : '' + logger.warn(`Invalid input "${raw.trim()}". Valid choices: ${validKeys}${defaultHint}.`) + } +} + +export async function promptRequired(question: string, message = 'Value is required.'): Promise { + while (true) { + const answer = (await prompt(question)).trim() + if (answer) return answer + logger.warn(message) + } +} + +export async function promptOptional(question: string): Promise { + return (await prompt(question)).trim() +} + +export async function promptRequiredList( + question: string, + parse: (input: string) => string[], + message = 'At least one value is required.' +): Promise { + while (true) { + const items = parse((await prompt(question)).trim()) + if (items.length > 0) return items + logger.warn(message) + } +} From 5c78473ea4b3b8610720f6a94850677ccb7665a1 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Tue, 16 Jun 2026 21:52:57 +0530 Subject: [PATCH 33/48] initial implementation of vault cli --- KeeperSdk/README.md | 47 +++- KeeperSdk/package-lock.json | 10 +- KeeperSdk/package.json | 2 +- KeeperSdk/src/api.ts | 40 ++++ KeeperSdk/src/cli/access.ts | 30 +++ KeeperSdk/src/cli/builtinCommands.ts | 36 +++ KeeperSdk/src/cli/commandHelpers.ts | 24 ++ KeeperSdk/src/cli/commander/get.ts | 42 ++++ KeeperSdk/src/cli/commander/getCore.ts | 125 ++++++++++ KeeperSdk/src/cli/commander/index.ts | 4 + KeeperSdk/src/cli/commander/misc.ts | 187 +++++++++++++++ KeeperSdk/src/cli/commander/nav.ts | 234 +++++++++++++++++++ KeeperSdk/src/cli/commands/help.ts | 76 ++++++ KeeperSdk/src/cli/commands/login.ts | 178 ++++++++++++++ KeeperSdk/src/cli/commands/logout.ts | 39 ++++ KeeperSdk/src/cli/commands/restoreSession.ts | 211 +++++++++++++++++ KeeperSdk/src/cli/commands/sync.ts | 61 +++++ KeeperSdk/src/cli/commands/vault.ts | 56 +++++ KeeperSdk/src/cli/dispatch.ts | 52 +++++ KeeperSdk/src/cli/help.ts | 82 +++++++ KeeperSdk/src/cli/index.ts | 109 +++++++++ KeeperSdk/src/cli/jsonArg.ts | 30 +++ KeeperSdk/src/cli/parse.ts | 173 ++++++++++++++ KeeperSdk/src/cli/parser.ts | 172 ++++++++++++++ KeeperSdk/src/cli/prompt.ts | 12 + KeeperSdk/src/cli/registry.ts | 55 +++++ KeeperSdk/src/cli/table.ts | 8 + KeeperSdk/src/cli/types.ts | 90 +++++++ KeeperSdk/src/cli/utils.ts | 18 ++ KeeperSdk/src/cli/vaultSurface.ts | 18 ++ 30 files changed, 2210 insertions(+), 11 deletions(-) create mode 100644 KeeperSdk/src/cli/access.ts create mode 100644 KeeperSdk/src/cli/builtinCommands.ts create mode 100644 KeeperSdk/src/cli/commandHelpers.ts create mode 100644 KeeperSdk/src/cli/commander/get.ts create mode 100644 KeeperSdk/src/cli/commander/getCore.ts create mode 100644 KeeperSdk/src/cli/commander/index.ts create mode 100644 KeeperSdk/src/cli/commander/misc.ts create mode 100644 KeeperSdk/src/cli/commander/nav.ts create mode 100644 KeeperSdk/src/cli/commands/help.ts create mode 100644 KeeperSdk/src/cli/commands/login.ts create mode 100644 KeeperSdk/src/cli/commands/logout.ts create mode 100644 KeeperSdk/src/cli/commands/restoreSession.ts create mode 100644 KeeperSdk/src/cli/commands/sync.ts create mode 100644 KeeperSdk/src/cli/commands/vault.ts create mode 100644 KeeperSdk/src/cli/dispatch.ts create mode 100644 KeeperSdk/src/cli/help.ts create mode 100644 KeeperSdk/src/cli/index.ts create mode 100644 KeeperSdk/src/cli/jsonArg.ts create mode 100644 KeeperSdk/src/cli/parse.ts create mode 100644 KeeperSdk/src/cli/parser.ts create mode 100644 KeeperSdk/src/cli/prompt.ts create mode 100644 KeeperSdk/src/cli/registry.ts create mode 100644 KeeperSdk/src/cli/table.ts create mode 100644 KeeperSdk/src/cli/types.ts create mode 100644 KeeperSdk/src/cli/utils.ts create mode 100644 KeeperSdk/src/cli/vaultSurface.ts diff --git a/KeeperSdk/README.md b/KeeperSdk/README.md index b4b5ca70..90ec3929 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -1,8 +1,6 @@ # @keeper-security/keeper-sdk-javascript -Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folders, and enterprise admin APIs. - -> **CLI:** Commander-style shell commands (`dispatchCliLine`, `help`, `get`, `ls`, …) live in [**@keeper-security/keeper-shell-component**](https://www.npmjs.com/package/@keeper-security/keeper-shell-component) (or this monorepo’s `commander-javascript-cli` package). This SDK package is API-only. +Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folders, enterprise admin APIs, and a built-in Commander-style shell CLI. [![NPM](https://img.shields.io/npm/v/@keeper-security/keeper-sdk-javascript?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeper-sdk-javascript) @@ -48,11 +46,48 @@ console.log(`Loaded ${vault.getRecords().length} records`); - **Folders**: List, get, create, rename, delete, change directory, folder tree - **Shared folders**: List shared folders, share with users, update permissions - **Sharing**: Share and unshare records, check share info -- **Teams / users / roles** (enterprise admin): Available via the SDK API +- **Teams / users / roles** (enterprise admin): Available via the SDK API; not exposed as shell commands in this release -## Examples +## Built-in shell CLI + +The package includes a Commander-style CLI (`dispatchCliLine`, `createKeeperCliParser`) for auth, records, and folders. + +**Before login:** `help`, `login`, `restore-session` + +**After login:** + +| Area | Commands | +|------|----------| +| Session | `logout`, `sync` (`syncdown`, `sync-down`, `d`), `whoami` | +| Records | `list` (`l`), `search` (`s`), `get` (`g`) | +| Folders | `ls`, `cd`, `tree`, `mkdir`, `list-sf` (`lsf`) | +| Vault info | `vault summary` | + +Every command supports `--help`. Record/folder write operations (`add`, `update`, `delete`, `share`, …) are SDK-only — see the examples below. + +### Finding records and folders -Shell CLI (`dispatchCliLine`, categorized `help`, record/folder commands) is provided by **@keeper-security/keeper-shell-component** — see that package’s `src/keeper-cli/README.md`. +| Goal | Command | +|------|---------| +| Record by UID (exact) | `get ` | +| Record by title | `get "Gmail Login"` or `search gmail` | +| Text in title/fields | `search ` (all terms must match) | +| Shared folder by UID | `get ` or `list-sf ` | +| Folder by path/UID | `get `, `ls`, `cd`, `tree` | +| All records (table) | `list` or `list --verbose` | +| Account summary | `whoami` or `vault summary` | + +`search` only covers **vault records** (title, fields, UID). It does not search teams or enterprise users — use the SDK API (`vault.viewTeam`, `vault.listTeams`, …) or `examples/sdk_example` scripts for those. + +```typescript +import { dispatchCliLine, type KeeperCliHost } from "@keeper-security/keeper-sdk-javascript"; + +await dispatchCliLine("restore-session --from-json session.json", host); +await dispatchCliLine("list", host); +await dispatchCliLine("ls", host); +``` + +## Examples Runnable SDK scripts are in [`examples/sdk_example`](../examples/sdk_example): diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index db1eb418..e3112485 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "18.0.2", + "@keeper-security/keeperapi": "18.0.4", "asmcrypto.js": "^2.3.2", "ts-node": "^10.7.0", "typescript": "^4.6.3" @@ -57,9 +57,9 @@ } }, "node_modules/@keeper-security/keeperapi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.2.tgz", - "integrity": "sha512-wF6VfvNJhwIXYaL2av5Rs7qgME1srarZjXaZu9qTbUUhP9BrWlJiP0gqRrfXqokHSWVG1ct3mkte+Z6GRr4nYQ==", + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.4.tgz", + "integrity": "sha512-/tQKZUBkOi8ne/nrSUF9G4jMtqgl1QFbgQ4NqjoG9XwyYSgHtJ4QPRVmH+pIinIZ7tuOLt7GdU6wbH7e1Odeyg==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -144,6 +144,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -573,6 +574,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index af068fbc..9beabba1 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -24,7 +24,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "18.0.2", + "@keeper-security/keeperapi": "18.0.4", "ts-node": "^10.7.0", "asmcrypto.js": "^2.3.2", "typescript": "^4.6.3" diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts index 341f0b98..887c69ca 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -358,6 +358,46 @@ export type { RoleViewTableRow, } from "./roles"; +export { + dispatchCliLine, + dispatchKeeperCli, + ensureKeeperCliRegistry, + registerCliCommand, + registerCliAlias, + getCliCommand, + listCliCommands, + listCliCommandNames, + listCliCommandNamesForLoginState, + listCliCommandsForLoginState, + isAuthCliCommand, + listDocumentedCommands, + getDetailedHelpPage, + formatDetailedHelpForCommand, + tokenizeArguments, + parseCliArgs, + wantsCliHelp, + rejectUnknownOptions, + loginWithCredentials, + loginWithSessionToken, + runLoginCommand, + runLogoutCommand, + KeeperCliParser, + createKeeperCliParser, + getKeeperCliPromptPrefix, + BUILTIN_CLI_COMMANDS, + registerBuiltinCliCommands, + listCommand, +} from "./cli"; +export type { KeeperCliParserOptions } from "./cli"; +export type { + CliResult, + ParsedCli, + CliCommandDefinition, + CliHelpDoc, + KeeperCliHost, + KeeperCliVault, +} from "./cli"; + export { Auth, KeeperEnvironment, diff --git a/KeeperSdk/src/cli/access.ts b/KeeperSdk/src/cli/access.ts new file mode 100644 index 00000000..027c0467 --- /dev/null +++ b/KeeperSdk/src/cli/access.ts @@ -0,0 +1,30 @@ +import type { CliCommandDefinition } from './types' +import { listCliCommands, resolveCliCommandName } from './registry' + +/** Commands available before a vault session exists. */ +export const AUTH_CLI_COMMAND_NAMES = new Set([ + 'help', + 'login', + 'restore-session', +]) + +export function isAuthCliCommand(name: string): boolean { + const resolved = resolveCliCommandName(name) + return resolved != null && AUTH_CLI_COMMAND_NAMES.has(resolved) +} + +export function filterCliCommandsForLoginState( + commands: readonly CliCommandDefinition[], + loggedIn: boolean +): CliCommandDefinition[] { + if (loggedIn) return [...commands] + return commands.filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) +} + +export function listCliCommandsForLoginState(loggedIn: boolean): CliCommandDefinition[] { + return filterCliCommandsForLoginState(listCliCommands(), loggedIn) +} + +export function listCliCommandNamesForLoginState(loggedIn: boolean): readonly string[] { + return listCliCommandsForLoginState(loggedIn).map((c) => c.name) +} diff --git a/KeeperSdk/src/cli/builtinCommands.ts b/KeeperSdk/src/cli/builtinCommands.ts new file mode 100644 index 00000000..1cd33332 --- /dev/null +++ b/KeeperSdk/src/cli/builtinCommands.ts @@ -0,0 +1,36 @@ +import type { CliCommandDefinition } from './types' +import { registerCliCommand } from './registry' +import { helpCommand } from './commands/help' +import { loginCommand } from './commands/login' +import { logoutCommand } from './commands/logout' +import { restoreSessionCommand } from './commands/restoreSession' +import { syncCommand } from './commands/sync' +import { vaultCommand } from './commands/vault' +import { getCommand } from './commander/get' +import { cdCommand, lsCommand, mkdirCommand, treeCommand } from './commander/nav' +import { listCommand, listSfCommand, searchCommand, whoamiCommand } from './commander/misc' + +/** Built-in CLI commands (Keeper Commander-style vault shell). */ +export const BUILTIN_CLI_COMMANDS: readonly CliCommandDefinition[] = [ + helpCommand, + loginCommand, + restoreSessionCommand, + syncCommand, + vaultCommand, + getCommand, + listCommand, + lsCommand, + cdCommand, + treeCommand, + mkdirCommand, + searchCommand, + listSfCommand, + whoamiCommand, + logoutCommand, +] + +export function registerBuiltinCliCommands(): void { + for (const def of BUILTIN_CLI_COMMANDS) { + registerCliCommand(def) + } +} diff --git a/KeeperSdk/src/cli/commandHelpers.ts b/KeeperSdk/src/cli/commandHelpers.ts new file mode 100644 index 00000000..e4fa6652 --- /dev/null +++ b/KeeperSdk/src/cli/commandHelpers.ts @@ -0,0 +1,24 @@ +import type { CliResult, KeeperCliHost, KeeperCliVault } from './types' +import { ensureLoggedIn } from './commands/login' + +export async function ensureSession(host: KeeperCliHost): Promise { + const v = host.getVault() + if (v.isLoggedIn) return null + const r = await ensureLoggedIn(host) + return r.code === 0 ? null : r +} + +export function ensureCapability( + v: KeeperCliVault, + name: K, + context: string +): CliResult | null { + if (typeof v[name] !== 'function') { + return { + code: 1, + out: '', + err: `${context}: this host does not expose KeeperCliVault.${String(name)}.\n`, + } + } + return null +} diff --git a/KeeperSdk/src/cli/commander/get.ts b/KeeperSdk/src/cli/commander/get.ts new file mode 100644 index 00000000..24fd82c1 --- /dev/null +++ b/KeeperSdk/src/cli/commander/get.ts @@ -0,0 +1,42 @@ +import type { CliCommandDefinition } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { executeGet } from './getCore' + +export const getCommand: CliCommandDefinition = { + name: 'get', + order: 10, + aliases: ['g'], + description: 'Get details of a record or folder by UID or title.', + usage: 'get [--format {detail,json,password,fields}] [--unmask]', + flagOptions: ['--format', '--unmask', '--detail', '--json'], + help: { + title: 'get — record/folder details (Keeper Commander)', + synopsis: 'usage: get [--unmask] [--format {detail,json,password,fields}] uid', + description: + ' Resolves a vault object by UID or title. Records support all output formats; folders and shared folders support detail/json.\n' + + ' Prefer get for exact UID lookup; search is for text in titles and fields.', + arguments: ' uid Record, folder, or shared-folder UID or title.', + options: ` --format {detail,json,password,fields} + detail (default): human-readable output. + json: JSON object. + password: password field only (records). + fields: JSON array of {name, value} (records). + --unmask Show sensitive field values (records). + --help, -h Show this help.`, + examples: ` get "Amazon" + get AbCdEf123456 --format json --unmask + get MyFolderUid --format json`, + seeAlso: ' ls, search, list', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(getCommand), err: '' } + } + try { + return await executeGet(host, parsed, 'get') + } catch (e) { + return { code: 1, out: '', err: host.formatError('get', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commander/getCore.ts b/KeeperSdk/src/cli/commander/getCore.ts new file mode 100644 index 00000000..6d98bde0 --- /dev/null +++ b/KeeperSdk/src/cli/commander/getCore.ts @@ -0,0 +1,125 @@ +import type { DRecord } from '@keeper-security/keeperapi' +import { + formatRecord, + formatRecordFields, + getRecordPassword, +} from '../../records/RecordUtils' +import type { CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt } from '../parse' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { GetFolderFormat } from '../../folders/getFolder' + +export type GetOutputFormat = 'detail' | 'json' | 'password' | 'fields' + +export function resolveGetFormat(parsed: ParsedCli): GetOutputFormat { + const raw = getOpt(parsed.opts, 'format')?.toLowerCase() + if (raw === 'json' || hasOpt(parsed.opts, 'json')) return 'json' + if (raw === 'password') return 'password' + if (raw === 'fields') return 'fields' + if (raw === 'detail') return 'detail' + return hasOpt(parsed.opts, 'detail') ? 'detail' : 'detail' +} + +export function resolveGetUnmask(parsed: ParsedCli): boolean { + return hasOpt(parsed.opts, 'unmask') +} + +export function getGetTarget(parsed: ParsedCli): string | undefined { + return parsed.positional[0]?.trim() || undefined +} + +async function outputRecord( + host: KeeperCliHost, + record: DRecord, + fmt: GetOutputFormat, + unmask: boolean, + cmd: string +): Promise { + if (fmt === 'password') { + const pw = getRecordPassword(record) + return { code: 0, out: pw ? `${pw}\n` : '', err: pw ? '' : `${cmd}: record has no password field\n` } + } + if (fmt === 'fields') { + return { code: 0, out: JSON.stringify(formatRecordFields(record, unmask), null, 2) + '\n', err: '' } + } + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(record, null, 2) + '\n', err: '' } + } + return { code: 0, out: formatRecord(record, { showDetails: true, unmask }) + '\n', err: '' } +} + +async function tryGetFolder( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + if (!v.getFolder) return null + try { + const res = await v.getFolder(target, { + format: fmt === 'json' ? GetFolderFormat.JSON : GetFolderFormat.Detail, + }) + if (fmt === 'json') { + const json = (res as { json?: Record }).json ?? res + return { code: 0, out: JSON.stringify(json, null, 2) + '\n', err: '' } + } + const name = 'name' in res ? res.name : target + const uid = + 'folder_uid' in res + ? res.folder_uid + : 'shared_folder_uid' in res + ? res.shared_folder_uid + : target + return { code: 0, out: `${name}\t${uid}\n`, err: '' } + } catch { + return null + } +} + +async function tryGetSharedFolderByUid( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + const hit = v.getSharedFolders().find((sf) => sf.uid === target) + if (!hit) return null + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(hit, null, 2) + '\n', err: '' } + } + if (fmt === 'password' || fmt === 'fields') { + return { code: 1, out: '', err: `${cmd}: --format ${fmt} applies to records only\n` } + } + return { code: 0, out: `${hit.name ?? '(unnamed)'}\t${hit.uid}\n`, err: '' } +} + +/** Commander-style `get` (record, folder, or shared folder by UID/title). */ +export async function executeGet(host: KeeperCliHost, parsed: ParsedCli, cmd = 'get'): Promise { + const target = getGetTarget(parsed) + if (!target) { + return { code: 1, out: '', err: `${cmd}: UID parameter is required\n` } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const fmt = resolveGetFormat(parsed) + const unmask = resolveGetUnmask(parsed) + + await v.sync() + + const sf = await tryGetSharedFolderByUid(host, target, fmt, cmd) + if (sf) return sf + + const folder = await tryGetFolder(host, target, fmt, cmd) + if (folder) return folder + + const cap = ensureCapability(v, 'findRecord', cmd) + if (cap) return cap + const record = v.findRecord!(target) + if (!record) { + return { code: 1, out: '', err: `${cmd}: cannot find any object matching "${target}"\n` } + } + return outputRecord(host, record, fmt, unmask, cmd) +} diff --git a/KeeperSdk/src/cli/commander/index.ts b/KeeperSdk/src/cli/commander/index.ts new file mode 100644 index 00000000..6795faac --- /dev/null +++ b/KeeperSdk/src/cli/commander/index.ts @@ -0,0 +1,4 @@ +export { getCommand } from './get' +export { executeGet } from './getCore' +export { lsCommand, cdCommand, treeCommand, mkdirCommand } from './nav' +export { listCommand, searchCommand, listSfCommand, whoamiCommand } from './misc' diff --git a/KeeperSdk/src/cli/commander/misc.ts b/KeeperSdk/src/cli/commander/misc.ts new file mode 100644 index 00000000..5e855aa5 --- /dev/null +++ b/KeeperSdk/src/cli/commander/misc.ts @@ -0,0 +1,187 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' +import { getRecordTitle } from '../../records/RecordUtils' +import { renderRecordsListTable } from '../../records/listRecordsTable' +import { recordUid } from '../utils' +import { formatSharedFoldersTable, renderSharedFoldersAsciiTable } from '../../sharedFolders/listSharedFolders' + +async function runList(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + await v.sync() + const records = v.getRecords() + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(records, null, 2) + '\n', err: '' } + } + if (records.length === 0) { + return { code: 0, out: '(no records)\n', err: '' } + } + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const out = renderRecordsListTable(records, { verbose }) + '\n' + return { code: 0, out, err: '' } +} + +async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { + const pattern = parsed.positional.join(' ') || getOpt(parsed.opts, 'pattern') + if (!pattern?.trim()) { + return { code: 1, out: '', err: 'search: missing search terms. Usage: search \n' } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'findRecords', 'search') + if (cap) return cap + await v.sync() + const matches = v.findRecords!(pattern) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(matches, null, 2) + '\n', err: '' } + } + if (matches.length === 0) { + return { code: 0, out: `(no records matched "${pattern}")\n`, err: '' } + } + const rows = matches.map((rec) => [recordUid(rec), getRecordTitle(rec)]) + return { code: 0, out: formatTable(['record_uid', 'title'], rows), err: '' } +} + +async function runListSf(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') + if (cap) return cap + await v.sync() + const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const rows = v.listSharedFolders!({ pattern, verbose, includeDetails: verbose }) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } + } + if (rows.length === 0) { + return { + code: 0, + out: pattern ? `(no shared folders matched "${pattern}")\n` : '(no shared folders)\n', + err: '', + } + } + const table = formatSharedFoldersTable(rows, { verbose }) + return { code: 0, out: renderSharedFoldersAsciiTable(table) + '\n', err: '' } +} + +async function runWhoami(host: KeeperCliHost): Promise { + const v = host.getVault() + if (!v.isLoggedIn) { + return { code: 1, out: '', err: 'whoami: not logged in\n' } + } + const username = + (await host.getAccountUsername?.()) ?? host.envString('KEEPER_USER') ?? host.envString('KEEPER_USERNAME') + const summary = v.getSummary?.() + const lines = [`username: ${username ?? '(unknown)'}`] + if (summary) { + lines.push( + `records: ${summary.recordCount}`, + `folders: ${summary.folderCount}`, + `shared_folders: ${summary.sharedFolderCount}` + ) + } + return { code: 0, out: lines.join('\n') + '\n', err: '' } +} + +export const listCommand: CliCommandDefinition = { + name: 'list', + order: 14, + aliases: ['l'], + description: 'List all vault records (Commander table).', + usage: 'list [--verbose|-v] [--json]', + flagOptions: ['--json', '--verbose', '-v'], + help: { + title: 'list — all records (Keeper Commander)', + synopsis: 'usage: list [--verbose]', + description: + ' Syncs and prints every record in a Commander-style table: uid, type, title, description, shared, and record category.', + options: ' --verbose, -v Do not truncate long columns (default max width 40).', + seeAlso: ' get, search, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listCommand), err: '' } + try { + return await runList(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list', e) } + } + }, +} + +export const searchCommand: CliCommandDefinition = { + name: 'search', + order: 15, + aliases: ['s'], + description: 'Search vault records by text.', + usage: 'search [--json]', + flagOptions: ['--json', '--pattern'], + help: { + title: 'search — find records (Keeper Commander)', + synopsis: 'usage: search ', + description: + ' Space-separated terms; all terms must match somewhere in the record (title, fields, or UID).\n' + + ' For exact lookup by UID, use get instead.', + examples: ' search amazon\n search bank account\n get zhJdqy7lb_zIEeCJT7GLlQ', + seeAlso: ' get, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(searchCommand), err: '' } + try { + return await runSearch(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('search', e) } + } + }, +} + +export const listSfCommand: CliCommandDefinition = { + name: 'list-sf', + order: 16, + aliases: ['lsf'], + description: 'List shared folders.', + usage: 'list-sf [pattern] [--verbose] [--json]', + flagOptions: ['--verbose', '-v', '--json', '--pattern'], + help: { + title: 'list-sf — shared folders (Keeper Commander)', + synopsis: 'usage: list-sf [pattern]', + seeAlso: ' ls, get', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listSfCommand), err: '' } + try { + return await runListSf(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list-sf', e) } + } + }, +} + +export const whoamiCommand: CliCommandDefinition = { + name: 'whoami', + order: 18, + description: 'Display current user and vault counts.', + usage: 'whoami', + help: { + title: 'whoami — current user (Keeper Commander)', + synopsis: 'usage: whoami', + seeAlso: ' login, sync-down', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(whoamiCommand), err: '' } + if (parsed.opts.size > 0 || parsed.positional.length > 0) { + return { code: 1, out: '', err: 'whoami: unexpected arguments\n' } + } + try { + return await runWhoami(host) + } catch (e) { + return { code: 1, out: '', err: host.formatError('whoami', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commander/nav.ts b/KeeperSdk/src/cli/commander/nav.ts new file mode 100644 index 00000000..0ff0e268 --- /dev/null +++ b/KeeperSdk/src/cli/commander/nav.ts @@ -0,0 +1,234 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' + +function lsPath(parsed: ParsedCli): string | undefined { + return parsed.positional[0] +} + +function formatLs( + result: { + detail: boolean + folders: Array<{ uid: string; name: string }> + records: Array<{ uid: string; name: string; type?: string }> + }, + detail: boolean +): string { + if (result.folders.length + result.records.length === 0) return '(empty)\n' + + const headers = detail ? ['flags', 'uid', 'name', 'type'] : ['kind', 'uid', 'name'] + const rows: string[][] = [] + for (const f of result.folders) { + const flags = ((f as { flags?: string }).flags ?? '').trim() + rows.push(detail ? [flags || 'f---', f.uid, f.name, ''] : ['dir', f.uid, f.name]) + } + for (const r of result.records) { + const flags = ((r as { flags?: string }).flags ?? '').trim() + const type = r.type ?? '' + rows.push(detail ? [flags || 'r---', r.uid, r.name, type] : ['rec', r.uid, r.name]) + } + return formatTable(headers, rows) +} + +async function runLs(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listFolder', cmd) + if (cap) return cap + await v.sync() + + const detail = hasOpt(parsed.opts, 'detail') || hasOpt(parsed.opts, 'list') || hasOpt(parsed.opts, 'l') + const foldersOnly = hasOpt(parsed.opts, 'folders') || hasOpt(parsed.opts, 'f') + const recordsOnly = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') + const target = lsPath(parsed) + + const listOpts = { + detail, + showFolders: recordsOnly ? false : true, + showRecords: foldersOnly ? false : true, + } + + if (!target) { + const result = await v.listFolder!({ ...listOpts }) + return { code: 0, out: formatLs(result, detail), err: '' } + } + + if (!v.changeDirectory || !v.getCurrentFolderUid) { + return { code: 1, out: '', err: `${cmd}: host lacks navigation capabilities.\n` } + } + + const originalUid = v.getCurrentFolderUid() + let resolvedUid: string | null + try { + const cd = await v.changeDirectory(target) + resolvedUid = cd.folderUid + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } + } + try { + const result = await v.listFolder!({ folderUid: resolvedUid ?? null, ...listOpts }) + return { code: 0, out: formatLs(result, detail), err: '' } + } finally { + if (resolvedUid !== originalUid) { + try { + await v.changeDirectory(originalUid ?? '/') + } catch { + /* best-effort */ + } + } + } +} + +async function runCd(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const target = parsed.positional[0] + if (!target) return { code: 1, out: '', err: `${cmd}: missing folder path. Usage: ${cmd} \n` } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'changeDirectory', cmd) + if (cap) return cap + try { + const res = await v.changeDirectory!(target) + return { code: 0, out: `${res.name}\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } + } +} + +async function runTree(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'tree', cmd) + if (cap) return cap + await v.sync() + const folderPath = parsed.positional[0] + const out = await v.tree!(folderPath ? { folderPath, showRecords: true } : { showRecords: true }) + return { code: 0, out: out.endsWith('\n') ? out : out + '\n', err: '' } +} + +async function runMkdir(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const target = parsed.positional[0] + if (!target) { + return { code: 1, out: '', err: `${cmd}: missing path. Usage: ${cmd} [-sf]\n` } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'mkdir', cmd) + if (cap) return cap + const cwd = v.getWorkingFolderDisplayName?.() ?? 'My Vault' + const shared = + hasOpt(parsed.opts, 'shared-folder') || + hasOpt(parsed.opts, 'sf') || + hasOpt(parsed.opts, 'shared') + try { + const res = await v.mkdir!(target, { sharedFolder: shared }) + if (!res.success) { + return { code: 1, out: '', err: `${cmd} [in ${cwd}]: ${res.message ?? 'failed'}\n` } + } + return { code: 0, out: `${res.folderUid}\t${target} (in ${cwd})\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target} [in ${cwd}]`, e) } + } +} + +const lsHelp: CliCommandDefinition['help'] = { + title: 'ls — list folder contents (Keeper Commander)', + synopsis: 'usage: ls [-l] [-f] [-r] [pattern]', + description: ' Lists records and subfolders in the current folder, or in PATH if given.', + options: ` -l, --list Detailed list (flags, types). + -f, --folders Folders only. + -r, --records Records only. + --help, -h Show this help.`, + examples: ' ls\n ls "Marketing"\n ls -l', + seeAlso: ' cd, tree, get', +} + +export const lsCommand: CliCommandDefinition = { + name: 'ls', + order: 11, + description: 'List folder contents (current folder or PATH).', + usage: 'ls [PATH] [-l|--list] [-f|--folders] [-r|--records]', + flagOptions: ['-l', '--list', '-f', '--folders', '-r', '--records', '--detail'], + help: lsHelp, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(lsCommand), err: '' } + try { + return await runLs(host, parsed, 'ls') + } catch (e) { + return { code: 1, out: '', err: host.formatError('ls', e) } + } + }, +} + +export const cdCommand: CliCommandDefinition = { + name: 'cd', + order: 12, + description: 'Change current folder.', + usage: 'cd ', + help: { + title: 'cd — change current folder (Keeper Commander)', + synopsis: 'usage: cd ', + description: ' PATH is a slash-separated folder name/UID sequence, or `/` for vault root.', + examples: ' cd Marketing\n cd ..\n cd /', + seeAlso: ' ls, tree', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(cdCommand), err: '' } + try { + return await runCd(host, parsed, 'cd') + } catch (e) { + return { code: 1, out: '', err: host.formatError('cd', e) } + } + }, +} + +export const treeCommand: CliCommandDefinition = { + name: 'tree', + order: 13, + description: 'Display the folder structure.', + usage: 'tree [PATH]', + help: { + title: 'tree — folder structure (Keeper Commander)', + synopsis: 'usage: tree [folder]', + description: + ' Renders an ASCII tree from PATH or the vault root. Each node is tagged [folder], [shared folder], or [record].', + seeAlso: ' ls, cd', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(treeCommand), err: '' } + try { + return await runTree(host, parsed, 'tree') + } catch (e) { + return { code: 1, out: '', err: host.formatError('tree', e) } + } + }, +} + +export const mkdirCommand: CliCommandDefinition = { + name: 'mkdir', + order: 14, + description: 'Create a folder.', + usage: 'mkdir [-sf|--shared-folder]', + flagOptions: ['-sf', '--shared-folder', '--shared'], + help: { + title: 'mkdir — create folder (Keeper Commander)', + synopsis: 'usage: mkdir [-sf]', + description: ' Creates a user folder under the current folder. -sf creates a shared folder.', + options: ' -sf, --shared-folder Create a shared folder.', + examples: ' mkdir Drafts\n mkdir TeamShare -sf', + seeAlso: ' cd, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(mkdirCommand), err: '' } + try { + return await runMkdir(host, parsed, 'mkdir') + } catch (e) { + return { code: 1, out: '', err: host.formatError('mkdir', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/help.ts b/KeeperSdk/src/cli/commands/help.ts new file mode 100644 index 00000000..17bae201 --- /dev/null +++ b/KeeperSdk/src/cli/commands/help.ts @@ -0,0 +1,76 @@ +import type { CliCommandDefinition, KeeperCliHost } from '../types' +import { wantsCliHelp } from '../parse' +import { + formatAllCommandsSummary, + formatDetailedHelpForCommand, + formatShortCommandSummary, + getDetailedHelpPageForRegistry, +} from '../help' +import { isAuthCliCommand, listCliCommandsForLoginState } from '../access' +import { getCliCommand } from '../registry' + +export const helpCommand: CliCommandDefinition = { + name: 'help', + order: 0, + description: 'Show all commands, or full docs for one command (same as COMMAND --help).', + usage: 'help [command] (see also: help --help)', + help: { + title: 'help — show commands or short syntax for one command', + synopsis: ' help [COMMAND]', + description: ` Without arguments, lists commands for the current session. + When not logged in, only sign-in commands are listed (login, restore-session, …). + After login, lists vault commands as well. + + With COMMAND, prints usage for that command (sign-in commands only when logged out).`, + options: ' None. This command does not take GNU-style flags.', + seeAlso: ' Each command’s --help output.', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(helpCommand), err: '' } + } + if (parsed.opts.size > 0) { + return { code: 1, out: '', err: 'help: unknown option (try `help --help`)\n' } + } + const loggedIn = host.getVault().isLoggedIn + const visible = listCliCommandsForLoginState(loggedIn) + const args = parsed.positional + if (args.length === 0) { + if (loggedIn) { + return { code: 0, out: formatAllCommandsSummary(visible), err: '' } + } + return { + code: 0, + out: formatAllCommandsSummary(visible, { + header: 'Not logged in — sign-in commands:\n\n', + footer: + '\nRun `login` or `restore-session` to open the vault.\n' + + 'After login, run `help` again for vault commands (get, ls, cd, …).\n', + }), + err: '', + } + } + if (args.length > 1) { + return { code: 1, out: '', err: 'Usage: help [command]\n' } + } + const name = args[0] + if (!loggedIn && !isAuthCliCommand(name)) { + return { + code: 1, + out: '', + err: + `help: "${name}" requires a logged-in session. ` + + 'Run `help` for sign-in commands (login, restore-session).\n', + } + } + const long = getDetailedHelpPageForRegistry(visible, name) + if (long) { + return { code: 0, out: long, err: '' } + } + const def = getCliCommand(name) + if (!def) { + return { code: 1, out: '', err: `help: unknown command: ${name}\n` } + } + return { code: 0, out: formatShortCommandSummary(def), err: '' } + }, +} diff --git a/KeeperSdk/src/cli/commands/login.ts b/KeeperSdk/src/cli/commands/login.ts new file mode 100644 index 00000000..b4e2a710 --- /dev/null +++ b/KeeperSdk/src/cli/commands/login.ts @@ -0,0 +1,178 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { utf8ToBase64Url } from '../utils' + +const LOGIN_ALLOWED = new Set([ + 'username', + 'user', + 'session-token', + 'token', + 'st', + 'session-token-plain', +]) + +export async function runLoginCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { + const opts = parsed?.opts ?? new Map() + if (parsed && wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(loginCommand), err: '' } + } + if (parsed) { + for (const secretFlag of ['password', 'pass', 'pwd'] as const) { + if (opts.has(secretFlag)) { + return { + code: 1, + out: '', + err: + 'login: do not pass --password on the command line (it is logged and visible). ' + + 'Use KEEPER_PASSWORD for automation, or run `login --username …` in the shell and enter the password when prompted (masked).\n', + } + } + } + const bad = rejectUnknownOptions(parsed, LOGIN_ALLOWED, 'login') + if (bad) return bad + } + + const username = getOpt(opts, 'username', 'user') ?? host.envString('KEEPER_USERNAME') + const passwordEnv = host.envString('KEEPER_PASSWORD') + const sessionRaw = getOpt(opts, 'session-token', 'token', 'st') ?? host.envString('KEEPER_SESSION_TOKEN') + const sessionPlain = parsed && hasOpt(opts, 'session-token-plain') + + if (parsed) { + const stPlainVal = opts.get('session-token-plain') + if (stPlainVal !== undefined && stPlainVal !== true) { + return { + code: 1, + out: '', + err: 'login: --session-token-plain is a boolean flag (no value)\n', + } + } + } + + if (!username) { + return { + code: 1, + out: '', + err: 'login: provide --username or KEEPER_USERNAME.\n', + } + } + + const sessionTrimmed = typeof sessionRaw === 'string' ? sessionRaw.trim() : '' + if (sessionTrimmed.length > 0) { + return loginWithSessionToken(host, username, sessionTrimmed, { plainToken: !!sessionPlain }) + } + + if (!passwordEnv) { + return { + code: 1, + needPassword: true, + loginUsername: username, + out: '', + err: '', + } + } + + return loginWithCredentials(host, username, passwordEnv) +} + +export async function loginWithCredentials( + host: KeeperCliHost, + username: string, + password: string +): Promise { + try { + const v = host.getVault() + if (v.isLoggedIn) { + await v.logout() + } + await v.login(username, password) + await v.sync() + return { code: 0, out: `keeper: logged in as ${username}.\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +export async function loginWithSessionToken( + host: KeeperCliHost, + username: string, + sessionToken: string, + options?: { plainToken?: boolean } +): Promise { + let token = sessionToken.trim() + if (options?.plainToken && token.length > 0) { + token = utf8ToBase64Url(token) + } + try { + const v = host.getVault() + if (v.isLoggedIn) { + await v.logout() + } + await v.loginWithSessionToken(username, token) + await v.sync() + return { code: 0, out: `keeper: logged in as ${username} (session token).\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +/** Pass-through if logged in; auto-login when `KEEPER_USERNAME` is set; otherwise "not logged in". */ +export async function ensureLoggedIn(host: KeeperCliHost): Promise { + if (host.getVault().isLoggedIn) { + return { code: 0, out: '', err: '' } + } + if (host.envString('KEEPER_USERNAME')) { + return runLoginCommand(host, { positional: [], opts: new Map() }) + } + return { code: 1, out: '', err: 'not logged in\n' } +} + +export const loginCommand: CliCommandDefinition = { + name: 'login', + order: 10, + description: + 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN). Password never on CLI line.', + usage: + 'login [--username|--user ] [--session-token|--token|--st ] [--session-token-plain] [--help|-h]', + flagOptions: [ + '--user', + '--username', + '--session-token', + '--token', + '--st', + '--session-token-plain', + ], + allowedOptions: LOGIN_ALLOWED, + help: { + title: 'login — authenticate to Keeper (vault session)', + synopsis: ` login [--username|--user EMAIL_OR_NAME] + login [--username|--user U] [--session-token|--token|--st TOKEN] + login [--username|--user U] [--session-token TOKEN] [--session-token-plain]`, + description: ` Establishes a Keeper session. + + Username comes from --username / --user or KEEPER_USERNAME. + + Password MUST NOT appear on the CLI line (logging, proxies, browser history). + Automation: set KEEPER_PASSWORD in the environment when embedding in Node. + Web shell: run login with only a username; the UI prompts for a masked password + and sends it through the login transport, not in "line". + + Session token login: pass the token on the command line or via + KEEPER_SESSION_TOKEN (sensitive — same caveats as any secret on argv). + + --session-token-plain treats the value as plain UTF-8 and encodes base64url + before login (same idea as the session_token_login example). + + Device registration: session token login requires deviceToken + privateKey for + this host in session storage (e.g. ~/.keeper/config.json) or a prior password + login in this shell.`, + options: ` --username, --user Account identifier (often email). + --session-token, --token, --st Session token string (or use KEEPER_SESSION_TOKEN). + --session-token-plain Treat --session-token value as plain UTF-8 and encode base64url.`, + environment: ` KEEPER_USERNAME Default username if not passed on the command line. + KEEPER_PASSWORD Password for non-interactive login (no session token). + KEEPER_SESSION_TOKEN Session token when not passed as a flag. + KEEPER_HOST Optional vault host / region (also: keeper-host attribute).`, + }, + run: (host, parsed) => runLoginCommand(host, parsed), +} diff --git a/KeeperSdk/src/cli/commands/logout.ts b/KeeperSdk/src/cli/commands/logout.ts new file mode 100644 index 00000000..aa70924a --- /dev/null +++ b/KeeperSdk/src/cli/commands/logout.ts @@ -0,0 +1,39 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' + +export async function runLogoutCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { + if (parsed && wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(logoutCommand), err: '' } + } + if (parsed && parsed.opts.size > 0) { + return { code: 1, out: '', err: 'logout: no options (try: logout --help)\n' } + } + if (parsed && parsed.positional.length > 0) { + return { code: 1, out: '', err: 'Usage: logout\n' } + } + try { + const v = host.getVault() + if (!v.isLoggedIn) { + return { code: 0, out: 'keeper: already logged out.\n', err: '' } + } + await v.logout() + return { code: 0, out: 'keeper: logged out.\n', err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +export const logoutCommand: CliCommandDefinition = { + name: 'logout', + order: 200, + description: 'Log out of the current Keeper session.', + usage: 'logout [--help|-h]', + help: { + title: 'logout — end the current Keeper session', + synopsis: ' logout', + description: ' Ends the current session if one exists.', + options: ' None.', + }, + run: (host, parsed) => runLogoutCommand(host, parsed), +} diff --git a/KeeperSdk/src/cli/commands/restoreSession.ts b/KeeperSdk/src/cli/commands/restoreSession.ts new file mode 100644 index 00000000..7e416d59 --- /dev/null +++ b/KeeperSdk/src/cli/commands/restoreSession.ts @@ -0,0 +1,211 @@ +import type { CliCommandDefinition, KeeperCliHost, ParsedCli } from '../types' +import { + resolveSessionRestorePayload, + validateSessionRestoreInput, + type SessionRestoreInput, +} from '../../auth/sessionRestore' +import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { runVaultSync } from './sync' + +/** Flags allowed to follow `--from-json ` on the same line. */ +export const RESTORE_SESSION_TRAILING_OPTS = [ + 'sync', + 'account-uid', + 'client-key', + 'data-key', + 'ecc-private-key', + 'ecc-public-key', + 'message-session-uid', + 'private-key', + 'session-token', + 'st', + 'session-token-type', + 'username', + 'user', + 'user-type', + 'sso-logout-url', + 'sso-session-id', + 'enterprise-public-key', + 'enterprise-ecc-public-key', +] as const + +const RESTORE_ALLOWED = new Set([ + 'sync', + 'from-json', + 'account-uid', + 'client-key', + 'data-key', + 'ecc-private-key', + 'ecc-public-key', + 'message-session-uid', + 'private-key', + 'session-token', + 'st', + 'session-token-type', + 'username', + 'user', + 'user-type', + 'sso-logout-url', + 'sso-session-id', + 'enterprise-public-key', + 'enterprise-ecc-public-key', +]) + +const ENV_PREFIX = 'RESTORE_SESSION_' + +const FIELD_ENV: Record = { + ACCOUNT_UID: 'accountUid', + CLIENT_KEY: 'clientKey', + DATA_KEY: 'dataKey', + ECC_PRIVATE_KEY: 'eccPrivateKey', + ECC_PUBLIC_KEY: 'eccPublicKey', + MESSAGE_SESSION_UID: 'messageSessionUid', + PRIVATE_KEY: 'privateKey', + SESSION_TOKEN: 'sessionToken', + SESSION_TOKEN_TYPE: 'sessionTokenType', + USERNAME: 'username', + USER_TYPE: 'userType', + SSO_LOGOUT_URL: 'ssoLogoutUrl', + SSO_SESSION_ID: 'ssoSessionId', + ENTERPRISE_PUBLIC_KEY: 'enterprisePublicKey', + ENTERPRISE_ECC_PUBLIC_KEY: 'enterpriseEccPublicKey', +} + +const OPT_TO_FIELD: Record = { + 'account-uid': 'accountUid', + 'client-key': 'clientKey', + 'data-key': 'dataKey', + 'ecc-private-key': 'eccPrivateKey', + 'ecc-public-key': 'eccPublicKey', + 'message-session-uid': 'messageSessionUid', + 'private-key': 'privateKey', + 'session-token': 'sessionToken', + st: 'sessionToken', + 'session-token-type': 'sessionTokenType', + username: 'username', + user: 'username', + 'user-type': 'userType', + 'sso-logout-url': 'ssoLogoutUrl', + 'sso-session-id': 'ssoSessionId', + 'enterprise-public-key': 'enterprisePublicKey', + 'enterprise-ecc-public-key': 'enterpriseEccPublicKey', +} + +function envField(host: KeeperCliHost, key: keyof SessionRestoreInput): string | undefined { + const envKey = Object.entries(FIELD_ENV).find(([, v]) => v === key)?.[0] + return envKey ? host.envString(`${ENV_PREFIX}${envKey}`) : undefined +} + +function buildInputFromFlags(host: KeeperCliHost, parsed: ParsedCli): SessionRestoreInput { + const partial: Partial = {} + + for (const [opt, field] of Object.entries(OPT_TO_FIELD)) { + const fromFlag = getOpt(parsed.opts, opt) + if (fromFlag !== undefined) { + ;(partial as Record)[field] = fromFlag + continue + } + const fromEnv = envField(host, field) + if (fromEnv !== undefined) { + ;(partial as Record)[field] = fromEnv + } + } + + return validateSessionRestoreInput(partial) +} + +export const restoreSessionCommand: CliCommandDefinition = { + name: 'restore-session', + order: 12, + description: + 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', + usage: + 'restore-session --from-json FILE|JSON [--sync] OR restore-session --session-token … (see --help)', + flagOptions: [ + '--sync', + '--from-json', + '--account-uid', + '--client-key', + '--data-key', + '--ecc-private-key', + '--ecc-public-key', + '--message-session-uid', + '--private-key', + '--session-token', + '--session-token-type', + '--username', + '--user-type', + '--sso-logout-url', + '--sso-session-id', + '--enterprise-public-key', + '--enterprise-ecc-public-key', + ], + allowedOptions: RESTORE_ALLOWED, + help: { + title: 'restore-session — restore SessionParams from extension / vault export', + synopsis: ` restore-session --from-json session.json + restore-session --session-token TOKEN --username U --account-uid B64 …`, + description: ` Loads a full SessionParams snapshot and resumes the session (same path as + the browser extension after login). Use this when you have accountUid, + clientKey, dataKey, keys, sessionToken, username, etc. from extension storage + — deviceToken/device private key are not part of this payload. + + Provide parameters either as one JSON object (--from-json) or as flags / env. + Binary fields are base64 or base64url.`, + options: ` --from-json Inline JSON (object or JSON-stringified object), or a file path + The entire remainder of the command line is passed to JSON.parse (then file read if needed). + --account-uid, --client-key, --data-key, --ecc-private-key, --ecc-public-key + --message-session-uid, --private-key + --session-token, --st Session token string (as stored; often base64url) + --session-token-type Numeric SessionTokenType enum + --username, --user + --user-type 0=normal, 1=cloud_sso, 2=onsite_sso (or string names) + --sso-logout-url, --sso-session-id + --enterprise-public-key, --enterprise-ecc-public-key (optional) + --sync Run syncDown after restoring the session`, + environment: ` RESTORE_SESSION_JSON Same as --from-json + RESTORE_SESSION_ACCOUNT_UID Per-field overrides (see --help flags) + RESTORE_SESSION_SESSION_TOKEN + … (RESTORE_SESSION_ for each field above)`, + note: ' sessionToken expires; region must match keeper-host / KEEPER_HOST.', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(restoreSessionCommand), err: '' } + } + const bad = rejectUnknownOptions(parsed, RESTORE_ALLOWED, 'restore-session') + if (bad) return bad + if (parsed.positional.length > 0) { + return { code: 1, out: '', err: 'restore-session: unexpected positional arguments\n' } + } + + try { + let input: SessionRestoreInput + const jsonRaw = getOpt(parsed.opts, 'from-json') ?? host.envString('RESTORE_SESSION_JSON') + if (jsonRaw) { + const readFile = + host.readTextFile ?? + (typeof document === 'undefined' + ? async (path: string) => (await import('fs/promises')).readFile(path, 'utf8') + : undefined) + input = await resolveSessionRestorePayload(jsonRaw, readFile) + } else { + input = buildInputFromFlags(host, parsed) + } + + await host.getVault().restoreSession(input) + let out = `keeper: session restored for ${input.username}.\n` + if (hasOpt(parsed.opts, 'sync')) { + const syncResult = await runVaultSync(host) + if (syncResult.code !== 0) { + return syncResult + } + out += syncResult.out + } + return { code: 0, out, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('restore-session', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/sync.ts b/KeeperSdk/src/cli/commands/sync.ts new file mode 100644 index 00000000..72d71f67 --- /dev/null +++ b/KeeperSdk/src/cli/commands/sync.ts @@ -0,0 +1,61 @@ +import type { SyncResult } from '@keeper-security/keeperapi' +import type { CliCommandDefinition, CliResult, KeeperCliHost } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureLoggedIn } from './login' + +function formatSyncSummary(result: SyncResult): string { + const lines = [`keeper: sync complete for ${result.username}.`, ` pages: ${result.pageCount}`] + if (result.totalTime) lines.push(` total: ${result.totalTime}`) + if (result.networkTime) lines.push(` network: ${result.networkTime}`) + const counts = result.counts ?? {} + const parts = Object.entries(counts) + .filter(([, n]) => typeof n === 'number' && n > 0) + .map(([k, n]) => `${k}=${n}`) + if (parts.length) lines.push(` counts: ${parts.join(', ')}`) + if (result.error) lines.push(` warning: ${result.error}`) + return lines.join('\n') + '\n' +} + +/** Download vault data via keeperapi syncDown (KeeperVault.sync). */ +export async function runVaultSync(host: KeeperCliHost): Promise { + const v = host.getVault() + if (!v.isLoggedIn) { + const login = await ensureLoggedIn(host) + if (login.code !== 0) return login + } + const result = await v.sync() + return { code: 0, out: formatSyncSummary(result), err: '' } +} + +export const syncCommand: CliCommandDefinition = { + name: 'sync', + order: 20, + aliases: ['syncdown', 'sync-down', 'd'], + description: 'Download / refresh vault data from Keeper (syncDown).', + usage: 'sync [--help|-h]', + help: { + title: 'sync — download vault data (syncDown)', + synopsis: ' sync', + description: ` Pulls records, folders, and related vault data into local storage. + Requires an active session (login or restore-session).`, + options: ' --help, -h Show this help.', + seeAlso: ' restore-session --sync, list, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(syncCommand), err: '' } + } + if (parsed.opts.size > 0) { + return { code: 1, out: '', err: 'sync: unknown option (try: sync --help)\n' } + } + if (parsed.positional.length > 0) { + return { code: 1, out: '', err: 'sync: unexpected arguments (try: sync --help)\n' } + } + try { + return await runVaultSync(host) + } catch (e) { + return { code: 1, out: '', err: host.formatError('sync', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/vault.ts b/KeeperSdk/src/cli/commands/vault.ts new file mode 100644 index 00000000..4bdaf979 --- /dev/null +++ b/KeeperSdk/src/cli/commands/vault.ts @@ -0,0 +1,56 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' + +async function runSummary(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'getSummary', 'vault summary') + if (cap) return cap + await v.sync!() + const summary = v.getSummary!() + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(summary, null, 2) + '\n', err: '' } + } + const lines = [ + `records: ${summary.recordCount}`, + `shared_folders: ${summary.sharedFolderCount}`, + `teams: ${summary.teamCount}`, + `folders: ${summary.folderCount}`, + ] + return { code: 0, out: lines.join('\n') + '\n', err: '' } +} + +export const vaultCommand: CliCommandDefinition = { + name: 'vault', + order: 25, + description: 'Vault summary counts (records, folders, shared folders).', + usage: 'vault summary [--json] [--help|-h]', + subcommands: ['summary'], + flagOptions: ['--json'], + help: { + title: 'vault — vault-wide statistics', + synopsis: ' vault summary [--json]', + description: ' Runs sync, then prints counts from the local vault cache.', + arguments: ' summary Print record, shared folder, and user-folder counts.', + options: ' --json Emit JSON.\n --help, -h Show this help.', + examples: ' vault summary\n vault summary --json', + seeAlso: ' sync, list, tree, whoami', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(vaultCommand), err: '' } + } + const sub = parsed.positional[0]?.toLowerCase() ?? 'summary' + if (sub !== 'summary') { + return { code: 1, out: '', err: 'Usage: vault summary\n' } + } + try { + return await runSummary(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('vault summary', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/dispatch.ts b/KeeperSdk/src/cli/dispatch.ts new file mode 100644 index 00000000..ba49e697 --- /dev/null +++ b/KeeperSdk/src/cli/dispatch.ts @@ -0,0 +1,52 @@ +import type { CliResult, KeeperCliHost, ParsedCli } from './types' +import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' +import { extractFromJsonFlagValue } from './jsonArg' +import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' +import { formatDetailedHelpForCommand } from './help' +import { isAuthCliCommand } from './access' +import { getCliCommand } from './registry' + +const NOT_LOGGED_IN_ERR = + 'Not logged in. Run `login` or `restore-session` (see `help`).\n' + +export async function dispatchKeeperCli( + commandName: string, + args: string[], + host: KeeperCliHost, + preParsed?: ParsedCli +): Promise { + const def = getCliCommand(commandName) + if (!def) { + return { code: 1, out: '', err: `Unknown command: ${commandName}\n` } + } + if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { + return { code: 1, out: '', err: NOT_LOGGED_IN_ERR } + } + const parsed = preParsed ?? parseCliArgs(args) + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(def), err: '' } + } + return def.run(host, parsed) +} + +export async function dispatchCliLine(line: string, host: KeeperCliHost): Promise { + const trimmed = line.trim() + if (!trimmed) { + return { code: 0, out: '', err: '' } + } + const tokens = tokenizeArguments(trimmed) + const name = tokens[0]?.toLowerCase() + if (!name) { + return { code: 0, out: '', err: '' } + } + const args = tokens.slice(1) + let preParsed: ParsedCli | undefined + if (name === 'restore-session') { + const json = extractFromJsonFlagValue(trimmed, 'from-json', RESTORE_SESSION_TRAILING_OPTS) + if (json) { + preParsed = parseCliArgs(args) + preParsed.opts.set('from-json', json) + } + } + return dispatchKeeperCli(name, args, host, preParsed) +} diff --git a/KeeperSdk/src/cli/help.ts b/KeeperSdk/src/cli/help.ts new file mode 100644 index 00000000..40773a4f --- /dev/null +++ b/KeeperSdk/src/cli/help.ts @@ -0,0 +1,82 @@ +import type { CliCommandDefinition, CliHelpDoc } from './types' + +const SECTION_ORDER: (keyof CliHelpDoc)[] = [ + 'synopsis', + 'description', + 'arguments', + 'options', + 'environment', + 'examples', + 'seeAlso', + 'note', +] + +const SECTION_LABELS: Partial> = { + synopsis: 'SYNOPSIS', + description: 'DESCRIPTION', + arguments: 'ARGUMENTS', + options: 'OPTIONS', + environment: 'ENVIRONMENT', + examples: 'EXAMPLES', + seeAlso: 'SEE ALSO', + note: 'NOTE', +} + +export function formatDetailedHelp(doc: CliHelpDoc): string { + const parts: string[] = [doc.title.trim()] + for (const key of SECTION_ORDER) { + const body = doc[key] + if (typeof body !== 'string' || !body.trim()) continue + const label = SECTION_LABELS[key] + if (label) { + parts.push('') + parts.push(label) + } + parts.push(body.trim()) + } + return `${parts.join('\n')}\n` +} + +export function formatDetailedHelpForCommand(def: CliCommandDefinition): string { + return formatDetailedHelp(def.help) +} + +export function getDetailedHelpPageForRegistry( + commands: Iterable, + name: string +): string | null { + const key = name.toLowerCase() + for (const def of commands) { + if (def.name === key) return formatDetailedHelpForCommand(def) + if (def.aliases?.some((a) => a.toLowerCase() === key)) { + return formatDetailedHelpForCommand(def) + } + } + return null +} + +export type CommandsSummaryOptions = { + header?: string + footer?: string +} + +export function formatAllCommandsSummary( + commands: readonly CliCommandDefinition[], + options?: CommandsSummaryOptions +): string { + const sorted = [...commands].sort((a, b) => a.name.localeCompare(b.name)) + const w = Math.max(...sorted.map((c) => c.name.length), 8) + let out = options?.header ?? 'Supported commands:\n\n' + if (!out.endsWith('\n\n')) { + out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n` + } + for (const c of sorted) { + out += ` ${c.name.padEnd(w)} ${c.description}\n` + } + out += options?.footer ?? '\nRun ` --help` (or `-h`) for details on a specific command.\n' + return out +} + +export function formatShortCommandSummary(def: CliCommandDefinition): string { + return `${def.name} — ${def.description}\n Usage: ${def.usage}\n` +} diff --git a/KeeperSdk/src/cli/index.ts b/KeeperSdk/src/cli/index.ts new file mode 100644 index 00000000..a3c67c51 --- /dev/null +++ b/KeeperSdk/src/cli/index.ts @@ -0,0 +1,109 @@ +import { registerBuiltinCliCommands } from './builtinCommands' +import { registerCliAlias } from './registry' + +let registryInitialized = false + +/** Register built-in Keeper CLI commands (idempotent). */ +export function ensureKeeperCliRegistry(): void { + if (registryInitialized) return + registryInitialized = true + registerBuiltinCliCommands() + registerCliAlias('?', 'help') +} + +ensureKeeperCliRegistry() + +export type { + CliResult, + ParsedCli, + CliHelpDoc, + CliCommandDefinition, + KeeperCliHost, + KeeperCliVault, +} from './types' + +export { + tokenizeArguments, + parseCliArgs, + hasOpt, + getOpt, + wantsCliHelp, + rejectUnknownOptions, +} from './parse' + +export { + formatDetailedHelp, + formatDetailedHelpForCommand, + formatAllCommandsSummary, + formatShortCommandSummary, +} from './help' +import { getDetailedHelpPageForRegistry } from './help' +import { listCliCommands } from './registry' + +export function getDetailedHelpPage(name: string): string | null { + ensureKeeperCliRegistry() + return getDetailedHelpPageForRegistry(listCliCommands(), name) +} + +export { + registerCliCommand, + registerCliAlias, + resolveCliCommandName, + getCliCommand, + listCliCommands, + listCliCommandNames, + listDocumentedCommands, + clearCliRegistry, +} from './registry' + +export { + AUTH_CLI_COMMAND_NAMES, + isAuthCliCommand, + filterCliCommandsForLoginState, + listCliCommandsForLoginState, + listCliCommandNamesForLoginState, +} from './access' + +export { dispatchKeeperCli, dispatchCliLine } from './dispatch' + +export { KeeperCliParser, createKeeperCliParser } from './parser' +export type { KeeperCliParserOptions } from './parser' + +export { + runLoginCommand, + loginWithCredentials, + loginWithSessionToken, + ensureLoggedIn, + loginCommand, +} from './commands/login' + +export { runLogoutCommand, logoutCommand } from './commands/logout' +export { vaultCommand } from './commands/vault' +export { helpCommand } from './commands/help' +export { restoreSessionCommand } from './commands/restoreSession' +export { syncCommand, runVaultSync } from './commands/sync' + +export { getKeeperCliPromptPrefix } from './prompt' +export { BUILTIN_CLI_COMMANDS, registerBuiltinCliCommands } from './builtinCommands' +export { + getCommand, + executeGet, + listCommand, + searchCommand, + listSfCommand, + whoamiCommand, + lsCommand, + cdCommand, + treeCommand, + mkdirCommand, +} from './commander' + +export { utf8ToBase64Url, recordUid } from './utils' + +export type { SessionRestoreInput } from '../auth/sessionRestore' +export { + toSessionParams, + validateSessionRestoreInput, + sessionRestoreFromJson, + resolveSessionRestorePayload, +} from '../auth/sessionRestore' diff --git a/KeeperSdk/src/cli/jsonArg.ts b/KeeperSdk/src/cli/jsonArg.ts new file mode 100644 index 00000000..6a11c1bc --- /dev/null +++ b/KeeperSdk/src/cli/jsonArg.ts @@ -0,0 +1,30 @@ +/** + * Everything after `--from-json` on the command line (trimmed). No tokenization — callers use JSON.parse. + * Trailing flags such as `--sync` are stripped via {@link stripTrailingCliFlags}. + */ +export function extractFromJsonFlagValue( + line: string, + flag = 'from-json', + trailingFlags: readonly string[] = [] +): string | null { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const flagRe = new RegExp(`(?:^|\\s)--${escaped}(?:\\s*=\\s*|\\s+)`, 'i') + const m = line.match(flagRe) + if (!m || m.index === undefined) return null + const rest = line.slice(m.index + m[0].length).trim() + if (!rest) return null + return stripTrailingCliFlags(rest, trailingFlags) +} + +/** Remove trailing ` --flag` tokens (e.g. `--sync` after a file path or JSON blob). */ +export function stripTrailingCliFlags(value: string, flagNames: readonly string[]): string { + if (flagNames.length === 0) return value.trim() + let s = value.trim() + const parts = flagNames.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + const alt = parts.join('|') + const tailFlag = new RegExp(`\\s+--(?:${alt})(?:\\s*=\\s*(?:"[^"]*"|\\S+))?\\s*$`, 'i') + while (tailFlag.test(s)) { + s = s.replace(tailFlag, '').trim() + } + return s +} diff --git a/KeeperSdk/src/cli/parse.ts b/KeeperSdk/src/cli/parse.ts new file mode 100644 index 00000000..73f9ef15 --- /dev/null +++ b/KeeperSdk/src/cli/parse.ts @@ -0,0 +1,173 @@ +import type { CliResult, ParsedCli } from './types' + +const isWhitespace = (ch: string) => /\s/.test(ch) + +/** Split a command line into tokens; respects double quotes and `\\` escapes. */ +export function tokenizeArguments(args: string): string[] { + const out: string[] = [] + const sb: string[] = [] + let pos = 0 + let inQuote = false + let escape = false + + const flush = () => { + if (sb.length > 0) { + out.push(sb.join('')) + sb.length = 0 + } + } + + while (pos < args.length) { + const ch = args[pos] + if (escape) { + escape = false + sb.push(ch) + pos++ + continue + } + if (inQuote) { + if (ch === '\\') { + escape = true + pos++ + continue + } + if (ch === '"') { + inQuote = false + pos++ + continue + } + sb.push(ch) + pos++ + continue + } + switch (ch) { + case '\\': + escape = true + pos++ + break + case '"': + inQuote = true + pos++ + break + default: + if (isWhitespace(ch)) { + flush() + pos++ + } else { + sb.push(ch) + pos++ + } + } + } + flush() + return out +} + +function setBool(opts: Map, k: string): void { + opts.set(k.toLowerCase(), true) +} + +function setStr(opts: Map, k: string, v: string): void { + opts.set(k.toLowerCase(), v) +} + +/** Parse argv-style tokens after the command name. */ +export function parseCliArgs(tokens: string[]): ParsedCli { + const positional: string[] = [] + const opts = new Map() + + let i = 0 + while (i < tokens.length) { + const t = tokens[i] + if (t === '--') { + positional.push(...tokens.slice(i + 1)) + break + } + if (t === '-' || !t.startsWith('-')) { + positional.push(t) + i++ + continue + } + + if (t.startsWith('--')) { + const body = t.slice(2) + if (!body) { + positional.push(t) + i++ + continue + } + const eq = body.indexOf('=') + if (eq >= 0) { + setStr(opts, body.slice(0, eq), body.slice(eq + 1)) + i++ + continue + } + const name = body + const next = tokens[i + 1] + if (next && next !== '--' && !next.startsWith('-')) { + setStr(opts, name, next) + i += 2 + continue + } + setBool(opts, name) + i++ + continue + } + + const rest = t.slice(1) + if (!rest) { + positional.push(t) + i++ + continue + } + if (/^[A-Za-z]$/.test(rest)) { + setBool(opts, rest) + i++ + continue + } + if (/^[A-Za-z]+$/.test(rest)) { + for (const ch of rest) setBool(opts, ch) + i++ + continue + } + + positional.push(t) + i++ + } + + return { positional, opts } +} + +export function hasOpt(opts: Map, ...names: string[]): boolean { + for (const n of names) { + const v = opts.get(n.toLowerCase()) + if (v === true) return true + } + return false +} + +export function getOpt(opts: Map, ...names: string[]): string | undefined { + for (const n of names) { + const v = opts.get(n.toLowerCase()) + if (v !== undefined && v !== true) return v + } + return undefined +} + +export function wantsCliHelp(parsed: ParsedCli): boolean { + return hasOpt(parsed.opts, 'help', 'h') +} + +export function rejectUnknownOptions( + parsed: ParsedCli, + allowed: ReadonlySet, + commandName: string +): CliResult | null { + for (const k of parsed.opts.keys()) { + if (k === 'help' || k === 'h') continue + if (!allowed.has(k)) { + return { code: 1, out: '', err: `${commandName}: unknown option --${k}\n` } + } + } + return null +} diff --git a/KeeperSdk/src/cli/parser.ts b/KeeperSdk/src/cli/parser.ts new file mode 100644 index 00000000..2be6be47 --- /dev/null +++ b/KeeperSdk/src/cli/parser.ts @@ -0,0 +1,172 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from './types' +import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' +import { extractFromJsonFlagValue } from './jsonArg' +import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' +import { AUTH_CLI_COMMAND_NAMES, isAuthCliCommand } from './access' +import { BUILTIN_CLI_COMMANDS } from './builtinCommands' +import { formatAllCommandsSummary, formatDetailedHelpForCommand, formatShortCommandSummary } from './help' + +const NOT_LOGGED_IN_ERR = + 'Not logged in. Run `login` or `restore-session` (see `help`).\n' + +export type KeeperCliParserOptions = { + prog?: string + description?: string + epilog?: string +} + +/** Self-contained CLI parser. Register commands, then `parse()` dispatches a line. */ +export class KeeperCliParser { + private readonly prog: string + private readonly description: string + private readonly epilog?: string + private readonly commands = new Map() + private readonly aliases = new Map() + + constructor(options: KeeperCliParserOptions = {}) { + this.prog = options.prog ?? 'keeper' + this.description = options.description ?? '' + this.epilog = options.epilog + } + + addCommand(def: CliCommandDefinition): this { + const key = def.name.toLowerCase() + this.commands.set(key, def) + if (def.aliases) { + for (const alias of def.aliases) { + this.aliases.set(alias.toLowerCase(), key) + } + } + return this + } + + addCommands(defs: Iterable): this { + for (const def of defs) this.addCommand(def) + return this + } + + list(): CliCommandDefinition[] { + return [...this.commands.values()].sort((a, b) => { + const oa = a.order ?? 500 + const ob = b.order ?? 500 + if (oa !== ob) return oa - ob + return a.name.localeCompare(b.name) + }) + } + + listNames(): string[] { + return this.list().map((c) => c.name) + } + + resolve(name: string): CliCommandDefinition | undefined { + const key = name.toLowerCase() + if (this.commands.has(key)) return this.commands.get(key) + const target = this.aliases.get(key) + return target ? this.commands.get(target) : undefined + } + + formatHelp(host?: KeeperCliHost): string { + const loggedIn = host?.getVault().isLoggedIn ?? true + const commands = loggedIn + ? this.list() + : this.list().filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) + const header = this.description ? `${this.prog} — ${this.description}\n\n` : '' + const body = loggedIn + ? formatAllCommandsSummary(commands) + : formatAllCommandsSummary(commands, { + header: 'Not logged in — sign-in commands:\n\n', + footer: + '\nRun `login` or `restore-session` to open the vault.\n' + + 'After login, run `help` again for vault commands (get, ls, cd, …).\n', + }) + const footer = this.epilog ? `\n${this.epilog}\n` : '' + return header + body + footer + } + + formatCommandHelp(name: string): string | null { + const def = this.resolve(name) + return def ? formatDetailedHelpForCommand(def) : null + } + + formatCommandSummary(name: string): string | null { + const def = this.resolve(name) + return def ? formatShortCommandSummary(def) : null + } + + async parse(line: string | readonly string[], host: KeeperCliHost): Promise { + const { tokens, raw } = normalizeInput(line) + if (tokens.length === 0) { + return ok(this.formatHelp(host)) + } + + const first = tokens[0] + const rest = tokens.slice(1) + + if (isHelpToken(first)) { + const sub = rest[0] + if (!sub) return ok(this.formatHelp(host)) + if (!host.getVault().isLoggedIn && !isAuthCliCommand(sub)) { + return err(NOT_LOGGED_IN_ERR) + } + const page = this.formatCommandHelp(sub) + if (page) return ok(page) + return err(`${this.prog}: unknown command: ${sub}\nTry: ${this.prog} --help\n`) + } + + const def = this.resolve(first) + if (!def) { + return err(`${this.prog}: unknown command: ${first}\nTry: ${this.prog} --help\n`) + } + + if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { + return err(NOT_LOGGED_IN_ERR) + } + + let parsed: ParsedCli + if (def.name === 'restore-session') { + const json = extractFromJsonFlagValue(raw, 'from-json', RESTORE_SESSION_TRAILING_OPTS) + parsed = parseCliArgs(rest) + if (json) parsed.opts.set('from-json', json) + } else { + parsed = parseCliArgs(rest) + } + + if (wantsCliHelp(parsed)) { + return ok(formatDetailedHelpForCommand(def)) + } + return def.run(host, parsed) + } +} + +/** Parser pre-loaded with the SDK's built-in commands. */ +export function createKeeperCliParser(options: KeeperCliParserOptions = {}): KeeperCliParser { + const parser = new KeeperCliParser(options) + void loadBuiltinsInto(parser) + return parser +} + +function loadBuiltinsInto(parser: KeeperCliParser): void { + parser.addCommands(BUILTIN_CLI_COMMANDS) +} + +function normalizeInput(line: string | readonly string[]): { tokens: string[]; raw: string } { + if (typeof line === 'string') { + const trimmed = line.trim() + return { tokens: trimmed ? tokenizeArguments(trimmed) : [], raw: trimmed } + } + const tokens = [...line].filter((t) => t.length > 0) + return { tokens, raw: tokens.join(' ') } +} + +function isHelpToken(token: string): boolean { + const t = token.toLowerCase() + return t === '--help' || t === '-h' || t === 'help' +} + +function ok(out: string): CliResult { + return { code: 0, out, err: '' } +} + +function err(message: string): CliResult { + return { code: 1, out: '', err: message } +} diff --git a/KeeperSdk/src/cli/prompt.ts b/KeeperSdk/src/cli/prompt.ts new file mode 100644 index 00000000..0ac8ea99 --- /dev/null +++ b/KeeperSdk/src/cli/prompt.ts @@ -0,0 +1,12 @@ +import type { KeeperCliHost } from './types' + +const NOT_LOGGED_IN_PROMPT = 'Not logged in> ' +const PROMPT_MAX_LEN = 40 + +export function getKeeperCliPromptPrefix(host: KeeperCliHost): string { + const v = host.getVault() + if (!v.isLoggedIn) return NOT_LOGGED_IN_PROMPT + const name = v.getWorkingFolderDisplayName?.() ?? 'My Vault' + const label = name.length > PROMPT_MAX_LEN ? `...${name.slice(-37)}` : name + return `${label}> ` +} diff --git a/KeeperSdk/src/cli/registry.ts b/KeeperSdk/src/cli/registry.ts new file mode 100644 index 00000000..4e541bac --- /dev/null +++ b/KeeperSdk/src/cli/registry.ts @@ -0,0 +1,55 @@ +import type { CliCommandDefinition } from './types' + +const commands = new Map() +const aliases = new Map() + +function normalizeName(name: string): string { + return name.toLowerCase() +} + +export function registerCliCommand(def: CliCommandDefinition): void { + const key = normalizeName(def.name) + commands.set(key, def) + if (def.aliases) { + for (const alias of def.aliases) { + aliases.set(normalizeName(alias), key) + } + } +} + +export function registerCliAlias(alias: string, commandName: string): void { + aliases.set(normalizeName(alias), normalizeName(commandName)) +} + +export function resolveCliCommandName(name: string): string | undefined { + const key = normalizeName(name) + if (commands.has(key)) return key + return aliases.get(key) +} + +export function getCliCommand(name: string): CliCommandDefinition | undefined { + const key = resolveCliCommandName(name) + return key ? commands.get(key) : undefined +} + +export function listCliCommands(): CliCommandDefinition[] { + return [...commands.values()].sort((a, b) => { + const oa = a.order ?? 500 + const ob = b.order ?? 500 + if (oa !== ob) return oa - ob + return a.name.localeCompare(b.name) + }) +} + +export function listCliCommandNames(): readonly string[] { + return listCliCommands().map((c) => c.name) +} + +export function listDocumentedCommands(): readonly string[] { + return listCliCommandNames() +} + +export function clearCliRegistry(): void { + commands.clear() + aliases.clear() +} diff --git a/KeeperSdk/src/cli/table.ts b/KeeperSdk/src/cli/table.ts new file mode 100644 index 00000000..499f206c --- /dev/null +++ b/KeeperSdk/src/cli/table.ts @@ -0,0 +1,8 @@ +/** Fixed-width column formatter. Last column is left unpadded. */ +export function formatTable(headers: string[], rows: string[][]): string { + if (rows.length === 0) return '' + const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) + const fmt = (cells: string[]): string => + cells.map((s, i) => (i === cells.length - 1 ? s : (s ?? '').padEnd(widths[i]))).join(' ') + return [fmt(headers), ...rows.map(fmt)].join('\n') + '\n' +} diff --git a/KeeperSdk/src/cli/types.ts b/KeeperSdk/src/cli/types.ts new file mode 100644 index 00000000..fb6cb6fc --- /dev/null +++ b/KeeperSdk/src/cli/types.ts @@ -0,0 +1,90 @@ +import type { DRecord, DSharedFolder, SyncResult } from '@keeper-security/keeperapi' +import type { SessionRestoreInput } from '../auth/sessionRestore' +import type { ChangeDirectoryResult } from '../folders/changeDirectory' +import type { FolderTreeBuildOptions } from '../folders/folderTree' +import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' +import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' +import type { MkdirOptions } from '../folders/addFolder' +import type { RenameFolderResult } from '../folders/updateFolder' +import type { DeleteFolderResult } from '../folders/deleteFolder' +import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' +import type { RecordShareInfo } from '../sharing/Sharing' +import type { VaultSummary } from '../vault/KeeperVault' + +export type CliResult = { + code: number + out: string + err: string + /** Set when the host UI must prompt for a masked password (never on the CLI line). */ + needPassword?: boolean + loginUsername?: string +} + +export type ParsedCli = { + positional: string[] + opts: Map +} + +/** + * Vault surface for CLI handlers. Methods beyond session/sync/records are optional; + * commands call `ensureCapability` so thin hosts fail with a clear message. + */ +export type KeeperCliVault = { + readonly isLoggedIn: boolean + login(username: string, password: string): Promise + loginWithSessionToken(username: string, sessionToken: string): Promise + logout(): Promise + sync(): Promise + getRecords(): DRecord[] + getSharedFolders(): DSharedFolder[] + restoreSession(input: SessionRestoreInput): Promise + getSummary?: () => VaultSummary + findRecord?: (uidOrTitle: string) => DRecord | undefined + findRecords?: (criteria: string) => DRecord[] + getRecordShareInfo?: (recordUid: string) => Promise + listSharedFolders?: (options?: ListSharedFoldersOptions) => ListSharedFolderRow[] + listFolder?: (options?: ListFolderOptions) => Promise + tree?: (options?: FolderTreeBuildOptions) => Promise + changeDirectory?: (path: string) => Promise + getCurrentFolderUid?: () => string | null + getWorkingFolderDisplayName?: () => string + getFolder?: (uidOrName: string, options?: GetFolderOptions) => Promise + mkdir?: (path: string, options?: MkdirOptions) => Promise<{ folderUid: string; success: boolean; message?: string }> + renameFolder?: (folderPath: string, newName: string) => Promise + rmdir?: (patterns: string[], options?: { force?: boolean }) => Promise +} + +/** Host adapter (browser shell, Node script, tests). `readTextFile` is optional. */ +export type KeeperCliHost = { + getVault(): KeeperCliVault + envString(name: string): string | undefined + formatError(context: string, err: unknown): string + readTextFile?: (path: string) => Promise + getAccountUsername?: () => Promise +} + +export type CliHelpDoc = { + title: string + synopsis?: string + description?: string + arguments?: string + options?: string + environment?: string + examples?: string + seeAlso?: string + note?: string +} + +export type CliCommandDefinition = { + name: string + order?: number + description: string + usage: string + aliases?: readonly string[] + subcommands?: readonly string[] + flagOptions?: readonly string[] + /** When set, options outside this set are rejected (`--help` / `-h` always allowed). */ + allowedOptions?: ReadonlySet + help: CliHelpDoc + run: (host: KeeperCliHost, parsed: ParsedCli) => Promise +} diff --git a/KeeperSdk/src/cli/utils.ts b/KeeperSdk/src/cli/utils.ts new file mode 100644 index 00000000..5db5dae4 --- /dev/null +++ b/KeeperSdk/src/cli/utils.ts @@ -0,0 +1,18 @@ +export function utf8ToBase64Url(s: string): string { + const bytes = new TextEncoder().encode(s) + let b64: string + if (typeof Buffer !== 'undefined') { + b64 = Buffer.from(bytes).toString('base64') + } else { + let bin = '' + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]!) + } + b64 = globalThis.btoa(bin) + } + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export function recordUid(rec: { uid?: string }): string { + return rec.uid || '(unknown uid)' +} diff --git a/KeeperSdk/src/cli/vaultSurface.ts b/KeeperSdk/src/cli/vaultSurface.ts new file mode 100644 index 00000000..cb3e43f8 --- /dev/null +++ b/KeeperSdk/src/cli/vaultSurface.ts @@ -0,0 +1,18 @@ +/** Shared footer for vault-related command help (SDK APIs not yet exposed as CLI). */ +export const KEEPER_VAULT_SURFACE = ` +KeeperVault (JavaScript SDK) — operations available in code (not all exposed as CLI yet): + + Session: login, loginWithSessionToken, logout, resumeSession, sync, disconnect + Records: getRecords, findRecord, findRecords, getRecordByUid, getRecordsByType, + addRecord, updateRecord, deleteRecord, moveRecord, getRecordHistory, + printRecords + Sharing: shareRecord, removeRecordShare, getRecordShareInfo + Folders: listFolder, changeDirectory, getFolder, mkdir, addFolder, updateFolder, + renameFolder, deleteFolder, rmdir, tree, getCurrentFolderUid + Shared folders: getSharedFolders, listSharedFolders, shareFolder, … + Metadata: getRecordMetadata, getSummary, … + +Utilities exported from @keeper-security/keeper-sdk-javascript include searchRecords, +formatRecord, getRecordTitle, getRecordPassword, getRecordLogin, shareRecord, … +See the SDK package for full APIs. +`.trim() From c65322e3802aff8fa88ff8f61f4730d263a697f7 Mon Sep 17 00:00:00 2001 From: Sergey Aldoukhov Date: Tue, 16 Jun 2026 14:06:30 -0700 Subject: [PATCH 34/48] Use logger in socket connection, add socket connection to the router. (#171) * use logger for KA socket * router socket * listener --- keeperapi/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeperapi/package.json b/keeperapi/package.json index 0d4dbf4a..f0d8bfa4 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -2,7 +2,7 @@ "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", "version": "18.0.4", - "browser": "dist/browser/index.js", + "browser": "dist/index.es.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", "sideEffects": [ From b0371b6fb6f43993f05836e2c0f175b642e4e841 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:21:21 -0700 Subject: [PATCH 35/48] Bump the npm_and_yarn group across 4 directories with 3 updates (#174) Bumps the npm_and_yarn group with 1 update in the /KeeperSdk directory: [form-data](https://github.com/form-data/form-data). Bumps the npm_and_yarn group with 1 update in the /examples/print-vault-node directory: [form-data](https://github.com/form-data/form-data). Bumps the npm_and_yarn group with 1 update in the /examples/sdk_example directory: [form-data](https://github.com/form-data/form-data). Bumps the npm_and_yarn group with 3 updates in the /keeperapi directory: [form-data](https://github.com/form-data/form-data), [protobufjs-cli](https://github.com/protobufjs/protobuf.js) and [markdown-it](https://github.com/markdown-it/markdown-it). Updates `form-data` from 4.0.5 to 4.0.6 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `form-data` from 4.0.5 to 4.0.6 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `form-data` from 4.0.5 to 4.0.6 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `form-data` from 4.0.4 to 4.0.6 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `protobufjs-cli` from 1.3.1 to 1.3.3 - [Release notes](https://github.com/protobufjs/protobuf.js/releases) - [Changelog](https://github.com/protobufjs/protobuf.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-cli-v1.3.1...protobufjs-cli-v1.3.3) Updates `markdown-it` from 12.3.2 to 14.2.0 - [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md) - [Commits](https://github.com/markdown-it/markdown-it/compare/12.3.2...14.2.0) --- updated-dependencies: - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 4.0.6 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: protobufjs-cli dependency-version: 1.3.3 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: markdown-it dependency-version: 14.2.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- keeperapi/package-lock.json | 291 ++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index 48920dce..be6c4ef1 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1789,6 +1789,34 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/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==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2104,6 +2132,51 @@ "node": ">=8" } }, + "node_modules/@jest/core/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==", + "dev": true, + "optional": true, + "peer": true, + "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/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2790,6 +2863,42 @@ "node": ">= 10" } }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -3604,6 +3713,15 @@ "url": "https://opencollective.com/core-js" } }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5001,6 +5119,51 @@ "node": ">=8" } }, + "node_modules/jest-cli/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==", + "dev": true, + "optional": true, + "peer": true, + "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/jest-diff": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", @@ -8327,6 +8490,15 @@ "requires-port": "^1.0.0" } }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -9829,6 +10001,31 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@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==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -10058,6 +10255,29 @@ "requires": { "has-flag": "^4.0.0" } + }, + "ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@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" + } } } }, @@ -10590,6 +10810,38 @@ "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "dev": true }, + "@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "optional": true, + "peer": true + }, + "@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "optional": true, + "peer": true + }, + "@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "optional": true, + "peer": true + }, + "@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==", + "dev": true, + "optional": true, + "peer": true + }, "@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -11236,6 +11488,14 @@ "browserslist": "^4.22.1" } }, + "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==", + "dev": true, + "optional": true, + "peer": true + }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12240,6 +12500,29 @@ "requires": { "has-flag": "^4.0.0" } + }, + "ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@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" + } } } }, @@ -14703,6 +14986,14 @@ "requires-port": "^1.0.0" } }, + "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==", + "dev": true, + "optional": true, + "peer": true + }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", From feddec1437524e1c3ee4f1a77fadb7ccfbbd8b45 Mon Sep 17 00:00:00 2001 From: Hoseong Lee <154545063+hleekeeper@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:09:42 -0500 Subject: [PATCH 36/48] BE-7699 Removed leading slashes from rest messages (#179) * Removed leading slashes from the rest messages * v17.3.1 * Added the engines field in the package.json * v18.0.0 --- keeperapi/package-lock.json | 291 ------------------------------------ 1 file changed, 291 deletions(-) diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index be6c4ef1..48920dce 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1789,34 +1789,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2132,51 +2104,6 @@ "node": ">=8" } }, - "node_modules/@jest/core/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==", - "dev": true, - "optional": true, - "peer": true, - "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/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2863,42 +2790,6 @@ "node": ">= 10" } }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -3713,15 +3604,6 @@ "url": "https://opencollective.com/core-js" } }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5119,51 +5001,6 @@ "node": ">=8" } }, - "node_modules/jest-cli/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==", - "dev": true, - "optional": true, - "peer": true, - "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/jest-diff": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", @@ -8490,15 +8327,6 @@ "requires-port": "^1.0.0" } }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -10001,31 +9829,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "@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==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "dependencies": { - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - } - } - }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -10255,29 +10058,6 @@ "requires": { "has-flag": "^4.0.0" } - }, - "ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@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" - } } } }, @@ -10810,38 +10590,6 @@ "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "dev": true }, - "@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "optional": true, - "peer": true - }, - "@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==", - "dev": true, - "optional": true, - "peer": true - }, "@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -11488,14 +11236,6 @@ "browserslist": "^4.22.1" } }, - "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==", - "dev": true, - "optional": true, - "peer": true - }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12500,29 +12240,6 @@ "requires": { "has-flag": "^4.0.0" } - }, - "ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@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" - } } } }, @@ -14986,14 +14703,6 @@ "requires-port": "^1.0.0" } }, - "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==", - "dev": true, - "optional": true, - "peer": true - }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", From 6c909cc7b18d2c3111342c368a42b8782da81477 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 24 Jun 2026 09:54:28 +0530 Subject: [PATCH 37/48] NSF command implementation (list, get, ln and rm) (#175) --- .../src/nestedShareFolders/linkNsfRecord.ts | 9 ++- KeeperSdk/src/nestedShareFolders/listNsf.ts | 73 +++++++++++-------- .../src/nestedShareFolders/removeNsfRecord.ts | 51 ++++++++++--- .../src/nestedShareFolders/get_nsf.ts | 19 +++-- .../src/nestedShareFolders/link_nsf.ts | 10 ++- .../src/nestedShareFolders/list_nsf.ts | 20 +++-- .../src/nestedShareFolders/remove_nsf.ts | 57 +++++++-------- 7 files changed, 149 insertions(+), 90 deletions(-) diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts index d00aea38..e277ffad 100644 --- a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -16,7 +16,6 @@ import { resolveNsfFolderIdentifier, resolveNsfRecordIdentifier, } from './nsfHelpers' -import type { LinkNsfRecordResult } from './nsfTypes' function resolveRecordKeyType( storage: InMemoryStorage, @@ -35,6 +34,14 @@ function resolveRecordKeyType( } } +export type LinkNsfRecordResult = { + success: boolean + recordUid: string + folderUid: string + status: string + message: string +} + async function buildRecordMetadata( storage: InMemoryStorage, folderUid: string, diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts index 0e21e178..e4ff8d2c 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -6,7 +6,6 @@ import { getKeeperDriveRecords, getRecordDescription, normalizeParentUid, - resolveKeeperDriveRootParentUid, } from './nsfHelpers' import { getRecordTitle, getRecordType } from '../records/RecordUtils' import { @@ -15,24 +14,40 @@ import { NSF_LIST_MIN_TRUNCATE_PREFIX, NSF_LIST_TABLE_HEADERS, } from './nsfConstants' -import { - ListNsfFormat, - type FormattedListNsfTable, - type ListNsfFormatInput, - type ListNsfOptions, - type ListNsfRow, -} from './nsfTypes' + +export enum ListNsfFormat { + Table = 'table', + CSV = 'csv', + JSON = 'json', +} + +export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}` + +export type ListNsfOptions = { + folders?: boolean + records?: boolean + format?: ListNsfFormatInput +} + +export type ListNsfRow = { + itemType: NsfItemType + uid: string + title: string + type: string + description: string + parentOrFolder: string +} + +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 resolveListParentOrFolder(storage: InMemoryStorage, value: string): string { - if (value !== 'root') return value - return resolveKeeperDriveRootParentUid(storage) ?? value -} - function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { return getKeeperDriveFolders(storage).map((folder) => ({ itemType: NsfItemType.Folder, @@ -40,7 +55,7 @@ function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { title: folder.data.name || 'Unnamed', type: '', description: '', - parentOrFolder: resolveListParentOrFolder(storage, normalizeParentUid(storage, folder.parentUid)), + parentOrFolder: normalizeParentUid(storage, folder.parentUid), })) } @@ -51,10 +66,7 @@ function collectRecordRows(storage: InMemoryStorage): ListNsfRow[] { title: getRecordTitle(record), type: getRecordType(record), description: getRecordDescription(record), - parentOrFolder: resolveListParentOrFolder( - storage, - findRecordFolderLocation(storage, record.uid) || 'root' - ), + parentOrFolder: findRecordFolderLocation(storage, record.uid) || 'root', })) } @@ -134,20 +146,19 @@ export function formatListNsfCsv(rows: ListNsfRow[]): string { 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 -} - 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_or_folder: row.parentOrFolder, + })), + null, + 2 + ) } export function formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = ListNsfFormat.Table): string { diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts index 2afae04e..91cc69ca 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -12,17 +12,47 @@ import { resolveNsfRecordIdentifier, } from './nsfHelpers' import { NSF_MAX_RECORD_BATCH } from './nsfConstants' -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] +export enum NsfRemoveOperation { + OwnerTrash = 'owner-trash', + FolderTrash = 'folder-trash', + Unlink = 'unlink', +} + +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.Unlink]: RecordOperationType.UNLINK_FROM_FOLDER, [NsfRemoveOperation.FolderTrash]: RecordOperationType.MOVE_TO_FOLDER_TRASH, @@ -176,13 +206,14 @@ export function formatRemoveNsfPreview(preview: NsfRemovePreviewItem[]): 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}`) - } + 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}`) + } } if (item.error?.message) { lines.push(` Error: ${item.error.message}`) diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts index 1da71772..a997403b 100644 --- a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts @@ -1,25 +1,28 @@ import { cleanup, extractErrorMessage, - formatNsfDetail, - formatNsfJson, GetNsfFormat, login, logger, + prompt, suppressLogs, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { promptRequired, promptYesNo, yesNoPrompt } from '../utils/promptCommands' +import { isYes } from '../utils/format' async function getNsf() { const vault = await login() try { - const identifier = await promptRequired('Record UID, folder UID, or title: ', 'No UID or title given.') + const identifier = (await prompt('Record UID, folder UID, or title: ')).trim() + if (!identifier) { + logger.info('No UID or title given.') + return + } - const asJson = await promptYesNo(yesNoPrompt('Output as JSON?')) - const verbose = await promptYesNo(yesNoPrompt('Verbose permissions?')) - const unmask = await promptYesNo(yesNoPrompt('Unmask secrets?')) + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const verbose = isYes(await prompt('Verbose permissions? [y/N]: ')) + const unmask = isYes(await prompt('Unmask secrets? [y/N]: ')) const restore = suppressLogs() let result @@ -34,7 +37,7 @@ async function getNsf() { } logger.info('') - const output = asJson ? formatNsfJson(result) : formatNsfDetail(result, verbose) + const output = asJson ? JSON.stringify(result.view, null, 2) : vault.formatNsfDetail(result, verbose) process.stdout.write(`${output}\n`) logger.info('') } catch (err) { diff --git a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts index 63e40124..896ed6bb 100644 --- a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts @@ -3,17 +3,21 @@ import { extractErrorMessage, login, logger, + prompt, suppressLogs, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { promptRequired } from '../utils/promptCommands' async function linkNsf() { const vault = await login() try { - const recordIdentifier = await promptRequired('Record UID or title: ') - const folderIdentifier = await promptRequired('Destination folder UID or name: ') + const recordIdentifier = (await prompt('Record UID or title: ')).trim() + const folderIdentifier = (await prompt('Destination folder UID or name: ')).trim() + if (!recordIdentifier || !folderIdentifier) { + logger.info('Both record and folder are required.') + return + } const restore = suppressLogs() let result diff --git a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts index 735b8597..20896bbc 100644 --- a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts @@ -2,17 +2,17 @@ import fs from 'fs/promises' import { cleanup, extractErrorMessage, - formatListNsfOutput, ListNsfFormat, login, logger, + prompt, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { promptChoice, promptOptional, promptYesNo, yesNoPrompt } from '../utils/promptCommands' +import { isYes } from '../utils/format' type ListMode = 'all' | 'folders' | 'records' -const LIST_MODE_CHOICES: Record = { +const MODE_BY_INPUT: Record = { '': 'all', '1': 'all', '2': 'folders', @@ -22,15 +22,19 @@ const LIST_MODE_CHOICES: Record = { records: 'records', } +function parseMode(input: string): ListMode { + return MODE_BY_INPUT[input.trim().toLowerCase()] ?? 'all' +} + async function listNsf() { const vault = await login() try { logger.info('Show: 1) all 2) folders only 3) records only') - const mode = await promptChoice('Choose [1]: ', LIST_MODE_CHOICES) - const asJson = await promptYesNo(yesNoPrompt('Output as JSON?')) - const asCsv = !asJson && (await promptYesNo(yesNoPrompt('Output as CSV?'))) - const outputPath = await promptOptional('Output file path (Enter for stdout): ') + const mode = parseMode(await prompt('Choose [1]: ')) + 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.listNestedShareFolders({ @@ -43,7 +47,7 @@ async function listNsf() { return } - const output = formatListNsfOutput(rows, format) + const output = vault.formatListNsfOutput(rows, format) if (outputPath && format !== ListNsfFormat.Table) { await fs.writeFile(outputPath, output, 'utf-8') logger.info(`Wrote ${rows.length} row(s) to ${outputPath}`) diff --git a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts index e15aec28..d4ce1c7d 100644 --- a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts @@ -1,27 +1,18 @@ import { cleanup, - collectRemoveNsfWarnings, extractErrorMessage, - formatRemoveNsfPreview, login, logger, NsfRemoveOperation, + prompt, suppressLogs, type RemoveNsfRecordInput, type RemoveNsfRecordResult, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { splitCommaSeparated } from '../utils/format' -import { - promptChoice, - promptOptional, - promptRequired, - promptRequiredList, - promptYesNo, - yesNoPrompt, -} from '../utils/promptCommands' +import { isYes } from '../utils/format' -const OPERATION_CHOICES: Record = { +const OPERATION_BY_INPUT: Record = { '': NsfRemoveOperation.OwnerTrash, '1': NsfRemoveOperation.OwnerTrash, '2': NsfRemoveOperation.FolderTrash, @@ -31,16 +22,22 @@ const OPERATION_CHOICES: Record = { unlink: NsfRemoveOperation.Unlink, } -function printPreview(result: RemoveNsfRecordResult): void { +function parseOperation(input: string): NsfRemoveOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveOperation.OwnerTrash +} + +function printPreview(vault: Awaited>, result: RemoveNsfRecordResult): void { if (result.preview.length === 0) return logger.info('') - logger.info(formatRemoveNsfPreview(result.preview)) + logger.info(vault.formatRemoveNsfPreview(result.preview)) logger.info('') } function printPreviewWarnings(result: RemoveNsfRecordResult): void { - for (const warning of collectRemoveNsfWarnings(result.preview)) { - logger.info(`Warning: ${warning}`) + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } } } @@ -60,19 +57,21 @@ async function removeNsf() { const vault = await login() try { - const records = await promptRequiredList( - 'Record UID(s) or title(s), comma-separated: ', - splitCommaSeparated - ) + const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() + const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } logger.info('Operation: 1) owner-trash 2) folder-trash 3) unlink') - const operation = await promptChoice('Choose [1]: ', OPERATION_CHOICES) + const operation = parseOperation(await prompt('Choose [1]: ')) const folder = operation === NsfRemoveOperation.Unlink - ? await promptRequired('Folder UID or name (required for unlink): ') - : await promptOptional('Folder UID or name (optional): ') - const dryRun = await promptYesNo(yesNoPrompt('Dry run (preview only)?')) - const force = dryRun ? false : await promptYesNo(yesNoPrompt('Force confirm without prompt?')) + ? (await prompt('Folder UID or name (required for unlink): ')).trim() + : (await prompt('Folder UID or name (optional): ')).trim() + 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: RemoveNsfRecordInput = { records, @@ -82,14 +81,14 @@ async function removeNsf() { if (dryRun) { const result = await removeNestedShareRecords(vault, { ...baseInput, dryRun: true }) - printPreview(result) + printPreview(vault, result) logger.info('[Dry-run] No records were removed.') return } if (force) { const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) - printPreview(result) + printPreview(vault, result) if (result.confirmed && result.message) { logger.info(result.message) } @@ -97,10 +96,10 @@ async function removeNsf() { } const preview = await removeNestedShareRecords(vault, { ...baseInput, force: false }) - printPreview(preview) + printPreview(vault, preview) printPreviewWarnings(preview) - if (!(await promptYesNo(yesNoPrompt('Do you want to proceed with deletion?')))) { + if (!isYes(await prompt('Do you want to proceed with deletion? [y/n]: '))) { logger.info('Removal cancelled.') return } From 65d48daf63ae0b8d3c7476da93039cf54ee171c8 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 1 Jul 2026 15:46:37 +0530 Subject: [PATCH 38/48] migrated functions and files from sdk to cli repository --- KeeperSdk/README.md | 47 +--- KeeperSdk/src/api.ts | 40 ---- KeeperSdk/src/cli/access.ts | 30 --- KeeperSdk/src/cli/builtinCommands.ts | 36 --- KeeperSdk/src/cli/commandHelpers.ts | 24 -- KeeperSdk/src/cli/commander/get.ts | 42 ---- KeeperSdk/src/cli/commander/getCore.ts | 125 ---------- KeeperSdk/src/cli/commander/index.ts | 4 - KeeperSdk/src/cli/commander/misc.ts | 187 --------------- KeeperSdk/src/cli/commander/nav.ts | 234 ------------------- KeeperSdk/src/cli/commands/help.ts | 76 ------ KeeperSdk/src/cli/commands/login.ts | 178 -------------- KeeperSdk/src/cli/commands/logout.ts | 39 ---- KeeperSdk/src/cli/commands/restoreSession.ts | 211 ----------------- KeeperSdk/src/cli/commands/sync.ts | 61 ----- KeeperSdk/src/cli/commands/vault.ts | 56 ----- KeeperSdk/src/cli/dispatch.ts | 52 ----- KeeperSdk/src/cli/help.ts | 82 ------- KeeperSdk/src/cli/index.ts | 109 --------- KeeperSdk/src/cli/jsonArg.ts | 30 --- KeeperSdk/src/cli/parse.ts | 173 -------------- KeeperSdk/src/cli/parser.ts | 172 -------------- KeeperSdk/src/cli/prompt.ts | 12 - KeeperSdk/src/cli/registry.ts | 55 ----- KeeperSdk/src/cli/table.ts | 8 - KeeperSdk/src/cli/types.ts | 90 ------- KeeperSdk/src/cli/utils.ts | 18 -- KeeperSdk/src/cli/vaultSurface.ts | 18 -- KeeperSdk/src/index.ts | 1 - 29 files changed, 6 insertions(+), 2204 deletions(-) delete mode 100644 KeeperSdk/src/cli/access.ts delete mode 100644 KeeperSdk/src/cli/builtinCommands.ts delete mode 100644 KeeperSdk/src/cli/commandHelpers.ts delete mode 100644 KeeperSdk/src/cli/commander/get.ts delete mode 100644 KeeperSdk/src/cli/commander/getCore.ts delete mode 100644 KeeperSdk/src/cli/commander/index.ts delete mode 100644 KeeperSdk/src/cli/commander/misc.ts delete mode 100644 KeeperSdk/src/cli/commander/nav.ts delete mode 100644 KeeperSdk/src/cli/commands/help.ts delete mode 100644 KeeperSdk/src/cli/commands/login.ts delete mode 100644 KeeperSdk/src/cli/commands/logout.ts delete mode 100644 KeeperSdk/src/cli/commands/restoreSession.ts delete mode 100644 KeeperSdk/src/cli/commands/sync.ts delete mode 100644 KeeperSdk/src/cli/commands/vault.ts delete mode 100644 KeeperSdk/src/cli/dispatch.ts delete mode 100644 KeeperSdk/src/cli/help.ts delete mode 100644 KeeperSdk/src/cli/index.ts delete mode 100644 KeeperSdk/src/cli/jsonArg.ts delete mode 100644 KeeperSdk/src/cli/parse.ts delete mode 100644 KeeperSdk/src/cli/parser.ts delete mode 100644 KeeperSdk/src/cli/prompt.ts delete mode 100644 KeeperSdk/src/cli/registry.ts delete mode 100644 KeeperSdk/src/cli/table.ts delete mode 100644 KeeperSdk/src/cli/types.ts delete mode 100644 KeeperSdk/src/cli/utils.ts delete mode 100644 KeeperSdk/src/cli/vaultSurface.ts diff --git a/KeeperSdk/README.md b/KeeperSdk/README.md index 90ec3929..b4b5ca70 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -1,6 +1,8 @@ # @keeper-security/keeper-sdk-javascript -Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folders, enterprise admin APIs, and a built-in Commander-style shell CLI. +Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folders, and enterprise admin APIs. + +> **CLI:** Commander-style shell commands (`dispatchCliLine`, `help`, `get`, `ls`, …) live in [**@keeper-security/keeper-shell-component**](https://www.npmjs.com/package/@keeper-security/keeper-shell-component) (or this monorepo’s `commander-javascript-cli` package). This SDK package is API-only. [![NPM](https://img.shields.io/npm/v/@keeper-security/keeper-sdk-javascript?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeper-sdk-javascript) @@ -46,49 +48,12 @@ console.log(`Loaded ${vault.getRecords().length} records`); - **Folders**: List, get, create, rename, delete, change directory, folder tree - **Shared folders**: List shared folders, share with users, update permissions - **Sharing**: Share and unshare records, check share info -- **Teams / users / roles** (enterprise admin): Available via the SDK API; not exposed as shell commands in this release - -## Built-in shell CLI - -The package includes a Commander-style CLI (`dispatchCliLine`, `createKeeperCliParser`) for auth, records, and folders. - -**Before login:** `help`, `login`, `restore-session` - -**After login:** - -| Area | Commands | -|------|----------| -| Session | `logout`, `sync` (`syncdown`, `sync-down`, `d`), `whoami` | -| Records | `list` (`l`), `search` (`s`), `get` (`g`) | -| Folders | `ls`, `cd`, `tree`, `mkdir`, `list-sf` (`lsf`) | -| Vault info | `vault summary` | - -Every command supports `--help`. Record/folder write operations (`add`, `update`, `delete`, `share`, …) are SDK-only — see the examples below. - -### Finding records and folders - -| Goal | Command | -|------|---------| -| Record by UID (exact) | `get ` | -| Record by title | `get "Gmail Login"` or `search gmail` | -| Text in title/fields | `search ` (all terms must match) | -| Shared folder by UID | `get ` or `list-sf ` | -| Folder by path/UID | `get `, `ls`, `cd`, `tree` | -| All records (table) | `list` or `list --verbose` | -| Account summary | `whoami` or `vault summary` | - -`search` only covers **vault records** (title, fields, UID). It does not search teams or enterprise users — use the SDK API (`vault.viewTeam`, `vault.listTeams`, …) or `examples/sdk_example` scripts for those. - -```typescript -import { dispatchCliLine, type KeeperCliHost } from "@keeper-security/keeper-sdk-javascript"; - -await dispatchCliLine("restore-session --from-json session.json", host); -await dispatchCliLine("list", host); -await dispatchCliLine("ls", host); -``` +- **Teams / users / roles** (enterprise admin): Available via the SDK API ## Examples +Shell CLI (`dispatchCliLine`, categorized `help`, record/folder commands) is provided by **@keeper-security/keeper-shell-component** — see that package’s `src/keeper-cli/README.md`. + Runnable SDK scripts are in [`examples/sdk_example`](../examples/sdk_example): ```bash diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts index 887c69ca..341f0b98 100644 --- a/KeeperSdk/src/api.ts +++ b/KeeperSdk/src/api.ts @@ -358,46 +358,6 @@ export type { RoleViewTableRow, } from "./roles"; -export { - dispatchCliLine, - dispatchKeeperCli, - ensureKeeperCliRegistry, - registerCliCommand, - registerCliAlias, - getCliCommand, - listCliCommands, - listCliCommandNames, - listCliCommandNamesForLoginState, - listCliCommandsForLoginState, - isAuthCliCommand, - listDocumentedCommands, - getDetailedHelpPage, - formatDetailedHelpForCommand, - tokenizeArguments, - parseCliArgs, - wantsCliHelp, - rejectUnknownOptions, - loginWithCredentials, - loginWithSessionToken, - runLoginCommand, - runLogoutCommand, - KeeperCliParser, - createKeeperCliParser, - getKeeperCliPromptPrefix, - BUILTIN_CLI_COMMANDS, - registerBuiltinCliCommands, - listCommand, -} from "./cli"; -export type { KeeperCliParserOptions } from "./cli"; -export type { - CliResult, - ParsedCli, - CliCommandDefinition, - CliHelpDoc, - KeeperCliHost, - KeeperCliVault, -} from "./cli"; - export { Auth, KeeperEnvironment, diff --git a/KeeperSdk/src/cli/access.ts b/KeeperSdk/src/cli/access.ts deleted file mode 100644 index 027c0467..00000000 --- a/KeeperSdk/src/cli/access.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CliCommandDefinition } from './types' -import { listCliCommands, resolveCliCommandName } from './registry' - -/** Commands available before a vault session exists. */ -export const AUTH_CLI_COMMAND_NAMES = new Set([ - 'help', - 'login', - 'restore-session', -]) - -export function isAuthCliCommand(name: string): boolean { - const resolved = resolveCliCommandName(name) - return resolved != null && AUTH_CLI_COMMAND_NAMES.has(resolved) -} - -export function filterCliCommandsForLoginState( - commands: readonly CliCommandDefinition[], - loggedIn: boolean -): CliCommandDefinition[] { - if (loggedIn) return [...commands] - return commands.filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) -} - -export function listCliCommandsForLoginState(loggedIn: boolean): CliCommandDefinition[] { - return filterCliCommandsForLoginState(listCliCommands(), loggedIn) -} - -export function listCliCommandNamesForLoginState(loggedIn: boolean): readonly string[] { - return listCliCommandsForLoginState(loggedIn).map((c) => c.name) -} diff --git a/KeeperSdk/src/cli/builtinCommands.ts b/KeeperSdk/src/cli/builtinCommands.ts deleted file mode 100644 index 1cd33332..00000000 --- a/KeeperSdk/src/cli/builtinCommands.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { CliCommandDefinition } from './types' -import { registerCliCommand } from './registry' -import { helpCommand } from './commands/help' -import { loginCommand } from './commands/login' -import { logoutCommand } from './commands/logout' -import { restoreSessionCommand } from './commands/restoreSession' -import { syncCommand } from './commands/sync' -import { vaultCommand } from './commands/vault' -import { getCommand } from './commander/get' -import { cdCommand, lsCommand, mkdirCommand, treeCommand } from './commander/nav' -import { listCommand, listSfCommand, searchCommand, whoamiCommand } from './commander/misc' - -/** Built-in CLI commands (Keeper Commander-style vault shell). */ -export const BUILTIN_CLI_COMMANDS: readonly CliCommandDefinition[] = [ - helpCommand, - loginCommand, - restoreSessionCommand, - syncCommand, - vaultCommand, - getCommand, - listCommand, - lsCommand, - cdCommand, - treeCommand, - mkdirCommand, - searchCommand, - listSfCommand, - whoamiCommand, - logoutCommand, -] - -export function registerBuiltinCliCommands(): void { - for (const def of BUILTIN_CLI_COMMANDS) { - registerCliCommand(def) - } -} diff --git a/KeeperSdk/src/cli/commandHelpers.ts b/KeeperSdk/src/cli/commandHelpers.ts deleted file mode 100644 index e4fa6652..00000000 --- a/KeeperSdk/src/cli/commandHelpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { CliResult, KeeperCliHost, KeeperCliVault } from './types' -import { ensureLoggedIn } from './commands/login' - -export async function ensureSession(host: KeeperCliHost): Promise { - const v = host.getVault() - if (v.isLoggedIn) return null - const r = await ensureLoggedIn(host) - return r.code === 0 ? null : r -} - -export function ensureCapability( - v: KeeperCliVault, - name: K, - context: string -): CliResult | null { - if (typeof v[name] !== 'function') { - return { - code: 1, - out: '', - err: `${context}: this host does not expose KeeperCliVault.${String(name)}.\n`, - } - } - return null -} diff --git a/KeeperSdk/src/cli/commander/get.ts b/KeeperSdk/src/cli/commander/get.ts deleted file mode 100644 index 24fd82c1..00000000 --- a/KeeperSdk/src/cli/commander/get.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { CliCommandDefinition } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { executeGet } from './getCore' - -export const getCommand: CliCommandDefinition = { - name: 'get', - order: 10, - aliases: ['g'], - description: 'Get details of a record or folder by UID or title.', - usage: 'get [--format {detail,json,password,fields}] [--unmask]', - flagOptions: ['--format', '--unmask', '--detail', '--json'], - help: { - title: 'get — record/folder details (Keeper Commander)', - synopsis: 'usage: get [--unmask] [--format {detail,json,password,fields}] uid', - description: - ' Resolves a vault object by UID or title. Records support all output formats; folders and shared folders support detail/json.\n' + - ' Prefer get for exact UID lookup; search is for text in titles and fields.', - arguments: ' uid Record, folder, or shared-folder UID or title.', - options: ` --format {detail,json,password,fields} - detail (default): human-readable output. - json: JSON object. - password: password field only (records). - fields: JSON array of {name, value} (records). - --unmask Show sensitive field values (records). - --help, -h Show this help.`, - examples: ` get "Amazon" - get AbCdEf123456 --format json --unmask - get MyFolderUid --format json`, - seeAlso: ' ls, search, list', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(getCommand), err: '' } - } - try { - return await executeGet(host, parsed, 'get') - } catch (e) { - return { code: 1, out: '', err: host.formatError('get', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commander/getCore.ts b/KeeperSdk/src/cli/commander/getCore.ts deleted file mode 100644 index 6d98bde0..00000000 --- a/KeeperSdk/src/cli/commander/getCore.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { DRecord } from '@keeper-security/keeperapi' -import { - formatRecord, - formatRecordFields, - getRecordPassword, -} from '../../records/RecordUtils' -import type { CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt } from '../parse' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { GetFolderFormat } from '../../folders/getFolder' - -export type GetOutputFormat = 'detail' | 'json' | 'password' | 'fields' - -export function resolveGetFormat(parsed: ParsedCli): GetOutputFormat { - const raw = getOpt(parsed.opts, 'format')?.toLowerCase() - if (raw === 'json' || hasOpt(parsed.opts, 'json')) return 'json' - if (raw === 'password') return 'password' - if (raw === 'fields') return 'fields' - if (raw === 'detail') return 'detail' - return hasOpt(parsed.opts, 'detail') ? 'detail' : 'detail' -} - -export function resolveGetUnmask(parsed: ParsedCli): boolean { - return hasOpt(parsed.opts, 'unmask') -} - -export function getGetTarget(parsed: ParsedCli): string | undefined { - return parsed.positional[0]?.trim() || undefined -} - -async function outputRecord( - host: KeeperCliHost, - record: DRecord, - fmt: GetOutputFormat, - unmask: boolean, - cmd: string -): Promise { - if (fmt === 'password') { - const pw = getRecordPassword(record) - return { code: 0, out: pw ? `${pw}\n` : '', err: pw ? '' : `${cmd}: record has no password field\n` } - } - if (fmt === 'fields') { - return { code: 0, out: JSON.stringify(formatRecordFields(record, unmask), null, 2) + '\n', err: '' } - } - if (fmt === 'json') { - return { code: 0, out: JSON.stringify(record, null, 2) + '\n', err: '' } - } - return { code: 0, out: formatRecord(record, { showDetails: true, unmask }) + '\n', err: '' } -} - -async function tryGetFolder( - host: KeeperCliHost, - target: string, - fmt: GetOutputFormat, - cmd: string -): Promise { - const v = host.getVault() - if (!v.getFolder) return null - try { - const res = await v.getFolder(target, { - format: fmt === 'json' ? GetFolderFormat.JSON : GetFolderFormat.Detail, - }) - if (fmt === 'json') { - const json = (res as { json?: Record }).json ?? res - return { code: 0, out: JSON.stringify(json, null, 2) + '\n', err: '' } - } - const name = 'name' in res ? res.name : target - const uid = - 'folder_uid' in res - ? res.folder_uid - : 'shared_folder_uid' in res - ? res.shared_folder_uid - : target - return { code: 0, out: `${name}\t${uid}\n`, err: '' } - } catch { - return null - } -} - -async function tryGetSharedFolderByUid( - host: KeeperCliHost, - target: string, - fmt: GetOutputFormat, - cmd: string -): Promise { - const v = host.getVault() - const hit = v.getSharedFolders().find((sf) => sf.uid === target) - if (!hit) return null - if (fmt === 'json') { - return { code: 0, out: JSON.stringify(hit, null, 2) + '\n', err: '' } - } - if (fmt === 'password' || fmt === 'fields') { - return { code: 1, out: '', err: `${cmd}: --format ${fmt} applies to records only\n` } - } - return { code: 0, out: `${hit.name ?? '(unnamed)'}\t${hit.uid}\n`, err: '' } -} - -/** Commander-style `get` (record, folder, or shared folder by UID/title). */ -export async function executeGet(host: KeeperCliHost, parsed: ParsedCli, cmd = 'get'): Promise { - const target = getGetTarget(parsed) - if (!target) { - return { code: 1, out: '', err: `${cmd}: UID parameter is required\n` } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const fmt = resolveGetFormat(parsed) - const unmask = resolveGetUnmask(parsed) - - await v.sync() - - const sf = await tryGetSharedFolderByUid(host, target, fmt, cmd) - if (sf) return sf - - const folder = await tryGetFolder(host, target, fmt, cmd) - if (folder) return folder - - const cap = ensureCapability(v, 'findRecord', cmd) - if (cap) return cap - const record = v.findRecord!(target) - if (!record) { - return { code: 1, out: '', err: `${cmd}: cannot find any object matching "${target}"\n` } - } - return outputRecord(host, record, fmt, unmask, cmd) -} diff --git a/KeeperSdk/src/cli/commander/index.ts b/KeeperSdk/src/cli/commander/index.ts deleted file mode 100644 index 6795faac..00000000 --- a/KeeperSdk/src/cli/commander/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { getCommand } from './get' -export { executeGet } from './getCore' -export { lsCommand, cdCommand, treeCommand, mkdirCommand } from './nav' -export { listCommand, searchCommand, listSfCommand, whoamiCommand } from './misc' diff --git a/KeeperSdk/src/cli/commander/misc.ts b/KeeperSdk/src/cli/commander/misc.ts deleted file mode 100644 index 5e855aa5..00000000 --- a/KeeperSdk/src/cli/commander/misc.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { formatTable } from '../table' -import { getRecordTitle } from '../../records/RecordUtils' -import { renderRecordsListTable } from '../../records/listRecordsTable' -import { recordUid } from '../utils' -import { formatSharedFoldersTable, renderSharedFoldersAsciiTable } from '../../sharedFolders/listSharedFolders' - -async function runList(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - await v.sync() - const records = v.getRecords() - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(records, null, 2) + '\n', err: '' } - } - if (records.length === 0) { - return { code: 0, out: '(no records)\n', err: '' } - } - const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') - const out = renderRecordsListTable(records, { verbose }) + '\n' - return { code: 0, out, err: '' } -} - -async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { - const pattern = parsed.positional.join(' ') || getOpt(parsed.opts, 'pattern') - if (!pattern?.trim()) { - return { code: 1, out: '', err: 'search: missing search terms. Usage: search \n' } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'findRecords', 'search') - if (cap) return cap - await v.sync() - const matches = v.findRecords!(pattern) - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(matches, null, 2) + '\n', err: '' } - } - if (matches.length === 0) { - return { code: 0, out: `(no records matched "${pattern}")\n`, err: '' } - } - const rows = matches.map((rec) => [recordUid(rec), getRecordTitle(rec)]) - return { code: 0, out: formatTable(['record_uid', 'title'], rows), err: '' } -} - -async function runListSf(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') - if (cap) return cap - await v.sync() - const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null - const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') - const rows = v.listSharedFolders!({ pattern, verbose, includeDetails: verbose }) - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } - } - if (rows.length === 0) { - return { - code: 0, - out: pattern ? `(no shared folders matched "${pattern}")\n` : '(no shared folders)\n', - err: '', - } - } - const table = formatSharedFoldersTable(rows, { verbose }) - return { code: 0, out: renderSharedFoldersAsciiTable(table) + '\n', err: '' } -} - -async function runWhoami(host: KeeperCliHost): Promise { - const v = host.getVault() - if (!v.isLoggedIn) { - return { code: 1, out: '', err: 'whoami: not logged in\n' } - } - const username = - (await host.getAccountUsername?.()) ?? host.envString('KEEPER_USER') ?? host.envString('KEEPER_USERNAME') - const summary = v.getSummary?.() - const lines = [`username: ${username ?? '(unknown)'}`] - if (summary) { - lines.push( - `records: ${summary.recordCount}`, - `folders: ${summary.folderCount}`, - `shared_folders: ${summary.sharedFolderCount}` - ) - } - return { code: 0, out: lines.join('\n') + '\n', err: '' } -} - -export const listCommand: CliCommandDefinition = { - name: 'list', - order: 14, - aliases: ['l'], - description: 'List all vault records (Commander table).', - usage: 'list [--verbose|-v] [--json]', - flagOptions: ['--json', '--verbose', '-v'], - help: { - title: 'list — all records (Keeper Commander)', - synopsis: 'usage: list [--verbose]', - description: - ' Syncs and prints every record in a Commander-style table: uid, type, title, description, shared, and record category.', - options: ' --verbose, -v Do not truncate long columns (default max width 40).', - seeAlso: ' get, search, ls', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listCommand), err: '' } - try { - return await runList(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('list', e) } - } - }, -} - -export const searchCommand: CliCommandDefinition = { - name: 'search', - order: 15, - aliases: ['s'], - description: 'Search vault records by text.', - usage: 'search [--json]', - flagOptions: ['--json', '--pattern'], - help: { - title: 'search — find records (Keeper Commander)', - synopsis: 'usage: search ', - description: - ' Space-separated terms; all terms must match somewhere in the record (title, fields, or UID).\n' + - ' For exact lookup by UID, use get instead.', - examples: ' search amazon\n search bank account\n get zhJdqy7lb_zIEeCJT7GLlQ', - seeAlso: ' get, ls', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(searchCommand), err: '' } - try { - return await runSearch(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('search', e) } - } - }, -} - -export const listSfCommand: CliCommandDefinition = { - name: 'list-sf', - order: 16, - aliases: ['lsf'], - description: 'List shared folders.', - usage: 'list-sf [pattern] [--verbose] [--json]', - flagOptions: ['--verbose', '-v', '--json', '--pattern'], - help: { - title: 'list-sf — shared folders (Keeper Commander)', - synopsis: 'usage: list-sf [pattern]', - seeAlso: ' ls, get', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listSfCommand), err: '' } - try { - return await runListSf(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('list-sf', e) } - } - }, -} - -export const whoamiCommand: CliCommandDefinition = { - name: 'whoami', - order: 18, - description: 'Display current user and vault counts.', - usage: 'whoami', - help: { - title: 'whoami — current user (Keeper Commander)', - synopsis: 'usage: whoami', - seeAlso: ' login, sync-down', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(whoamiCommand), err: '' } - if (parsed.opts.size > 0 || parsed.positional.length > 0) { - return { code: 1, out: '', err: 'whoami: unexpected arguments\n' } - } - try { - return await runWhoami(host) - } catch (e) { - return { code: 1, out: '', err: host.formatError('whoami', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commander/nav.ts b/KeeperSdk/src/cli/commander/nav.ts deleted file mode 100644 index 0ff0e268..00000000 --- a/KeeperSdk/src/cli/commander/nav.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' -import { formatTable } from '../table' - -function lsPath(parsed: ParsedCli): string | undefined { - return parsed.positional[0] -} - -function formatLs( - result: { - detail: boolean - folders: Array<{ uid: string; name: string }> - records: Array<{ uid: string; name: string; type?: string }> - }, - detail: boolean -): string { - if (result.folders.length + result.records.length === 0) return '(empty)\n' - - const headers = detail ? ['flags', 'uid', 'name', 'type'] : ['kind', 'uid', 'name'] - const rows: string[][] = [] - for (const f of result.folders) { - const flags = ((f as { flags?: string }).flags ?? '').trim() - rows.push(detail ? [flags || 'f---', f.uid, f.name, ''] : ['dir', f.uid, f.name]) - } - for (const r of result.records) { - const flags = ((r as { flags?: string }).flags ?? '').trim() - const type = r.type ?? '' - rows.push(detail ? [flags || 'r---', r.uid, r.name, type] : ['rec', r.uid, r.name]) - } - return formatTable(headers, rows) -} - -async function runLs(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'listFolder', cmd) - if (cap) return cap - await v.sync() - - const detail = hasOpt(parsed.opts, 'detail') || hasOpt(parsed.opts, 'list') || hasOpt(parsed.opts, 'l') - const foldersOnly = hasOpt(parsed.opts, 'folders') || hasOpt(parsed.opts, 'f') - const recordsOnly = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') - const target = lsPath(parsed) - - const listOpts = { - detail, - showFolders: recordsOnly ? false : true, - showRecords: foldersOnly ? false : true, - } - - if (!target) { - const result = await v.listFolder!({ ...listOpts }) - return { code: 0, out: formatLs(result, detail), err: '' } - } - - if (!v.changeDirectory || !v.getCurrentFolderUid) { - return { code: 1, out: '', err: `${cmd}: host lacks navigation capabilities.\n` } - } - - const originalUid = v.getCurrentFolderUid() - let resolvedUid: string | null - try { - const cd = await v.changeDirectory(target) - resolvedUid = cd.folderUid - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } - } - try { - const result = await v.listFolder!({ folderUid: resolvedUid ?? null, ...listOpts }) - return { code: 0, out: formatLs(result, detail), err: '' } - } finally { - if (resolvedUid !== originalUid) { - try { - await v.changeDirectory(originalUid ?? '/') - } catch { - /* best-effort */ - } - } - } -} - -async function runCd(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const target = parsed.positional[0] - if (!target) return { code: 1, out: '', err: `${cmd}: missing folder path. Usage: ${cmd} \n` } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'changeDirectory', cmd) - if (cap) return cap - try { - const res = await v.changeDirectory!(target) - return { code: 0, out: `${res.name}\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } - } -} - -async function runTree(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'tree', cmd) - if (cap) return cap - await v.sync() - const folderPath = parsed.positional[0] - const out = await v.tree!(folderPath ? { folderPath, showRecords: true } : { showRecords: true }) - return { code: 0, out: out.endsWith('\n') ? out : out + '\n', err: '' } -} - -async function runMkdir(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { - const target = parsed.positional[0] - if (!target) { - return { code: 1, out: '', err: `${cmd}: missing path. Usage: ${cmd} [-sf]\n` } - } - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'mkdir', cmd) - if (cap) return cap - const cwd = v.getWorkingFolderDisplayName?.() ?? 'My Vault' - const shared = - hasOpt(parsed.opts, 'shared-folder') || - hasOpt(parsed.opts, 'sf') || - hasOpt(parsed.opts, 'shared') - try { - const res = await v.mkdir!(target, { sharedFolder: shared }) - if (!res.success) { - return { code: 1, out: '', err: `${cmd} [in ${cwd}]: ${res.message ?? 'failed'}\n` } - } - return { code: 0, out: `${res.folderUid}\t${target} (in ${cwd})\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError(`${cmd} ${target} [in ${cwd}]`, e) } - } -} - -const lsHelp: CliCommandDefinition['help'] = { - title: 'ls — list folder contents (Keeper Commander)', - synopsis: 'usage: ls [-l] [-f] [-r] [pattern]', - description: ' Lists records and subfolders in the current folder, or in PATH if given.', - options: ` -l, --list Detailed list (flags, types). - -f, --folders Folders only. - -r, --records Records only. - --help, -h Show this help.`, - examples: ' ls\n ls "Marketing"\n ls -l', - seeAlso: ' cd, tree, get', -} - -export const lsCommand: CliCommandDefinition = { - name: 'ls', - order: 11, - description: 'List folder contents (current folder or PATH).', - usage: 'ls [PATH] [-l|--list] [-f|--folders] [-r|--records]', - flagOptions: ['-l', '--list', '-f', '--folders', '-r', '--records', '--detail'], - help: lsHelp, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(lsCommand), err: '' } - try { - return await runLs(host, parsed, 'ls') - } catch (e) { - return { code: 1, out: '', err: host.formatError('ls', e) } - } - }, -} - -export const cdCommand: CliCommandDefinition = { - name: 'cd', - order: 12, - description: 'Change current folder.', - usage: 'cd ', - help: { - title: 'cd — change current folder (Keeper Commander)', - synopsis: 'usage: cd ', - description: ' PATH is a slash-separated folder name/UID sequence, or `/` for vault root.', - examples: ' cd Marketing\n cd ..\n cd /', - seeAlso: ' ls, tree', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(cdCommand), err: '' } - try { - return await runCd(host, parsed, 'cd') - } catch (e) { - return { code: 1, out: '', err: host.formatError('cd', e) } - } - }, -} - -export const treeCommand: CliCommandDefinition = { - name: 'tree', - order: 13, - description: 'Display the folder structure.', - usage: 'tree [PATH]', - help: { - title: 'tree — folder structure (Keeper Commander)', - synopsis: 'usage: tree [folder]', - description: - ' Renders an ASCII tree from PATH or the vault root. Each node is tagged [folder], [shared folder], or [record].', - seeAlso: ' ls, cd', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(treeCommand), err: '' } - try { - return await runTree(host, parsed, 'tree') - } catch (e) { - return { code: 1, out: '', err: host.formatError('tree', e) } - } - }, -} - -export const mkdirCommand: CliCommandDefinition = { - name: 'mkdir', - order: 14, - description: 'Create a folder.', - usage: 'mkdir [-sf|--shared-folder]', - flagOptions: ['-sf', '--shared-folder', '--shared'], - help: { - title: 'mkdir — create folder (Keeper Commander)', - synopsis: 'usage: mkdir [-sf]', - description: ' Creates a user folder under the current folder. -sf creates a shared folder.', - options: ' -sf, --shared-folder Create a shared folder.', - examples: ' mkdir Drafts\n mkdir TeamShare -sf', - seeAlso: ' cd, ls', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(mkdirCommand), err: '' } - try { - return await runMkdir(host, parsed, 'mkdir') - } catch (e) { - return { code: 1, out: '', err: host.formatError('mkdir', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commands/help.ts b/KeeperSdk/src/cli/commands/help.ts deleted file mode 100644 index 17bae201..00000000 --- a/KeeperSdk/src/cli/commands/help.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { CliCommandDefinition, KeeperCliHost } from '../types' -import { wantsCliHelp } from '../parse' -import { - formatAllCommandsSummary, - formatDetailedHelpForCommand, - formatShortCommandSummary, - getDetailedHelpPageForRegistry, -} from '../help' -import { isAuthCliCommand, listCliCommandsForLoginState } from '../access' -import { getCliCommand } from '../registry' - -export const helpCommand: CliCommandDefinition = { - name: 'help', - order: 0, - description: 'Show all commands, or full docs for one command (same as COMMAND --help).', - usage: 'help [command] (see also: help --help)', - help: { - title: 'help — show commands or short syntax for one command', - synopsis: ' help [COMMAND]', - description: ` Without arguments, lists commands for the current session. - When not logged in, only sign-in commands are listed (login, restore-session, …). - After login, lists vault commands as well. - - With COMMAND, prints usage for that command (sign-in commands only when logged out).`, - options: ' None. This command does not take GNU-style flags.', - seeAlso: ' Each command’s --help output.', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(helpCommand), err: '' } - } - if (parsed.opts.size > 0) { - return { code: 1, out: '', err: 'help: unknown option (try `help --help`)\n' } - } - const loggedIn = host.getVault().isLoggedIn - const visible = listCliCommandsForLoginState(loggedIn) - const args = parsed.positional - if (args.length === 0) { - if (loggedIn) { - return { code: 0, out: formatAllCommandsSummary(visible), err: '' } - } - return { - code: 0, - out: formatAllCommandsSummary(visible, { - header: 'Not logged in — sign-in commands:\n\n', - footer: - '\nRun `login` or `restore-session` to open the vault.\n' + - 'After login, run `help` again for vault commands (get, ls, cd, …).\n', - }), - err: '', - } - } - if (args.length > 1) { - return { code: 1, out: '', err: 'Usage: help [command]\n' } - } - const name = args[0] - if (!loggedIn && !isAuthCliCommand(name)) { - return { - code: 1, - out: '', - err: - `help: "${name}" requires a logged-in session. ` + - 'Run `help` for sign-in commands (login, restore-session).\n', - } - } - const long = getDetailedHelpPageForRegistry(visible, name) - if (long) { - return { code: 0, out: long, err: '' } - } - const def = getCliCommand(name) - if (!def) { - return { code: 1, out: '', err: `help: unknown command: ${name}\n` } - } - return { code: 0, out: formatShortCommandSummary(def), err: '' } - }, -} diff --git a/KeeperSdk/src/cli/commands/login.ts b/KeeperSdk/src/cli/commands/login.ts deleted file mode 100644 index b4e2a710..00000000 --- a/KeeperSdk/src/cli/commands/login.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { utf8ToBase64Url } from '../utils' - -const LOGIN_ALLOWED = new Set([ - 'username', - 'user', - 'session-token', - 'token', - 'st', - 'session-token-plain', -]) - -export async function runLoginCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { - const opts = parsed?.opts ?? new Map() - if (parsed && wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(loginCommand), err: '' } - } - if (parsed) { - for (const secretFlag of ['password', 'pass', 'pwd'] as const) { - if (opts.has(secretFlag)) { - return { - code: 1, - out: '', - err: - 'login: do not pass --password on the command line (it is logged and visible). ' + - 'Use KEEPER_PASSWORD for automation, or run `login --username …` in the shell and enter the password when prompted (masked).\n', - } - } - } - const bad = rejectUnknownOptions(parsed, LOGIN_ALLOWED, 'login') - if (bad) return bad - } - - const username = getOpt(opts, 'username', 'user') ?? host.envString('KEEPER_USERNAME') - const passwordEnv = host.envString('KEEPER_PASSWORD') - const sessionRaw = getOpt(opts, 'session-token', 'token', 'st') ?? host.envString('KEEPER_SESSION_TOKEN') - const sessionPlain = parsed && hasOpt(opts, 'session-token-plain') - - if (parsed) { - const stPlainVal = opts.get('session-token-plain') - if (stPlainVal !== undefined && stPlainVal !== true) { - return { - code: 1, - out: '', - err: 'login: --session-token-plain is a boolean flag (no value)\n', - } - } - } - - if (!username) { - return { - code: 1, - out: '', - err: 'login: provide --username or KEEPER_USERNAME.\n', - } - } - - const sessionTrimmed = typeof sessionRaw === 'string' ? sessionRaw.trim() : '' - if (sessionTrimmed.length > 0) { - return loginWithSessionToken(host, username, sessionTrimmed, { plainToken: !!sessionPlain }) - } - - if (!passwordEnv) { - return { - code: 1, - needPassword: true, - loginUsername: username, - out: '', - err: '', - } - } - - return loginWithCredentials(host, username, passwordEnv) -} - -export async function loginWithCredentials( - host: KeeperCliHost, - username: string, - password: string -): Promise { - try { - const v = host.getVault() - if (v.isLoggedIn) { - await v.logout() - } - await v.login(username, password) - await v.sync() - return { code: 0, out: `keeper: logged in as ${username}.\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -export async function loginWithSessionToken( - host: KeeperCliHost, - username: string, - sessionToken: string, - options?: { plainToken?: boolean } -): Promise { - let token = sessionToken.trim() - if (options?.plainToken && token.length > 0) { - token = utf8ToBase64Url(token) - } - try { - const v = host.getVault() - if (v.isLoggedIn) { - await v.logout() - } - await v.loginWithSessionToken(username, token) - await v.sync() - return { code: 0, out: `keeper: logged in as ${username} (session token).\n`, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -/** Pass-through if logged in; auto-login when `KEEPER_USERNAME` is set; otherwise "not logged in". */ -export async function ensureLoggedIn(host: KeeperCliHost): Promise { - if (host.getVault().isLoggedIn) { - return { code: 0, out: '', err: '' } - } - if (host.envString('KEEPER_USERNAME')) { - return runLoginCommand(host, { positional: [], opts: new Map() }) - } - return { code: 1, out: '', err: 'not logged in\n' } -} - -export const loginCommand: CliCommandDefinition = { - name: 'login', - order: 10, - description: - 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN). Password never on CLI line.', - usage: - 'login [--username|--user ] [--session-token|--token|--st ] [--session-token-plain] [--help|-h]', - flagOptions: [ - '--user', - '--username', - '--session-token', - '--token', - '--st', - '--session-token-plain', - ], - allowedOptions: LOGIN_ALLOWED, - help: { - title: 'login — authenticate to Keeper (vault session)', - synopsis: ` login [--username|--user EMAIL_OR_NAME] - login [--username|--user U] [--session-token|--token|--st TOKEN] - login [--username|--user U] [--session-token TOKEN] [--session-token-plain]`, - description: ` Establishes a Keeper session. - - Username comes from --username / --user or KEEPER_USERNAME. - - Password MUST NOT appear on the CLI line (logging, proxies, browser history). - Automation: set KEEPER_PASSWORD in the environment when embedding in Node. - Web shell: run login with only a username; the UI prompts for a masked password - and sends it through the login transport, not in "line". - - Session token login: pass the token on the command line or via - KEEPER_SESSION_TOKEN (sensitive — same caveats as any secret on argv). - - --session-token-plain treats the value as plain UTF-8 and encodes base64url - before login (same idea as the session_token_login example). - - Device registration: session token login requires deviceToken + privateKey for - this host in session storage (e.g. ~/.keeper/config.json) or a prior password - login in this shell.`, - options: ` --username, --user Account identifier (often email). - --session-token, --token, --st Session token string (or use KEEPER_SESSION_TOKEN). - --session-token-plain Treat --session-token value as plain UTF-8 and encode base64url.`, - environment: ` KEEPER_USERNAME Default username if not passed on the command line. - KEEPER_PASSWORD Password for non-interactive login (no session token). - KEEPER_SESSION_TOKEN Session token when not passed as a flag. - KEEPER_HOST Optional vault host / region (also: keeper-host attribute).`, - }, - run: (host, parsed) => runLoginCommand(host, parsed), -} diff --git a/KeeperSdk/src/cli/commands/logout.ts b/KeeperSdk/src/cli/commands/logout.ts deleted file mode 100644 index aa70924a..00000000 --- a/KeeperSdk/src/cli/commands/logout.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' - -export async function runLogoutCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { - if (parsed && wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(logoutCommand), err: '' } - } - if (parsed && parsed.opts.size > 0) { - return { code: 1, out: '', err: 'logout: no options (try: logout --help)\n' } - } - if (parsed && parsed.positional.length > 0) { - return { code: 1, out: '', err: 'Usage: logout\n' } - } - try { - const v = host.getVault() - if (!v.isLoggedIn) { - return { code: 0, out: 'keeper: already logged out.\n', err: '' } - } - await v.logout() - return { code: 0, out: 'keeper: logged out.\n', err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('keeper', e) } - } -} - -export const logoutCommand: CliCommandDefinition = { - name: 'logout', - order: 200, - description: 'Log out of the current Keeper session.', - usage: 'logout [--help|-h]', - help: { - title: 'logout — end the current Keeper session', - synopsis: ' logout', - description: ' Ends the current session if one exists.', - options: ' None.', - }, - run: (host, parsed) => runLogoutCommand(host, parsed), -} diff --git a/KeeperSdk/src/cli/commands/restoreSession.ts b/KeeperSdk/src/cli/commands/restoreSession.ts deleted file mode 100644 index 7e416d59..00000000 --- a/KeeperSdk/src/cli/commands/restoreSession.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { CliCommandDefinition, KeeperCliHost, ParsedCli } from '../types' -import { - resolveSessionRestorePayload, - validateSessionRestoreInput, - type SessionRestoreInput, -} from '../../auth/sessionRestore' -import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { runVaultSync } from './sync' - -/** Flags allowed to follow `--from-json ` on the same line. */ -export const RESTORE_SESSION_TRAILING_OPTS = [ - 'sync', - 'account-uid', - 'client-key', - 'data-key', - 'ecc-private-key', - 'ecc-public-key', - 'message-session-uid', - 'private-key', - 'session-token', - 'st', - 'session-token-type', - 'username', - 'user', - 'user-type', - 'sso-logout-url', - 'sso-session-id', - 'enterprise-public-key', - 'enterprise-ecc-public-key', -] as const - -const RESTORE_ALLOWED = new Set([ - 'sync', - 'from-json', - 'account-uid', - 'client-key', - 'data-key', - 'ecc-private-key', - 'ecc-public-key', - 'message-session-uid', - 'private-key', - 'session-token', - 'st', - 'session-token-type', - 'username', - 'user', - 'user-type', - 'sso-logout-url', - 'sso-session-id', - 'enterprise-public-key', - 'enterprise-ecc-public-key', -]) - -const ENV_PREFIX = 'RESTORE_SESSION_' - -const FIELD_ENV: Record = { - ACCOUNT_UID: 'accountUid', - CLIENT_KEY: 'clientKey', - DATA_KEY: 'dataKey', - ECC_PRIVATE_KEY: 'eccPrivateKey', - ECC_PUBLIC_KEY: 'eccPublicKey', - MESSAGE_SESSION_UID: 'messageSessionUid', - PRIVATE_KEY: 'privateKey', - SESSION_TOKEN: 'sessionToken', - SESSION_TOKEN_TYPE: 'sessionTokenType', - USERNAME: 'username', - USER_TYPE: 'userType', - SSO_LOGOUT_URL: 'ssoLogoutUrl', - SSO_SESSION_ID: 'ssoSessionId', - ENTERPRISE_PUBLIC_KEY: 'enterprisePublicKey', - ENTERPRISE_ECC_PUBLIC_KEY: 'enterpriseEccPublicKey', -} - -const OPT_TO_FIELD: Record = { - 'account-uid': 'accountUid', - 'client-key': 'clientKey', - 'data-key': 'dataKey', - 'ecc-private-key': 'eccPrivateKey', - 'ecc-public-key': 'eccPublicKey', - 'message-session-uid': 'messageSessionUid', - 'private-key': 'privateKey', - 'session-token': 'sessionToken', - st: 'sessionToken', - 'session-token-type': 'sessionTokenType', - username: 'username', - user: 'username', - 'user-type': 'userType', - 'sso-logout-url': 'ssoLogoutUrl', - 'sso-session-id': 'ssoSessionId', - 'enterprise-public-key': 'enterprisePublicKey', - 'enterprise-ecc-public-key': 'enterpriseEccPublicKey', -} - -function envField(host: KeeperCliHost, key: keyof SessionRestoreInput): string | undefined { - const envKey = Object.entries(FIELD_ENV).find(([, v]) => v === key)?.[0] - return envKey ? host.envString(`${ENV_PREFIX}${envKey}`) : undefined -} - -function buildInputFromFlags(host: KeeperCliHost, parsed: ParsedCli): SessionRestoreInput { - const partial: Partial = {} - - for (const [opt, field] of Object.entries(OPT_TO_FIELD)) { - const fromFlag = getOpt(parsed.opts, opt) - if (fromFlag !== undefined) { - ;(partial as Record)[field] = fromFlag - continue - } - const fromEnv = envField(host, field) - if (fromEnv !== undefined) { - ;(partial as Record)[field] = fromEnv - } - } - - return validateSessionRestoreInput(partial) -} - -export const restoreSessionCommand: CliCommandDefinition = { - name: 'restore-session', - order: 12, - description: - 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', - usage: - 'restore-session --from-json FILE|JSON [--sync] OR restore-session --session-token … (see --help)', - flagOptions: [ - '--sync', - '--from-json', - '--account-uid', - '--client-key', - '--data-key', - '--ecc-private-key', - '--ecc-public-key', - '--message-session-uid', - '--private-key', - '--session-token', - '--session-token-type', - '--username', - '--user-type', - '--sso-logout-url', - '--sso-session-id', - '--enterprise-public-key', - '--enterprise-ecc-public-key', - ], - allowedOptions: RESTORE_ALLOWED, - help: { - title: 'restore-session — restore SessionParams from extension / vault export', - synopsis: ` restore-session --from-json session.json - restore-session --session-token TOKEN --username U --account-uid B64 …`, - description: ` Loads a full SessionParams snapshot and resumes the session (same path as - the browser extension after login). Use this when you have accountUid, - clientKey, dataKey, keys, sessionToken, username, etc. from extension storage - — deviceToken/device private key are not part of this payload. - - Provide parameters either as one JSON object (--from-json) or as flags / env. - Binary fields are base64 or base64url.`, - options: ` --from-json Inline JSON (object or JSON-stringified object), or a file path - The entire remainder of the command line is passed to JSON.parse (then file read if needed). - --account-uid, --client-key, --data-key, --ecc-private-key, --ecc-public-key - --message-session-uid, --private-key - --session-token, --st Session token string (as stored; often base64url) - --session-token-type Numeric SessionTokenType enum - --username, --user - --user-type 0=normal, 1=cloud_sso, 2=onsite_sso (or string names) - --sso-logout-url, --sso-session-id - --enterprise-public-key, --enterprise-ecc-public-key (optional) - --sync Run syncDown after restoring the session`, - environment: ` RESTORE_SESSION_JSON Same as --from-json - RESTORE_SESSION_ACCOUNT_UID Per-field overrides (see --help flags) - RESTORE_SESSION_SESSION_TOKEN - … (RESTORE_SESSION_ for each field above)`, - note: ' sessionToken expires; region must match keeper-host / KEEPER_HOST.', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(restoreSessionCommand), err: '' } - } - const bad = rejectUnknownOptions(parsed, RESTORE_ALLOWED, 'restore-session') - if (bad) return bad - if (parsed.positional.length > 0) { - return { code: 1, out: '', err: 'restore-session: unexpected positional arguments\n' } - } - - try { - let input: SessionRestoreInput - const jsonRaw = getOpt(parsed.opts, 'from-json') ?? host.envString('RESTORE_SESSION_JSON') - if (jsonRaw) { - const readFile = - host.readTextFile ?? - (typeof document === 'undefined' - ? async (path: string) => (await import('fs/promises')).readFile(path, 'utf8') - : undefined) - input = await resolveSessionRestorePayload(jsonRaw, readFile) - } else { - input = buildInputFromFlags(host, parsed) - } - - await host.getVault().restoreSession(input) - let out = `keeper: session restored for ${input.username}.\n` - if (hasOpt(parsed.opts, 'sync')) { - const syncResult = await runVaultSync(host) - if (syncResult.code !== 0) { - return syncResult - } - out += syncResult.out - } - return { code: 0, out, err: '' } - } catch (e) { - return { code: 1, out: '', err: host.formatError('restore-session', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commands/sync.ts b/KeeperSdk/src/cli/commands/sync.ts deleted file mode 100644 index 72d71f67..00000000 --- a/KeeperSdk/src/cli/commands/sync.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { SyncResult } from '@keeper-security/keeperapi' -import type { CliCommandDefinition, CliResult, KeeperCliHost } from '../types' -import { wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureLoggedIn } from './login' - -function formatSyncSummary(result: SyncResult): string { - const lines = [`keeper: sync complete for ${result.username}.`, ` pages: ${result.pageCount}`] - if (result.totalTime) lines.push(` total: ${result.totalTime}`) - if (result.networkTime) lines.push(` network: ${result.networkTime}`) - const counts = result.counts ?? {} - const parts = Object.entries(counts) - .filter(([, n]) => typeof n === 'number' && n > 0) - .map(([k, n]) => `${k}=${n}`) - if (parts.length) lines.push(` counts: ${parts.join(', ')}`) - if (result.error) lines.push(` warning: ${result.error}`) - return lines.join('\n') + '\n' -} - -/** Download vault data via keeperapi syncDown (KeeperVault.sync). */ -export async function runVaultSync(host: KeeperCliHost): Promise { - const v = host.getVault() - if (!v.isLoggedIn) { - const login = await ensureLoggedIn(host) - if (login.code !== 0) return login - } - const result = await v.sync() - return { code: 0, out: formatSyncSummary(result), err: '' } -} - -export const syncCommand: CliCommandDefinition = { - name: 'sync', - order: 20, - aliases: ['syncdown', 'sync-down', 'd'], - description: 'Download / refresh vault data from Keeper (syncDown).', - usage: 'sync [--help|-h]', - help: { - title: 'sync — download vault data (syncDown)', - synopsis: ' sync', - description: ` Pulls records, folders, and related vault data into local storage. - Requires an active session (login or restore-session).`, - options: ' --help, -h Show this help.', - seeAlso: ' restore-session --sync, list, ls', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(syncCommand), err: '' } - } - if (parsed.opts.size > 0) { - return { code: 1, out: '', err: 'sync: unknown option (try: sync --help)\n' } - } - if (parsed.positional.length > 0) { - return { code: 1, out: '', err: 'sync: unexpected arguments (try: sync --help)\n' } - } - try { - return await runVaultSync(host) - } catch (e) { - return { code: 1, out: '', err: host.formatError('sync', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/commands/vault.ts b/KeeperSdk/src/cli/commands/vault.ts deleted file mode 100644 index 4bdaf979..00000000 --- a/KeeperSdk/src/cli/commands/vault.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' -import { hasOpt, wantsCliHelp } from '../parse' -import { formatDetailedHelpForCommand } from '../help' -import { ensureCapability, ensureSession } from '../commandHelpers' - -async function runSummary(host: KeeperCliHost, parsed: ParsedCli): Promise { - const r = await ensureSession(host) - if (r) return r - const v = host.getVault() - const cap = ensureCapability(v, 'getSummary', 'vault summary') - if (cap) return cap - await v.sync!() - const summary = v.getSummary!() - if (hasOpt(parsed.opts, 'json')) { - return { code: 0, out: JSON.stringify(summary, null, 2) + '\n', err: '' } - } - const lines = [ - `records: ${summary.recordCount}`, - `shared_folders: ${summary.sharedFolderCount}`, - `teams: ${summary.teamCount}`, - `folders: ${summary.folderCount}`, - ] - return { code: 0, out: lines.join('\n') + '\n', err: '' } -} - -export const vaultCommand: CliCommandDefinition = { - name: 'vault', - order: 25, - description: 'Vault summary counts (records, folders, shared folders).', - usage: 'vault summary [--json] [--help|-h]', - subcommands: ['summary'], - flagOptions: ['--json'], - help: { - title: 'vault — vault-wide statistics', - synopsis: ' vault summary [--json]', - description: ' Runs sync, then prints counts from the local vault cache.', - arguments: ' summary Print record, shared folder, and user-folder counts.', - options: ' --json Emit JSON.\n --help, -h Show this help.', - examples: ' vault summary\n vault summary --json', - seeAlso: ' sync, list, tree, whoami', - }, - async run(host, parsed) { - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(vaultCommand), err: '' } - } - const sub = parsed.positional[0]?.toLowerCase() ?? 'summary' - if (sub !== 'summary') { - return { code: 1, out: '', err: 'Usage: vault summary\n' } - } - try { - return await runSummary(host, parsed) - } catch (e) { - return { code: 1, out: '', err: host.formatError('vault summary', e) } - } - }, -} diff --git a/KeeperSdk/src/cli/dispatch.ts b/KeeperSdk/src/cli/dispatch.ts deleted file mode 100644 index ba49e697..00000000 --- a/KeeperSdk/src/cli/dispatch.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { CliResult, KeeperCliHost, ParsedCli } from './types' -import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' -import { extractFromJsonFlagValue } from './jsonArg' -import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' -import { formatDetailedHelpForCommand } from './help' -import { isAuthCliCommand } from './access' -import { getCliCommand } from './registry' - -const NOT_LOGGED_IN_ERR = - 'Not logged in. Run `login` or `restore-session` (see `help`).\n' - -export async function dispatchKeeperCli( - commandName: string, - args: string[], - host: KeeperCliHost, - preParsed?: ParsedCli -): Promise { - const def = getCliCommand(commandName) - if (!def) { - return { code: 1, out: '', err: `Unknown command: ${commandName}\n` } - } - if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { - return { code: 1, out: '', err: NOT_LOGGED_IN_ERR } - } - const parsed = preParsed ?? parseCliArgs(args) - if (wantsCliHelp(parsed)) { - return { code: 0, out: formatDetailedHelpForCommand(def), err: '' } - } - return def.run(host, parsed) -} - -export async function dispatchCliLine(line: string, host: KeeperCliHost): Promise { - const trimmed = line.trim() - if (!trimmed) { - return { code: 0, out: '', err: '' } - } - const tokens = tokenizeArguments(trimmed) - const name = tokens[0]?.toLowerCase() - if (!name) { - return { code: 0, out: '', err: '' } - } - const args = tokens.slice(1) - let preParsed: ParsedCli | undefined - if (name === 'restore-session') { - const json = extractFromJsonFlagValue(trimmed, 'from-json', RESTORE_SESSION_TRAILING_OPTS) - if (json) { - preParsed = parseCliArgs(args) - preParsed.opts.set('from-json', json) - } - } - return dispatchKeeperCli(name, args, host, preParsed) -} diff --git a/KeeperSdk/src/cli/help.ts b/KeeperSdk/src/cli/help.ts deleted file mode 100644 index 40773a4f..00000000 --- a/KeeperSdk/src/cli/help.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { CliCommandDefinition, CliHelpDoc } from './types' - -const SECTION_ORDER: (keyof CliHelpDoc)[] = [ - 'synopsis', - 'description', - 'arguments', - 'options', - 'environment', - 'examples', - 'seeAlso', - 'note', -] - -const SECTION_LABELS: Partial> = { - synopsis: 'SYNOPSIS', - description: 'DESCRIPTION', - arguments: 'ARGUMENTS', - options: 'OPTIONS', - environment: 'ENVIRONMENT', - examples: 'EXAMPLES', - seeAlso: 'SEE ALSO', - note: 'NOTE', -} - -export function formatDetailedHelp(doc: CliHelpDoc): string { - const parts: string[] = [doc.title.trim()] - for (const key of SECTION_ORDER) { - const body = doc[key] - if (typeof body !== 'string' || !body.trim()) continue - const label = SECTION_LABELS[key] - if (label) { - parts.push('') - parts.push(label) - } - parts.push(body.trim()) - } - return `${parts.join('\n')}\n` -} - -export function formatDetailedHelpForCommand(def: CliCommandDefinition): string { - return formatDetailedHelp(def.help) -} - -export function getDetailedHelpPageForRegistry( - commands: Iterable, - name: string -): string | null { - const key = name.toLowerCase() - for (const def of commands) { - if (def.name === key) return formatDetailedHelpForCommand(def) - if (def.aliases?.some((a) => a.toLowerCase() === key)) { - return formatDetailedHelpForCommand(def) - } - } - return null -} - -export type CommandsSummaryOptions = { - header?: string - footer?: string -} - -export function formatAllCommandsSummary( - commands: readonly CliCommandDefinition[], - options?: CommandsSummaryOptions -): string { - const sorted = [...commands].sort((a, b) => a.name.localeCompare(b.name)) - const w = Math.max(...sorted.map((c) => c.name.length), 8) - let out = options?.header ?? 'Supported commands:\n\n' - if (!out.endsWith('\n\n')) { - out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n` - } - for (const c of sorted) { - out += ` ${c.name.padEnd(w)} ${c.description}\n` - } - out += options?.footer ?? '\nRun ` --help` (or `-h`) for details on a specific command.\n' - return out -} - -export function formatShortCommandSummary(def: CliCommandDefinition): string { - return `${def.name} — ${def.description}\n Usage: ${def.usage}\n` -} diff --git a/KeeperSdk/src/cli/index.ts b/KeeperSdk/src/cli/index.ts deleted file mode 100644 index a3c67c51..00000000 --- a/KeeperSdk/src/cli/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { registerBuiltinCliCommands } from './builtinCommands' -import { registerCliAlias } from './registry' - -let registryInitialized = false - -/** Register built-in Keeper CLI commands (idempotent). */ -export function ensureKeeperCliRegistry(): void { - if (registryInitialized) return - registryInitialized = true - registerBuiltinCliCommands() - registerCliAlias('?', 'help') -} - -ensureKeeperCliRegistry() - -export type { - CliResult, - ParsedCli, - CliHelpDoc, - CliCommandDefinition, - KeeperCliHost, - KeeperCliVault, -} from './types' - -export { - tokenizeArguments, - parseCliArgs, - hasOpt, - getOpt, - wantsCliHelp, - rejectUnknownOptions, -} from './parse' - -export { - formatDetailedHelp, - formatDetailedHelpForCommand, - formatAllCommandsSummary, - formatShortCommandSummary, -} from './help' -import { getDetailedHelpPageForRegistry } from './help' -import { listCliCommands } from './registry' - -export function getDetailedHelpPage(name: string): string | null { - ensureKeeperCliRegistry() - return getDetailedHelpPageForRegistry(listCliCommands(), name) -} - -export { - registerCliCommand, - registerCliAlias, - resolveCliCommandName, - getCliCommand, - listCliCommands, - listCliCommandNames, - listDocumentedCommands, - clearCliRegistry, -} from './registry' - -export { - AUTH_CLI_COMMAND_NAMES, - isAuthCliCommand, - filterCliCommandsForLoginState, - listCliCommandsForLoginState, - listCliCommandNamesForLoginState, -} from './access' - -export { dispatchKeeperCli, dispatchCliLine } from './dispatch' - -export { KeeperCliParser, createKeeperCliParser } from './parser' -export type { KeeperCliParserOptions } from './parser' - -export { - runLoginCommand, - loginWithCredentials, - loginWithSessionToken, - ensureLoggedIn, - loginCommand, -} from './commands/login' - -export { runLogoutCommand, logoutCommand } from './commands/logout' -export { vaultCommand } from './commands/vault' -export { helpCommand } from './commands/help' -export { restoreSessionCommand } from './commands/restoreSession' -export { syncCommand, runVaultSync } from './commands/sync' - -export { getKeeperCliPromptPrefix } from './prompt' -export { BUILTIN_CLI_COMMANDS, registerBuiltinCliCommands } from './builtinCommands' -export { - getCommand, - executeGet, - listCommand, - searchCommand, - listSfCommand, - whoamiCommand, - lsCommand, - cdCommand, - treeCommand, - mkdirCommand, -} from './commander' - -export { utf8ToBase64Url, recordUid } from './utils' - -export type { SessionRestoreInput } from '../auth/sessionRestore' -export { - toSessionParams, - validateSessionRestoreInput, - sessionRestoreFromJson, - resolveSessionRestorePayload, -} from '../auth/sessionRestore' diff --git a/KeeperSdk/src/cli/jsonArg.ts b/KeeperSdk/src/cli/jsonArg.ts deleted file mode 100644 index 6a11c1bc..00000000 --- a/KeeperSdk/src/cli/jsonArg.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Everything after `--from-json` on the command line (trimmed). No tokenization — callers use JSON.parse. - * Trailing flags such as `--sync` are stripped via {@link stripTrailingCliFlags}. - */ -export function extractFromJsonFlagValue( - line: string, - flag = 'from-json', - trailingFlags: readonly string[] = [] -): string | null { - const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const flagRe = new RegExp(`(?:^|\\s)--${escaped}(?:\\s*=\\s*|\\s+)`, 'i') - const m = line.match(flagRe) - if (!m || m.index === undefined) return null - const rest = line.slice(m.index + m[0].length).trim() - if (!rest) return null - return stripTrailingCliFlags(rest, trailingFlags) -} - -/** Remove trailing ` --flag` tokens (e.g. `--sync` after a file path or JSON blob). */ -export function stripTrailingCliFlags(value: string, flagNames: readonly string[]): string { - if (flagNames.length === 0) return value.trim() - let s = value.trim() - const parts = flagNames.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - const alt = parts.join('|') - const tailFlag = new RegExp(`\\s+--(?:${alt})(?:\\s*=\\s*(?:"[^"]*"|\\S+))?\\s*$`, 'i') - while (tailFlag.test(s)) { - s = s.replace(tailFlag, '').trim() - } - return s -} diff --git a/KeeperSdk/src/cli/parse.ts b/KeeperSdk/src/cli/parse.ts deleted file mode 100644 index 73f9ef15..00000000 --- a/KeeperSdk/src/cli/parse.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { CliResult, ParsedCli } from './types' - -const isWhitespace = (ch: string) => /\s/.test(ch) - -/** Split a command line into tokens; respects double quotes and `\\` escapes. */ -export function tokenizeArguments(args: string): string[] { - const out: string[] = [] - const sb: string[] = [] - let pos = 0 - let inQuote = false - let escape = false - - const flush = () => { - if (sb.length > 0) { - out.push(sb.join('')) - sb.length = 0 - } - } - - while (pos < args.length) { - const ch = args[pos] - if (escape) { - escape = false - sb.push(ch) - pos++ - continue - } - if (inQuote) { - if (ch === '\\') { - escape = true - pos++ - continue - } - if (ch === '"') { - inQuote = false - pos++ - continue - } - sb.push(ch) - pos++ - continue - } - switch (ch) { - case '\\': - escape = true - pos++ - break - case '"': - inQuote = true - pos++ - break - default: - if (isWhitespace(ch)) { - flush() - pos++ - } else { - sb.push(ch) - pos++ - } - } - } - flush() - return out -} - -function setBool(opts: Map, k: string): void { - opts.set(k.toLowerCase(), true) -} - -function setStr(opts: Map, k: string, v: string): void { - opts.set(k.toLowerCase(), v) -} - -/** Parse argv-style tokens after the command name. */ -export function parseCliArgs(tokens: string[]): ParsedCli { - const positional: string[] = [] - const opts = new Map() - - let i = 0 - while (i < tokens.length) { - const t = tokens[i] - if (t === '--') { - positional.push(...tokens.slice(i + 1)) - break - } - if (t === '-' || !t.startsWith('-')) { - positional.push(t) - i++ - continue - } - - if (t.startsWith('--')) { - const body = t.slice(2) - if (!body) { - positional.push(t) - i++ - continue - } - const eq = body.indexOf('=') - if (eq >= 0) { - setStr(opts, body.slice(0, eq), body.slice(eq + 1)) - i++ - continue - } - const name = body - const next = tokens[i + 1] - if (next && next !== '--' && !next.startsWith('-')) { - setStr(opts, name, next) - i += 2 - continue - } - setBool(opts, name) - i++ - continue - } - - const rest = t.slice(1) - if (!rest) { - positional.push(t) - i++ - continue - } - if (/^[A-Za-z]$/.test(rest)) { - setBool(opts, rest) - i++ - continue - } - if (/^[A-Za-z]+$/.test(rest)) { - for (const ch of rest) setBool(opts, ch) - i++ - continue - } - - positional.push(t) - i++ - } - - return { positional, opts } -} - -export function hasOpt(opts: Map, ...names: string[]): boolean { - for (const n of names) { - const v = opts.get(n.toLowerCase()) - if (v === true) return true - } - return false -} - -export function getOpt(opts: Map, ...names: string[]): string | undefined { - for (const n of names) { - const v = opts.get(n.toLowerCase()) - if (v !== undefined && v !== true) return v - } - return undefined -} - -export function wantsCliHelp(parsed: ParsedCli): boolean { - return hasOpt(parsed.opts, 'help', 'h') -} - -export function rejectUnknownOptions( - parsed: ParsedCli, - allowed: ReadonlySet, - commandName: string -): CliResult | null { - for (const k of parsed.opts.keys()) { - if (k === 'help' || k === 'h') continue - if (!allowed.has(k)) { - return { code: 1, out: '', err: `${commandName}: unknown option --${k}\n` } - } - } - return null -} diff --git a/KeeperSdk/src/cli/parser.ts b/KeeperSdk/src/cli/parser.ts deleted file mode 100644 index 2be6be47..00000000 --- a/KeeperSdk/src/cli/parser.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from './types' -import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' -import { extractFromJsonFlagValue } from './jsonArg' -import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' -import { AUTH_CLI_COMMAND_NAMES, isAuthCliCommand } from './access' -import { BUILTIN_CLI_COMMANDS } from './builtinCommands' -import { formatAllCommandsSummary, formatDetailedHelpForCommand, formatShortCommandSummary } from './help' - -const NOT_LOGGED_IN_ERR = - 'Not logged in. Run `login` or `restore-session` (see `help`).\n' - -export type KeeperCliParserOptions = { - prog?: string - description?: string - epilog?: string -} - -/** Self-contained CLI parser. Register commands, then `parse()` dispatches a line. */ -export class KeeperCliParser { - private readonly prog: string - private readonly description: string - private readonly epilog?: string - private readonly commands = new Map() - private readonly aliases = new Map() - - constructor(options: KeeperCliParserOptions = {}) { - this.prog = options.prog ?? 'keeper' - this.description = options.description ?? '' - this.epilog = options.epilog - } - - addCommand(def: CliCommandDefinition): this { - const key = def.name.toLowerCase() - this.commands.set(key, def) - if (def.aliases) { - for (const alias of def.aliases) { - this.aliases.set(alias.toLowerCase(), key) - } - } - return this - } - - addCommands(defs: Iterable): this { - for (const def of defs) this.addCommand(def) - return this - } - - list(): CliCommandDefinition[] { - return [...this.commands.values()].sort((a, b) => { - const oa = a.order ?? 500 - const ob = b.order ?? 500 - if (oa !== ob) return oa - ob - return a.name.localeCompare(b.name) - }) - } - - listNames(): string[] { - return this.list().map((c) => c.name) - } - - resolve(name: string): CliCommandDefinition | undefined { - const key = name.toLowerCase() - if (this.commands.has(key)) return this.commands.get(key) - const target = this.aliases.get(key) - return target ? this.commands.get(target) : undefined - } - - formatHelp(host?: KeeperCliHost): string { - const loggedIn = host?.getVault().isLoggedIn ?? true - const commands = loggedIn - ? this.list() - : this.list().filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) - const header = this.description ? `${this.prog} — ${this.description}\n\n` : '' - const body = loggedIn - ? formatAllCommandsSummary(commands) - : formatAllCommandsSummary(commands, { - header: 'Not logged in — sign-in commands:\n\n', - footer: - '\nRun `login` or `restore-session` to open the vault.\n' + - 'After login, run `help` again for vault commands (get, ls, cd, …).\n', - }) - const footer = this.epilog ? `\n${this.epilog}\n` : '' - return header + body + footer - } - - formatCommandHelp(name: string): string | null { - const def = this.resolve(name) - return def ? formatDetailedHelpForCommand(def) : null - } - - formatCommandSummary(name: string): string | null { - const def = this.resolve(name) - return def ? formatShortCommandSummary(def) : null - } - - async parse(line: string | readonly string[], host: KeeperCliHost): Promise { - const { tokens, raw } = normalizeInput(line) - if (tokens.length === 0) { - return ok(this.formatHelp(host)) - } - - const first = tokens[0] - const rest = tokens.slice(1) - - if (isHelpToken(first)) { - const sub = rest[0] - if (!sub) return ok(this.formatHelp(host)) - if (!host.getVault().isLoggedIn && !isAuthCliCommand(sub)) { - return err(NOT_LOGGED_IN_ERR) - } - const page = this.formatCommandHelp(sub) - if (page) return ok(page) - return err(`${this.prog}: unknown command: ${sub}\nTry: ${this.prog} --help\n`) - } - - const def = this.resolve(first) - if (!def) { - return err(`${this.prog}: unknown command: ${first}\nTry: ${this.prog} --help\n`) - } - - if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { - return err(NOT_LOGGED_IN_ERR) - } - - let parsed: ParsedCli - if (def.name === 'restore-session') { - const json = extractFromJsonFlagValue(raw, 'from-json', RESTORE_SESSION_TRAILING_OPTS) - parsed = parseCliArgs(rest) - if (json) parsed.opts.set('from-json', json) - } else { - parsed = parseCliArgs(rest) - } - - if (wantsCliHelp(parsed)) { - return ok(formatDetailedHelpForCommand(def)) - } - return def.run(host, parsed) - } -} - -/** Parser pre-loaded with the SDK's built-in commands. */ -export function createKeeperCliParser(options: KeeperCliParserOptions = {}): KeeperCliParser { - const parser = new KeeperCliParser(options) - void loadBuiltinsInto(parser) - return parser -} - -function loadBuiltinsInto(parser: KeeperCliParser): void { - parser.addCommands(BUILTIN_CLI_COMMANDS) -} - -function normalizeInput(line: string | readonly string[]): { tokens: string[]; raw: string } { - if (typeof line === 'string') { - const trimmed = line.trim() - return { tokens: trimmed ? tokenizeArguments(trimmed) : [], raw: trimmed } - } - const tokens = [...line].filter((t) => t.length > 0) - return { tokens, raw: tokens.join(' ') } -} - -function isHelpToken(token: string): boolean { - const t = token.toLowerCase() - return t === '--help' || t === '-h' || t === 'help' -} - -function ok(out: string): CliResult { - return { code: 0, out, err: '' } -} - -function err(message: string): CliResult { - return { code: 1, out: '', err: message } -} diff --git a/KeeperSdk/src/cli/prompt.ts b/KeeperSdk/src/cli/prompt.ts deleted file mode 100644 index 0ac8ea99..00000000 --- a/KeeperSdk/src/cli/prompt.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { KeeperCliHost } from './types' - -const NOT_LOGGED_IN_PROMPT = 'Not logged in> ' -const PROMPT_MAX_LEN = 40 - -export function getKeeperCliPromptPrefix(host: KeeperCliHost): string { - const v = host.getVault() - if (!v.isLoggedIn) return NOT_LOGGED_IN_PROMPT - const name = v.getWorkingFolderDisplayName?.() ?? 'My Vault' - const label = name.length > PROMPT_MAX_LEN ? `...${name.slice(-37)}` : name - return `${label}> ` -} diff --git a/KeeperSdk/src/cli/registry.ts b/KeeperSdk/src/cli/registry.ts deleted file mode 100644 index 4e541bac..00000000 --- a/KeeperSdk/src/cli/registry.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { CliCommandDefinition } from './types' - -const commands = new Map() -const aliases = new Map() - -function normalizeName(name: string): string { - return name.toLowerCase() -} - -export function registerCliCommand(def: CliCommandDefinition): void { - const key = normalizeName(def.name) - commands.set(key, def) - if (def.aliases) { - for (const alias of def.aliases) { - aliases.set(normalizeName(alias), key) - } - } -} - -export function registerCliAlias(alias: string, commandName: string): void { - aliases.set(normalizeName(alias), normalizeName(commandName)) -} - -export function resolveCliCommandName(name: string): string | undefined { - const key = normalizeName(name) - if (commands.has(key)) return key - return aliases.get(key) -} - -export function getCliCommand(name: string): CliCommandDefinition | undefined { - const key = resolveCliCommandName(name) - return key ? commands.get(key) : undefined -} - -export function listCliCommands(): CliCommandDefinition[] { - return [...commands.values()].sort((a, b) => { - const oa = a.order ?? 500 - const ob = b.order ?? 500 - if (oa !== ob) return oa - ob - return a.name.localeCompare(b.name) - }) -} - -export function listCliCommandNames(): readonly string[] { - return listCliCommands().map((c) => c.name) -} - -export function listDocumentedCommands(): readonly string[] { - return listCliCommandNames() -} - -export function clearCliRegistry(): void { - commands.clear() - aliases.clear() -} diff --git a/KeeperSdk/src/cli/table.ts b/KeeperSdk/src/cli/table.ts deleted file mode 100644 index 499f206c..00000000 --- a/KeeperSdk/src/cli/table.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Fixed-width column formatter. Last column is left unpadded. */ -export function formatTable(headers: string[], rows: string[][]): string { - if (rows.length === 0) return '' - const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) - const fmt = (cells: string[]): string => - cells.map((s, i) => (i === cells.length - 1 ? s : (s ?? '').padEnd(widths[i]))).join(' ') - return [fmt(headers), ...rows.map(fmt)].join('\n') + '\n' -} diff --git a/KeeperSdk/src/cli/types.ts b/KeeperSdk/src/cli/types.ts deleted file mode 100644 index fb6cb6fc..00000000 --- a/KeeperSdk/src/cli/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { DRecord, DSharedFolder, SyncResult } from '@keeper-security/keeperapi' -import type { SessionRestoreInput } from '../auth/sessionRestore' -import type { ChangeDirectoryResult } from '../folders/changeDirectory' -import type { FolderTreeBuildOptions } from '../folders/folderTree' -import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' -import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' -import type { MkdirOptions } from '../folders/addFolder' -import type { RenameFolderResult } from '../folders/updateFolder' -import type { DeleteFolderResult } from '../folders/deleteFolder' -import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' -import type { RecordShareInfo } from '../sharing/Sharing' -import type { VaultSummary } from '../vault/KeeperVault' - -export type CliResult = { - code: number - out: string - err: string - /** Set when the host UI must prompt for a masked password (never on the CLI line). */ - needPassword?: boolean - loginUsername?: string -} - -export type ParsedCli = { - positional: string[] - opts: Map -} - -/** - * Vault surface for CLI handlers. Methods beyond session/sync/records are optional; - * commands call `ensureCapability` so thin hosts fail with a clear message. - */ -export type KeeperCliVault = { - readonly isLoggedIn: boolean - login(username: string, password: string): Promise - loginWithSessionToken(username: string, sessionToken: string): Promise - logout(): Promise - sync(): Promise - getRecords(): DRecord[] - getSharedFolders(): DSharedFolder[] - restoreSession(input: SessionRestoreInput): Promise - getSummary?: () => VaultSummary - findRecord?: (uidOrTitle: string) => DRecord | undefined - findRecords?: (criteria: string) => DRecord[] - getRecordShareInfo?: (recordUid: string) => Promise - listSharedFolders?: (options?: ListSharedFoldersOptions) => ListSharedFolderRow[] - listFolder?: (options?: ListFolderOptions) => Promise - tree?: (options?: FolderTreeBuildOptions) => Promise - changeDirectory?: (path: string) => Promise - getCurrentFolderUid?: () => string | null - getWorkingFolderDisplayName?: () => string - getFolder?: (uidOrName: string, options?: GetFolderOptions) => Promise - mkdir?: (path: string, options?: MkdirOptions) => Promise<{ folderUid: string; success: boolean; message?: string }> - renameFolder?: (folderPath: string, newName: string) => Promise - rmdir?: (patterns: string[], options?: { force?: boolean }) => Promise -} - -/** Host adapter (browser shell, Node script, tests). `readTextFile` is optional. */ -export type KeeperCliHost = { - getVault(): KeeperCliVault - envString(name: string): string | undefined - formatError(context: string, err: unknown): string - readTextFile?: (path: string) => Promise - getAccountUsername?: () => Promise -} - -export type CliHelpDoc = { - title: string - synopsis?: string - description?: string - arguments?: string - options?: string - environment?: string - examples?: string - seeAlso?: string - note?: string -} - -export type CliCommandDefinition = { - name: string - order?: number - description: string - usage: string - aliases?: readonly string[] - subcommands?: readonly string[] - flagOptions?: readonly string[] - /** When set, options outside this set are rejected (`--help` / `-h` always allowed). */ - allowedOptions?: ReadonlySet - help: CliHelpDoc - run: (host: KeeperCliHost, parsed: ParsedCli) => Promise -} diff --git a/KeeperSdk/src/cli/utils.ts b/KeeperSdk/src/cli/utils.ts deleted file mode 100644 index 5db5dae4..00000000 --- a/KeeperSdk/src/cli/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function utf8ToBase64Url(s: string): string { - const bytes = new TextEncoder().encode(s) - let b64: string - if (typeof Buffer !== 'undefined') { - b64 = Buffer.from(bytes).toString('base64') - } else { - let bin = '' - for (let i = 0; i < bytes.length; i++) { - bin += String.fromCharCode(bytes[i]!) - } - b64 = globalThis.btoa(bin) - } - return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - -export function recordUid(rec: { uid?: string }): string { - return rec.uid || '(unknown uid)' -} diff --git a/KeeperSdk/src/cli/vaultSurface.ts b/KeeperSdk/src/cli/vaultSurface.ts deleted file mode 100644 index cb3e43f8..00000000 --- a/KeeperSdk/src/cli/vaultSurface.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** Shared footer for vault-related command help (SDK APIs not yet exposed as CLI). */ -export const KEEPER_VAULT_SURFACE = ` -KeeperVault (JavaScript SDK) — operations available in code (not all exposed as CLI yet): - - Session: login, loginWithSessionToken, logout, resumeSession, sync, disconnect - Records: getRecords, findRecord, findRecords, getRecordByUid, getRecordsByType, - addRecord, updateRecord, deleteRecord, moveRecord, getRecordHistory, - printRecords - Sharing: shareRecord, removeRecordShare, getRecordShareInfo - Folders: listFolder, changeDirectory, getFolder, mkdir, addFolder, updateFolder, - renameFolder, deleteFolder, rmdir, tree, getCurrentFolderUid - Shared folders: getSharedFolders, listSharedFolders, shareFolder, … - Metadata: getRecordMetadata, getSummary, … - -Utilities exported from @keeper-security/keeper-sdk-javascript include searchRecords, -formatRecord, getRecordTitle, getRecordPassword, getRecordLogin, shareRecord, … -See the SDK package for full APIs. -`.trim() diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 26917b28..99be2829 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -20,7 +20,6 @@ export { export { InMemoryStorage } from "./storage/InMemoryStorage"; export { - Logger, ConsoleLogger, LogLevel, logger, From 2440ab4921b384b6767db26c0efbe7028af83c93 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Fri, 10 Jul 2026 15:06:56 +0530 Subject: [PATCH 39/48] formatting changes after running formatting. --- .../EnterpriseReportManager.ts | 59 +- .../src/enterpriseReport/actionReport.ts | 985 ++++++----- KeeperSdk/src/enterpriseReport/auditReport.ts | 1472 +++++++++-------- KeeperSdk/src/enterpriseReport/index.ts | 99 +- .../src/enterpriseReport/passwordReport.ts | 861 +++++----- KeeperSdk/src/enterpriseReport/reportTypes.ts | 690 ++++---- KeeperSdk/src/enterpriseReport/reportUtils.ts | 48 +- .../NestedShareFolderManager.ts | 12 +- .../src/nestedShareFolders/addNsfRecord.ts | 367 ++-- KeeperSdk/src/nestedShareFolders/getNsf.ts | 1007 +++++------ .../nestedShareFolders/getNsfRecordDetails.ts | 204 ++- KeeperSdk/src/nestedShareFolders/index.ts | 259 +-- .../src/nestedShareFolders/linkNsfRecord.ts | 261 +-- KeeperSdk/src/nestedShareFolders/listNsf.ts | 268 +-- KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 416 ++--- .../src/nestedShareFolders/nsfConstants.ts | 179 +- .../src/nestedShareFolders/nsfHelpers.ts | 1367 ++++++++------- .../src/nestedShareFolders/nsfRecordCrypto.ts | 201 ++- .../src/nestedShareFolders/nsfRecordData.ts | 647 ++++---- .../src/nestedShareFolders/nsfRecordTypes.ts | 147 +- KeeperSdk/src/nestedShareFolders/nsfTypes.ts | 579 +++---- .../src/nestedShareFolders/removeNsfFolder.ts | 415 ++--- .../src/nestedShareFolders/removeNsfRecord.ts | 505 +++--- .../src/nestedShareFolders/updateNsfRecord.ts | 365 ++-- KeeperSdk/src/utils/Logger.ts | 5 +- KeeperSdk/src/utils/constants.ts | 3 +- 26 files changed, 6208 insertions(+), 5213 deletions(-) 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/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index b9070ed8..d2a5e7f9 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -8,9 +8,9 @@ import { renderListNsfAsciiTable, } from "./listNsf"; import { - formatNsfDetail, - formatNsfFolderDetail, - formatNsfRecordDetail, + formatNsfDetail as renderNsfDetail, + formatNsfFolderDetail as renderNsfFolderDetail, + formatNsfRecordDetail as renderNsfRecordDetail, getNestedShareFolder, } from "./getNsf"; import { linkNestedShareRecord } from "./linkNsfRecord"; @@ -115,15 +115,15 @@ export class NestedShareFolderManager { } public formatNsfDetail(result: GetNsfResult, verbose = false): string { - return formatNsfDetail(result, verbose); + return renderNsfDetail(result, verbose); } public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { - return formatNsfFolderDetail(view, verbose); + return renderNsfFolderDetail(view, verbose); } public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { - return formatNsfRecordDetail(view, verbose); + return renderNsfRecordDetail(view, verbose); } public async linkNestedShareRecord( 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..3af627c3 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -1,568 +1,651 @@ -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, + 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 NsfFolderPermission, + type NsfFolderView, + type NsfRecordFieldView, + type NsfRecordFolderView, + type NsfRecordJsonUserPermission, + type NsfRecordJsonView, + type NsfRecordPermission, + type NsfRecordView, +} from "./nsfTypes"; 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 uid = folderUids[0] - return { - uid, - path: buildFolderPath(storage, uid).replace(/^\//, ''), - } + const folderUids = findNestedShareFoldersForRecord(storage, recordUid); + if (folderUids.length === 0) return undefined; + + const uid = folderUids[0]; + return { + uid, + path: buildFolderPath(storage, uid).replace(/^\//, ""), + }; } -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 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 (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 (lines.length > 0) return lines + 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; + } } - - 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)] + 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 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 - } - } - return part - }) + return value.map((part) => { + if ((type === "host" || type === "address") && part.startsWith("{")) { + try { + return JSON.parse(part) as Record; + } catch { + return part; + } + } + 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, - } + 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): string { - return JSON.stringify(toNsfRecordJsonView(view), null, 2) + return JSON.stringify(toNsfRecordJsonView(view), 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 + 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; } 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), - } - }) + 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 [] - } +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 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: 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), + })), + }; } 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), - '', - 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}`), - ] - - if (!verbose) return lines.join('\n') - - lines.push('', 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})`) - } +export function formatNsfFolderDetail( + view: NsfFolderView, + verbose = false, +): string { + const lines = [ + folderDetailRow("Nested Share Folder UID", view.folderUid), + folderDetailRow("Name", view.name), + "", + 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}`), + ]; + + if (!verbose) return lines.join("\n"); + + lines.push( + "", + 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})`); } - if (view.teamPermissions.length > 0) { - lines.push('', 'Team Permissions:') - for (const entry of view.teamPermissions) { - lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`) - } + } + if (view.teamPermissions.length > 0) { + lines.push("", "Team Permissions:"); + for (const entry of view.teamPermissions) { + lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`); } - return lines.join('\n') + } + 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), - ] - - 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)) +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) { + lines.push("", NSF_USER_PERMISSIONS_HEADING); + for (const entry of view.userPermissions) { + lines.push("", ...formatRecordUserPermissionBlock(entry)); } - - 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) + if (result.kind === NsfObjectKind.Record) { + return formatNsfRecordJson(result.view); + } + return JSON.stringify(result.view, null, 2); } 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..228e2f18 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -1,151 +1,154 @@ 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' + 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' + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, +} from "./listNsf"; export { - resolveNsfFolder, - resolveNsfRecord, - getNestedShareFolder, - formatNsfFolderDetail, - formatNsfRecordDetail, - formatNsfDetail, - formatNsfRecordJson, - formatNsfJson, - toNsfRecordJsonView, -} from './getNsf' + 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' + ListNsfFormat, + GetNsfFormat, + NsfAccessRoleLabel, + NsfObjectKind, + NsfRemoveOperation, + NsfRemoveFolderOperation, + GetNsfRecordDetailsFormat, + NSF_ACCESS_ROLE_LABELS, + resolveRecordPermissionRole, + toNsfAccessRoleLabel, +} from "./nsfTypes"; 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' + 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 { linkNestedShareRecord } from "./linkNsfRecord"; export { - removeNestedShareRecords, - formatRemoveNsfPreview, - collectRemoveNsfWarnings, -} from './removeNsfRecord' + 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 { mkdirNestedShareFolder } from "./mkdirNsf"; +export { NSF_FOLDER_COLORS, NSF_MAX_RECORD_BATCH } from "./nsfConstants"; +export type { NsfFolderColor } from "./nsfConstants"; export { - removeNestedShareFolders, - formatRemoveNsfFolderPreview, -} from './removeNsfFolder' + removeNestedShareFolders, + formatRemoveNsfFolderPreview, +} from "./removeNsfFolder"; export { - getNestedShareRecordDetails, - formatNsfRecordDetailsTable, - formatNsfRecordDetailsOutput, -} from './getNsfRecordDetails' + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, +} from "./getNsfRecordDetails"; -export { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' +export { + updateNestedShareRecords, + updateNestedShareRecord, +} from "./updateNsfRecord"; -export { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' +export { addNestedShareRecord, addNestedShareRecords } from "./addNsfRecord"; -export { clearNsfRecordTypeCache } from './nsfRecordTypes' +export { clearNsfRecordTypeCache } from "./nsfRecordTypes"; export { - buildNsfRecordData, - parseNsfFieldInput, - parseNsfFieldSpaceInput, - resolveNsfFieldValue, - type ParsedNsfFields, - type RecordFieldEntry, -} from './nsfRecordData' + buildNsfRecordData, + parseNsfFieldInput, + parseNsfFieldSpaceInput, + resolveNsfFieldValue, + type ParsedNsfFields, + type RecordFieldEntry, +} from "./nsfRecordData"; -export { NestedShareFolderManager } from './NestedShareFolderManager' +export { NestedShareFolderManager } from "./NestedShareFolderManager"; diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts index e277ffad..fb3069ad 100644 --- a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -1,139 +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' + 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 -} + 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 e4ff8d2c..485bacb1 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -1,173 +1,185 @@ -import type { InMemoryStorage } from '../storage/InMemoryStorage' +import type { InMemoryStorage } from "../storage/InMemoryStorage"; import { - NsfItemType, - findRecordFolderLocation, - getKeeperDriveFolders, - getKeeperDriveRecords, - getRecordDescription, - normalizeParentUid, -} from './nsfHelpers' -import { getRecordTitle, getRecordType } from '../records/RecordUtils' + NsfItemType, + findRecordFolderLocation, + getKeeperDriveFolders, + getKeeperDriveRecords, + getRecordDescription, + normalizeParentUid, +} 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' + NSF_LIST_DEFAULT_COLUMN_WIDTH, + NSF_LIST_FULL_HEADERS, + NSF_LIST_MIN_TRUNCATE_PREFIX, + NSF_LIST_TABLE_HEADERS, +} from "./nsfConstants"; 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; +}; 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[][]; +}; 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' }) + 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: normalizeParentUid(storage, folder.parentUid), - })) + return getKeeperDriveFolders(storage).map((folder) => ({ + itemType: NsfItemType.Folder, + uid: folder.uid, + title: folder.data.name || "Unnamed", + type: "", + description: "", + parentOrFolder: normalizeParentUid(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: 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: findRecordFolderLocation(storage, record.uid) || "root", + })); } -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 rows: ListNsfRow[] = []; + if (showFolders) rows.push(...collectFolderRows(storage)); + if (showRecords) rows.push(...collectRecordRows(storage)); + 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') + 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((row) => ({ - item_type: row.itemType, - uid: row.uid, - title: row.title, - type: row.type, - description: row.description, - parent_or_folder: row.parentOrFolder, - })), - 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_or_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..2190611b 100644 --- a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -1,224 +1,252 @@ -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 -} - -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 + 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: + 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, + ); + } } diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index b8d09c45..d50b7c9d 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -1,87 +1,128 @@ -import { Folder } from '@keeper-security/keeperapi' +import { Folder } from "@keeper-security/keeperapi"; 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_SHARE_ADMINS_HEADING = "Share Administrators:"; +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 + "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 +export const NSF_MAX_RECORD_BATCH = 500; +export const NSF_MAX_FOLDER_REMOVALS = 100; -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_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"]), +}; diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 7e42605d..57fb2963 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,804 +1,959 @@ import type { - Auth, - DRecord, - DUser, - DKdFolder, - DKdFolderAccess, - DKdFolderRecord, - DKdRecordAccess, -} from '@keeper-security/keeperapi' + Auth, + DRecord, + 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, + 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"; 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_SENSITIVE_FIELD_TYPES, +} from "./nsfConstants"; +import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS } from "./nsfTypes"; 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', - } -} - -export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string): boolean { - return !!getKeeperDriveRecord(storage, recordUid) + 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); } 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 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 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 isNestedShareFolder( + storage: InMemoryStorage, + folderUid: string, +): boolean { + if (isRootFolderUid(storage, folderUid)) return true; + return !!getKeeperDriveFolder(storage, folderUid); } -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 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, + ); } 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 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; } -function resolveFolderByPath(storage: InMemoryStorage, identifier: string): string | undefined { - const trimmed = identifier.trim().replace(/^\/+/, '') - if (!trimmed) return resolveKeeperDriveRootParentUid(storage) ?? '' +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 - } + 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 +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 + 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) + 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 ) -} - -export function findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { - return storage - .getAll(KeeperDriveKind.FolderRecord) - .filter((entry) => entry.recordUid === recordUid) - .map((entry) => entry.folderUid) + 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); } 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 - )) - ) + 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, + )) + ); } 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 - )) - ) -} - -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 + 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; } -type FolderPermissionFlag = 'canRemove' | 'canDelete' +type FolderPermissionFlag = "canRemove" | "canDelete"; 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; + } + return false; } -function getRecordAccessEntries(storage: InMemoryStorage, recordUid: string): DKdRecordAccess[] { - return storage - .getAll(KeeperDriveKind.RecordAccess) - .filter((entry) => entry.recordUid === recordUid) +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 + 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.canDelete) return true; + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, "canDelete") + ) { + return true; } + return false; + } - return ( - !!folderUid && - !isRootFolderUid(storage, folderUid) && - hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') - ) + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, "canDelete") + ); } 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 findRecordFolderLocation(storage: InMemoryStorage, recordUid: string): string { - const folderUids = findNestedShareFoldersForRecord(storage, recordUid) - if (folderUids.length === 0) return 'root' - return getFolderDisplayName(storage, folderUids[0]) +export function getFolderDisplayName( + storage: InMemoryStorage, + folderUid: string, +): string { + if (isRootFolderUid(storage, folderUid)) return "root"; + return getKeeperDriveFolder(storage, folderUid)?.data.name ?? folderUid; } -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 findRecordFolderLocation( + storage: InMemoryStorage, + recordUid: string, +): string { + const folderUids = findNestedShareFoldersForRecord(storage, recordUid); + if (folderUids.length === 0) return "root"; + return getFolderDisplayName(storage, folderUids[0]); } -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 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 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 ""; - if (typeof data.notes === 'string' && data.notes.trim()) { - return data.notes.trim().slice(0, NSF_RECORD_DESCRIPTION_MAX_LENGTH) + 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); } - 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 - - for (const user of storage.getAll('user')) { - if (webSafe64FromBytes(user.accountUid) === accessTypeUid) { - return user.username + const fromShare = shareUsers?.get(accessTypeUid); + if (fromShare) return fromShare; + + 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; +} + +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); + } + } + + 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); } + } } - if (folder?.ownerInfo?.accountUid === accessTypeUid && folder.ownerInfo.username) { - return folder.ownerInfo.username - } - return accessTypeUid -} + } catch {} -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) - } - } - - 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 { - } - - 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 - ) - } + 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 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 - ) + 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 - ) + return ( + entry.accessType === Folder.AccessType.AT_USER || + entry.accessType === Folder.AccessType.AT_OWNER + ); } 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/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/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts index 9a6383d4..bba3a2f7 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -1,395 +1,410 @@ -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; +}; 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 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; +}; 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 -} - -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 + 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; } 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 91cc69ca..3c9210f1 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -1,265 +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 { + 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] +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', + 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; +}; -const OPERATION_MAP: Record = { - [NsfRemoveOperation.Unlink]: RecordOperationType.UNLINK_FROM_FOLDER, - [NsfRemoveOperation.FolderTrash]: RecordOperationType.MOVE_TO_FOLDER_TRASH, - [NsfRemoveOperation.OwnerTrash]: RecordOperationType.MOVE_TO_OWNER_TRASH, -} +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 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 - ) - } - - const folderUid = folderIdentifier ? resolveNsfFolderIdentifier(storage, folderIdentifier) : undefined - if (folderIdentifier && !folderUid) { - throw new KeeperSdkError(`Folder '${folderIdentifier}' not found`, ResultCodes.NSF_NOT_FOUND) - } + 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 accountUid = auth.accountUid - if (!accountUid?.length) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } + const folderUid = folderIdentifier + ? resolveNsfFolderIdentifier(storage, folderIdentifier) + : undefined; + if (folderIdentifier && !folderUid) { + throw new KeeperSdkError( + `Folder '${folderIdentifier}' not found`, + ResultCodes.NSF_NOT_FOUND, + ); + } - 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) + const accountUid = auth.accountUid; + if (!accountUid?.length) { + throw new KeeperSdkError( + "Not logged in. Call login() first.", + ResultCodes.NOT_LOGGED_IN, + ); + } - 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}`) - 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}`) - } - } - 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}`); + } + } + if (item.error?.message) { + lines.push(` Error: ${item.error.message}`); } - return lines.join('\n').trimEnd() + 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) + 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) + try { + const previewResponse = await executeRemove( + auth, + removals, + RemoveAction.REMOVE_ACTION_PREVIEW, + ); + const preview = (previewResponse.results ?? []).map(mapPreviewItem); - if (hasPreviewErrors(preview)) { - throw new KeeperSdkError(formatRemoveNsfPreview(preview) || 'Removal preview failed.', ResultCodes.NSF_REMOVE_FAILED) - } - - 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/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/utils/Logger.ts b/KeeperSdk/src/utils/Logger.ts index 2ba99a99..e0f810a4 100644 --- a/KeeperSdk/src/utils/Logger.ts +++ b/KeeperSdk/src/utils/Logger.ts @@ -17,7 +17,10 @@ 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 { diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index d73e5410..e15de3c9 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -231,7 +231,8 @@ export const ResultCodes = { 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_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, From 24d582dbb4967bf92e5cfb248c988af01c092e38 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Tue, 14 Jul 2026 12:13:08 +0530 Subject: [PATCH 40/48] added parsing option for restore session. --- KeeperSdk/src/auth/sessionRestore.ts | 36 ++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/KeeperSdk/src/auth/sessionRestore.ts b/KeeperSdk/src/auth/sessionRestore.ts index beff79bb..1d5e902e 100644 --- a/KeeperSdk/src/auth/sessionRestore.ts +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -195,22 +195,38 @@ function looksLikeFilePath(text: string): boolean { return false; } -/** - * Parse session JSON. One JSON.parse when the payload is an object; two when the CLI - * value is a JSON-encoded string (e.g. JSON.stringify(conf) wrapped in quotes). - */ -export function sessionRestoreFromJson(json: string): SessionRestoreInput { - const text = json.trim().replace(/^\uFEFF/, ""); - let parsed: unknown; +function unwrapShellQuotedJson(text: string): string | null { + if (text.length < 2) return null; + const q = text[0]; + if ((q !== '"' && q !== "'") || text[text.length - 1] !== q) return null; + const inner = text.slice(1, -1).trim(); + if (!inner.startsWith("{") && !inner.startsWith("[")) return null; + return inner; +} + +function parseJsonPayload(text: string): unknown { try { - parsed = JSON.parse(text); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + return JSON.parse(text); + } catch (first) { + const unwrapped = unwrapShellQuotedJson(text); + if (unwrapped !== null) { + try { + return JSON.parse(unwrapped); + } catch { + /* fall through to original error */ + } + } + const msg = first instanceof Error ? first.message : String(first); throw new KeeperSdkError( `restore-session: invalid JSON: ${msg}`, ResultCodes.INVALID_CREDENTIALS, ); } +} + +export function sessionRestoreFromJson(json: string): SessionRestoreInput { + const text = json.trim().replace(/^\uFEFF/, ""); + let parsed: unknown = parseJsonPayload(text); if (typeof parsed === "string") { try { parsed = JSON.parse(parsed); From bfef44f1d79101ff1242d3b991d580c7f8d539b5 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 15 Jul 2026 11:52:28 +0530 Subject: [PATCH 41/48] updated keeper api version --- KeeperSdk/package.json | 2 +- keeperapi/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 9beabba1..31f629c6 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -24,7 +24,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "18.0.4", + "@keeper-security/keeperapi": "18.0.5", "ts-node": "^10.7.0", "asmcrypto.js": "^2.3.2", "typescript": "^4.6.3" diff --git a/keeperapi/package.json b/keeperapi/package.json index 0d4dbf4a..687676a3 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.4", + "version": "18.0.5", "browser": "dist/browser/index.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", From 52d0869dfd08e71ff3ea0dc9660fe3febc5d139e Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 15 Jul 2026 14:46:20 +0530 Subject: [PATCH 42/48] updated version of keeperapi --- KeeperSdk/package-lock.json | 10 ++++------ KeeperSdk/package.json | 2 +- .../src/nestedShareFolders/nsfRecordPermission.ts | 4 ++-- KeeperSdk/src/nestedShareFolders/nsfShare.ts | 4 ++-- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index e3112485..36cc68b7 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "18.0.4", + "@keeper-security/keeperapi": "18.0.5", "asmcrypto.js": "^2.3.2", "ts-node": "^10.7.0", "typescript": "^4.6.3" @@ -57,9 +57,9 @@ } }, "node_modules/@keeper-security/keeperapi": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.4.tgz", - "integrity": "sha512-/tQKZUBkOi8ne/nrSUF9G4jMtqgl1QFbgQ4NqjoG9XwyYSgHtJ4QPRVmH+pIinIZ7tuOLt7GdU6wbH7e1Odeyg==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.5.tgz", + "integrity": "sha512-13P0mDMgaeUsbsQxlUqV1SMX0X9VAQ5aGa2antZDJWifqNqkZbTQEX/5evuZQN/tsTBn30/2DANpsCQyB1uQmw==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -144,7 +144,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -574,7 +573,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 31f629c6..5ef7114e 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist node_modules", - "rebuild": "npm run clean && npm install && npm run build", + "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 .", diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts index 173a622a..b1ed9a6a 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts @@ -4,7 +4,7 @@ import { normal64Bytes, platform, record, - recordAccessDetailsMessage, + getRecordAccessMessage, recordsShareV3Message, webSafe64FromBytes, } from '@keeper-security/keeperapi' @@ -243,7 +243,7 @@ async function getRecordAccessesV3( } const response = await auth.executeRest( - recordAccessDetailsMessage({ + getRecordAccessMessage({ recordUids: recordUids.map((uid) => normal64Bytes(uid)), }) ) diff --git a/KeeperSdk/src/nestedShareFolders/nsfShare.ts b/KeeperSdk/src/nestedShareFolders/nsfShare.ts index befeb836..9321cf6e 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfShare.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfShare.ts @@ -3,7 +3,7 @@ import { Folder, getShareObjectsMessage, normal64Bytes, - recordAccessDetailsMessage, + getRecordAccessMessage, recordsShareV3Message, folderAccessUpdateMessage, webSafe64FromBytes, @@ -554,7 +554,7 @@ async function findDirectUserShare( email: string ): Promise { const response = await auth.executeRest( - recordAccessDetailsMessage({ + getRecordAccessMessage({ recordUids: [normal64Bytes(recordUid)], }) ) From 45f6255138250758d78764eafad1edd7e3fb2402 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 15 Jul 2026 17:27:01 +0530 Subject: [PATCH 43/48] updated restore-sesion json parsing --- KeeperSdk/src/auth/sessionRestore.ts | 34 ++++++++++++++++++---------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/KeeperSdk/src/auth/sessionRestore.ts b/KeeperSdk/src/auth/sessionRestore.ts index 1d5e902e..80febf4d 100644 --- a/KeeperSdk/src/auth/sessionRestore.ts +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -179,9 +179,24 @@ function assertBodyIsJsonNotHtml(body: string, source: string): void { } } +function unwrapShellQuotedJson(text: string): string | null { + if (text.length < 2) return null; + const q = text[0]; + if ((q !== '"' && q !== "'") || text[text.length - 1] !== q) return null; + const inner = text.slice(1, -1).trim(); + if (!inner.startsWith("{") && !inner.startsWith("[")) return null; + return inner; +} + function looksLikeInlineJson(text: string): boolean { const t = text.trimStart(); - return t.startsWith("{") || t.startsWith("[") || t.startsWith('"'); + // Object / array / JSON-encoded string, or shell-quoted paste: '{"a":1}' + return ( + t.startsWith("{") || + t.startsWith("[") || + t.startsWith('"') || + unwrapShellQuotedJson(t) !== null + ); } /** File path / URL — not inline JSON (avoid JSON.parse on `/path/to/conf.json`). */ @@ -195,15 +210,6 @@ function looksLikeFilePath(text: string): boolean { return false; } -function unwrapShellQuotedJson(text: string): string | null { - if (text.length < 2) return null; - const q = text[0]; - if ((q !== '"' && q !== "'") || text[text.length - 1] !== q) return null; - const inner = text.slice(1, -1).trim(); - if (!inner.startsWith("{") && !inner.startsWith("[")) return null; - return inner; -} - function parseJsonPayload(text: string): unknown { try { return JSON.parse(text); @@ -212,8 +218,12 @@ function parseJsonPayload(text: string): unknown { if (unwrapped !== null) { try { return JSON.parse(unwrapped); - } catch { - /* fall through to original error */ + } catch (second) { + const msg = second instanceof Error ? second.message : String(second); + throw new KeeperSdkError( + `restore-session: invalid JSON: ${msg}`, + ResultCodes.INVALID_CREDENTIALS, + ); } } const msg = first instanceof Error ? first.message : String(first); From 798b4590d3a45f244bd831a52a2f5327f7141cdb Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Thu, 16 Jul 2026 20:20:31 +0530 Subject: [PATCH 44/48] added support of nsf to list command --- KeeperSdk/src/index.ts | 3 + KeeperSdk/src/nestedShareFolders/getNsf.ts | 204 ++++++++++++++---- KeeperSdk/src/nestedShareFolders/index.ts | 9 + KeeperSdk/src/nestedShareFolders/listNsf.ts | 61 +++--- KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 5 +- .../src/nestedShareFolders/nsfHelpers.ts | 72 ++++++- .../src/nestedShareFolders/nsfShortcut.ts | 42 +++- KeeperSdk/src/nestedShareFolders/nsfTypes.ts | 18 +- keeperapi/package-lock.json | 4 +- 9 files changed, 337 insertions(+), 81 deletions(-) diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index f407bb90..a7963b13 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -588,7 +588,10 @@ export { formatNsfDetail, formatNsfJson, formatNsfRecordJson, + formatNsfFolderJson, toNsfRecordJsonView, + toNsfFolderJsonView, + NSF_UNMASK_WARNING, linkNestedShareRecord, NsfRemoveOperation, NsfRemoveFolderOperation, diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts index 3af627c3..ba9b4da7 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -25,6 +25,7 @@ import { KeeperSdkError, ResultCodes } from "../utils"; import { buildFolderPath, collectRecordsInFolder, + displayNsfParentUid, fetchLiveRecordAccessEntries, getFolderAccessEntries, findNestedShareFoldersForRecord, @@ -63,6 +64,7 @@ import { type GetNsfOptions, type GetNsfResult, type NsfFolderAccessRow, + type NsfFolderJsonView, type NsfFolderPermission, type NsfFolderView, type NsfRecordFieldView, @@ -73,6 +75,24 @@ import { 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 !== "") @@ -292,8 +312,14 @@ function formatRecordFieldDetailLines(field: NsfRecordFieldView): string[] { ]; } -function jsonFieldValues(type: string, value: string[]): unknown[] { +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; @@ -305,18 +331,31 @@ function jsonFieldValues(type: string, value: string[]): unknown[] { }); } -export function toNsfRecordJsonView(view: NsfRecordView): NsfRecordJsonView { +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, - version: view.version, - revision: view.revision, - ...(view.folder ? { folder: view.folder } : {}), - fields: view.fields.map(({ type, value }) => ({ - type, - value: jsonFieldValues(type, value), - })), + ...(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, @@ -329,11 +368,47 @@ export function toNsfRecordJsonView(view: NsfRecordView): NsfRecordJsonView { }; } -export function formatNsfRecordJson(view: NsfRecordView): string { - return JSON.stringify(toNsfRecordJsonView(view), null, 2); +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, + } + : {}), + }; +} + +export function formatNsfRecordJson( + view: NsfRecordView, + options: { includeDag?: boolean; unmask?: boolean } = {}, +): string { + return JSON.stringify(toNsfRecordJsonView(view, options), null, 2); +} + +export function formatNsfFolderJson( + view: NsfFolderView, + options: { includeDag?: boolean } = {}, +): string { + return JSON.stringify(toNsfFolderJsonView(view, options), null, 2); } -function formatRecordUserPermissionBlock(entry: NsfRecordPermission): string[] { +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}`); @@ -344,6 +419,38 @@ function formatRecordUserPermissionBlock(entry: NsfRecordPermission): string[] { 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, @@ -423,7 +530,7 @@ async function buildFolderView( objectType: NsfObjectKind.Folder, folderUid, name: folder.data.name || "Unnamed", - parentUid: normalizeParentUid(storage, folder.parentUid), + parentUid: displayNsfParentUid(storage, folder.parentUid), path: buildFolderPath(storage, folderUid), userPermissions, shareAdmins, @@ -550,33 +657,45 @@ export function formatNsfFolderDetail( const lines = [ folderDetailRow("Nested Share Folder UID", view.folderUid), folderDetailRow("Name", view.name), + ]; + + if (verbose) { + lines.push( + ...formatFolderAccessTable( + NSF_USER_PERMISSIONS_HEADING, + view.userPermissions, + ), + ...formatFolderAccessTable( + NSF_FOLDER_SHARE_ADMINS_HEADING, + 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})`); + } + } + if (view.teamPermissions.length > 0) { + lines.push("", "Team Permissions:"); + for (const entry of view.teamPermissions) { + lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`); + } + } + return lines.join("\n"); + } + + 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}`), - ]; - - if (!verbose) return lines.join("\n"); - - lines.push( - "", - 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})`); - } - } - if (view.teamPermissions.length > 0) { - lines.push("", "Team Permissions:"); - for (const entry of view.teamPermissions) { - lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`); - } - } return lines.join("\n"); } @@ -600,9 +719,13 @@ export function formatNsfRecordDetail( } if (view.userPermissions.length > 0) { - lines.push("", NSF_USER_PERMISSIONS_HEADING); - for (const entry of view.userPermissions) { - lines.push("", ...formatRecordUserPermissionBlock(entry)); + 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)); + } } } @@ -643,9 +766,12 @@ export function formatNsfDetail(result: GetNsfResult, verbose = false): string { : formatNsfRecordDetail(result.view, verbose); } -export function formatNsfJson(result: GetNsfResult): string { +export function formatNsfJson( + result: GetNsfResult, + options: { includeDag?: boolean; unmask?: boolean } = {}, +): string { if (result.kind === NsfObjectKind.Record) { - return formatNsfRecordJson(result.view); + return formatNsfRecordJson(result.view, options); } - return JSON.stringify(result.view, null, 2); + return formatNsfFolderJson(result.view, options); } diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index 078e57f9..2b5766bb 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -5,6 +5,10 @@ export { formatAccessRoleType, formatAccessType, normalizeParentUid, + displayNsfParentUid, + findRecordFolderParentUid, + nsfFolderHasPamUserWithRotation, + nsfRecordIsRoeEligible, isRootFolderUid, resolveKeeperDriveRootParentUid, getKeeperDriveFolders, @@ -56,8 +60,11 @@ export { formatNsfRecordDetail, formatNsfDetail, formatNsfRecordJson, + formatNsfFolderJson, formatNsfJson, toNsfRecordJsonView, + toNsfFolderJsonView, + NSF_UNMASK_WARNING, } from "./getNsf"; export { @@ -86,6 +93,7 @@ export type { NsfRecordFieldView, NsfRecordFolderView, NsfRecordJsonView, + NsfFolderJsonView, NsfRecordJsonUserPermission, NsfFolderPermission, NsfFolderAccessRow, @@ -171,6 +179,7 @@ export { } from "./nsfShortcut"; export type { NsfShortcutRow, + NsfShortcutFolderRef, ListNsfShortcutsOptions, KeepNsfShortcutInput, KeepNsfShortcutPlanItem, diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts index 485bacb1..455cd79d 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -1,11 +1,13 @@ import type { InMemoryStorage } from "../storage/InMemoryStorage"; import { NsfItemType, - findRecordFolderLocation, + displayNsfParentUid, + findRecordFolderParentUid, getKeeperDriveFolders, getKeeperDriveRecords, getRecordDescription, - normalizeParentUid, + nsfFolderHasPamUserWithRotation, + nsfRecordIsRoeEligible, } from "./nsfHelpers"; import { getRecordTitle, getRecordType } from "../records/RecordUtils"; import { @@ -14,6 +16,7 @@ import { NSF_LIST_MIN_TRUNCATE_PREFIX, NSF_LIST_TABLE_HEADERS, } from "./nsfConstants"; +import type { ListNsfFormatInput, ListNsfOptions, ListNsfRow } from "./nsfTypes"; export enum ListNsfFormat { Table = "table", @@ -21,22 +24,7 @@ export enum ListNsfFormat { JSON = "json", } -export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}`; - -export type ListNsfOptions = { - folders?: boolean; - records?: boolean; - format?: ListNsfFormatInput; -}; - -export type ListNsfRow = { - itemType: NsfItemType; - uid: string; - title: string; - type: string; - description: string; - parentOrFolder: string; -}; +export type { ListNsfFormatInput, ListNsfOptions, ListNsfRow }; export type FormattedListNsfTable = { headers: string[]; @@ -57,7 +45,7 @@ function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { title: folder.data.name || "Unnamed", type: "", description: "", - parentOrFolder: normalizeParentUid(storage, folder.parentUid), + parentOrFolder: displayNsfParentUid(storage, folder.parentUid), })); } @@ -68,7 +56,7 @@ function collectRecordRows(storage: InMemoryStorage): ListNsfRow[] { title: getRecordTitle(record), type: getRecordType(record), description: getRecordDescription(record), - parentOrFolder: findRecordFolderLocation(storage, record.uid) || "root", + parentOrFolder: findRecordFolderParentUid(storage, record.uid), })); } @@ -78,9 +66,26 @@ export function listNestedShareFolders( ): 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) rows.push(...collectFolderRows(storage)); - if (showRecords) rows.push(...collectRecordRows(storage)); + 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); } @@ -158,12 +163,12 @@ export function formatListNsfCsv(rows: ListNsfRow[]): string { export function formatListNsfJson(rows: ListNsfRow[]): string { return JSON.stringify( rows.map((row) => ({ - item_type: row.itemType, - uid: row.uid, - title: row.title, - type: row.type, - description: row.description, - parent_or_folder: row.parentOrFolder, + "Item Type": row.itemType, + UID: row.uid, + Title: row.title, + Type: row.type, + Description: row.description, + "Parent/Folder": row.parentOrFolder, })), null, 2, diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts index 9c4f6a02..8ba29b4f 100644 --- a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -239,10 +239,7 @@ export async function mkdirNestedShareFolder( return { folderUid: createdUid, created: true, - message: - segments.length > 1 - ? `Created folder path "${folderPath}".` - : `Created folder "${segments[lastIdx]}".`, + message: createdUid, }; } catch (err) { if (err instanceof KeeperSdkError) throw err; diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 22050c87..1c52c73b 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,6 +1,7 @@ import type { Auth, DRecord, + DRecordRotation, DUser, DKdFolder, DKdFolderAccess, @@ -18,7 +19,7 @@ import { import type { InMemoryStorage } from "../storage/InMemoryStorage"; import { VaultObjectKind } from "../folders/folderHelpers"; import { KeeperSdkError, ResultCodes, extractErrorMessage } from "../utils"; -import { getRecordTitle } from "../records/RecordUtils"; +import { getRecordTitle, getRecordType } from "../records/RecordUtils"; import { NSF_ACCESS_TYPE_LABELS, NSF_LEGACY_FOLDER_MSG, @@ -169,6 +170,75 @@ export function normalizeParentUid( : (parentUid ?? "").trim(); } +/** Parent UID for Commander-aligned list/get JSON (drive root UID, not the literal `root`). */ +export function displayNsfParentUid( + storage: InMemoryStorage, + parentUid: string | undefined | null, +): string { + if (isRootFolderUid(storage, parentUid)) { + return resolveKeeperDriveRootParentUid(storage) ?? ""; + } + return (parentUid ?? "").trim(); +} + +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 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); +} + export function isNestedShareFolder( storage: InMemoryStorage, folderUid: string, diff --git a/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts index a3e7fcce..aa6cff98 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts @@ -22,10 +22,15 @@ import type { DKdFolderRecord } from '@keeper-security/keeperapi' const SHORTCUT_ERROR = ResultCodes.NSF_SHORTCUT_FAILED const ROOT_FOLDER_LABEL = 'root' +export type NsfShortcutFolderRef = { + folderUid: string + name: string +} + export type NsfShortcutRow = { recordUid: string title: string - folders: string[] + folders: NsfShortcutFolderRef[] } export type ListNsfShortcutsOptions = { @@ -82,6 +87,7 @@ export function getNsfRecordShortcuts(storage: InMemoryStorage): Map() folderSet.add(folderUid) @@ -145,6 +151,20 @@ function resolveShortcutRecordUids( throw new KeeperSdkError(`Target '${trimmed}' not found`, ResultCodes.NSF_NOT_FOUND) } +function shortcutFolderRef( + storage: InMemoryStorage, + folderUid: string +): NsfShortcutFolderRef { + const normalized = normalizeParentUid(storage, folderUid) + if (isRootFolderUid(storage, normalized)) { + return { folderUid: normalized, name: ROOT_FOLDER_LABEL } + } + return { + folderUid: normalized, + name: getFolderDisplayName(storage, normalized), + } +} + function collectShortcutRows( storage: InMemoryStorage, shortcuts: Map>, @@ -155,6 +175,7 @@ function collectShortcutRows( : new Set(shortcuts.keys()) return [...recordUids] + .filter((recordUid) => !!getKeeperDriveRecord(storage, recordUid)) .sort((a, b) => recordTitle(storage, a).localeCompare(recordTitle(storage, b))) .map((recordUid) => ({ recordUid, @@ -163,7 +184,7 @@ function collectShortcutRows( .sort((a, b) => formatFolderLabel(storage, a).localeCompare(formatFolderLabel(storage, b)) ) - .map((folderUid) => formatFolderLabel(storage, folderUid)), + .map((folderUid) => shortcutFolderRef(storage, folderUid)), })) } @@ -175,7 +196,11 @@ function escapeCsvCell(value: string): string { 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 tableRows = rows.map((row) => [ + row.recordUid, + row.title, + row.folders.map((folder) => `${folder.name} (${folder.folderUid})`).join('; '), + ]) const columnWidths = headers.map((header, index) => Math.max(header.length, ...tableRows.map((cells) => (cells[index] || '').length)) ) @@ -189,7 +214,11 @@ 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('; ')] + [ + row.recordUid, + row.title, + row.folders.map((folder) => `${folder.name} (${folder.folderUid})`).join('; '), + ] .map(escapeCsvCell) .join(',') ) @@ -202,7 +231,10 @@ export function formatNsfShortcutJson(rows: NsfShortcutRow[]): string { rows.map((row) => ({ record_uid: row.recordUid, title: row.title, - folders: row.folders, + folders: row.folders.map((folder) => ({ + folder_uid: folder.folderUid, + name: folder.name, + })), })), null, 2 diff --git a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts index bba3a2f7..b6a3e82e 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -49,6 +49,7 @@ export type GetNsfOptions = { format?: GetNsfFormatInput; verbose?: boolean; unmask?: boolean; + includeDag?: boolean; }; export type NsfFolderAccessRow = { @@ -117,8 +118,8 @@ export type NsfRecordJsonView = { record_uid: string; title: string; type: string; - version: number; - revision: number; + version?: number; + revision?: number; folder?: NsfRecordFolderView; fields: { type: string; value: unknown[] }[]; notes?: string; @@ -126,6 +127,18 @@ export type NsfRecordJsonView = { 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; @@ -260,6 +273,7 @@ export type ListNsfOptions = { folders?: boolean; records?: boolean; format?: ListNsfFormatInput; + roeEligible?: boolean; }; export type ListNsfRow = { diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index 48920dce..3dcac4db 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.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@keeper-security/keeperapi", - "version": "18.0.3", + "version": "18.0.5", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", From da8cc640be4e270f3064a69d5b8173cb939812d1 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Thu, 16 Jul 2026 21:29:03 +0530 Subject: [PATCH 45/48] Added support for TeamGetKeys API (#208) --- keeperapi/package-lock.json | 4 ++-- keeperapi/package.json | 2 +- keeperapi/src/commands.ts | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) 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/commands.ts b/keeperapi/src/commands.ts index d8d97c80..6e566b34 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -379,6 +379,24 @@ export const teamEnterpriseUserRemoveCommand = ( request: TeamUserCommandRequest ): RestCommand => createCommand(request, 'team_enterprise_user_remove') +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 From bf503f4fda6fed0b8d4c1a9ecb5a53f98a566692 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Thu, 16 Jul 2026 22:11:52 +0530 Subject: [PATCH 46/48] NSF sharing, permissions , shortcut and transfer functionalities (#210) --- KeeperSdk/package-lock.json | 15 +- KeeperSdk/package.json | 4 +- KeeperSdk/src/index.ts | 74 ++ .../NestedShareFolderManager.ts | 58 ++ KeeperSdk/src/nestedShareFolders/index.ts | 100 ++- .../src/nestedShareFolders/nsfConstants.ts | 176 ++++ .../src/nestedShareFolders/nsfHelpers.ts | 567 +++++++++++- .../nestedShareFolders/nsfRecordPermission.ts | 713 +++++++++++++++ KeeperSdk/src/nestedShareFolders/nsfShare.ts | 815 ++++++++++++++++++ .../src/nestedShareFolders/nsfShareKeys.ts | 242 ++++++ .../src/nestedShareFolders/nsfShortcut.ts | 354 ++++++++ .../src/nestedShareFolders/nsfTeamShare.ts | 215 +++++ .../nestedShareFolders/nsfTransferRecord.ts | 130 +++ KeeperSdk/src/nestedShareFolders/nsfTypes.ts | 254 ++++++ .../src/nestedShareFolders/updateNsfFolder.ts | 160 ++++ KeeperSdk/src/utils/constants.ts | 8 + KeeperSdk/src/vault/KeeperVault.ts | 124 +++ 17 files changed, 3996 insertions(+), 13 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfShare.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfShortcut.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index c1ee8b4a..c4740a6a 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -1,15 +1,15 @@ { "name": "@keeper-security/keeper-sdk-javascript", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@keeper-security/keeper-sdk-javascript", - "version": "1.1.0", + "version": "1.1.1", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "17.2.7", + "@keeper-security/keeperapi": "^18.0.4", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, @@ -56,9 +56,9 @@ } }, "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==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.5.tgz", + "integrity": "sha512-13P0mDMgaeUsbsQxlUqV1SMX0X9VAQ5aGa2antZDJWifqNqkZbTQEX/5evuZQN/tsTBn30/2DANpsCQyB1uQmw==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -66,6 +66,9 @@ "faye-websocket": "^0.11.3", "form-data": "^4.0.4", "node-rsa": "^1.0.8" + }, + "engines": { + "node": ">=24.13.1" } }, "node_modules/@noble/curves": { diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index a6e66e17..755f8510 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -1,6 +1,6 @@ { "name": "@keeper-security/keeper-sdk-javascript", - "version": "1.1.0", + "version": "1.1.1", "description": "High-level wrapper for Keeper Security JavaScript SDK", "repository": { "type": "git", @@ -21,7 +21,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "17.2.7", + "@keeper-security/keeperapi": "^18.0.4", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index f6dc11de..17ee6f53 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -557,6 +557,50 @@ export { resolveNsfFieldValue, clearNsfRecordTypeCache, checkRecordEditPermission, + updateNestedShareFolder, + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, + parseShareExpiration, + parseShareExpirationValue, + validateShareExpirationTimestamp, + isShareExpirationNoop, + fetchNsfTeamPublicKeys, + encryptNsfFolderKeyForTeam, + resolveNsfShareRecipient, + MIN_SHARE_EXPIRATION_MS, + TeamGetKeysResponseKeyType, + getNsfRecordShortcuts, + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, + transferNestedShareRecords, + formatTransferNestedShareRecordResults, + NSF_RECORD_PERMISSION_ROLES, + NSFShareRoleName, + NsfShareCommandName, + NSF_SHARE_EXPIRATION_NEVER, + NsfFolderShareAction, + NsfFolderShareActionTaken, + NsfRecordShareAction, + NsfRecordShareActionTaken, + NsfRecordPermissionAction, + NsfResultStatus, + NsfTransferApiStatus, + NsfPermissionFailureCode, + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + getFolderPermissionsForRole, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -607,6 +651,36 @@ export type { FolderCreatePayload, ParsedNsfFields, RecordFieldEntry, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + NsfFolderShareActionInput, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, + NsfRecordPermissionRole, + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, + ParseShareExpirationInput, + NsfTeamPublicKeys, + NsfResolvedShareRecipient, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index c8e75fa3..f3c9d4b8 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -10,6 +10,11 @@ import { removeNestedShareFolders } from './removeNsfFolder' import { getNestedShareRecordDetails } from './getNsfRecordDetails' import { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' import { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' +import { shareNestedShareFolder, shareNestedShareRecord } from './nsfShare' +import { listNsfShortcuts, keepNsfShortcut } from './nsfShortcut' +import { transferNestedShareRecords } from './nsfTransferRecord' +import { updateNestedShareRecordPermissions } from './nsfRecordPermission' +import { updateNestedShareFolder } from './updateNsfFolder' import type { AddNsfRecordInput, AddNsfRecordResult, @@ -19,18 +24,32 @@ import type { GetNsfResult, GetNsfRecordDetailsInput, GetNsfRecordDetailsResult, + KeepNsfShortcutInput, + KeepNsfShortcutResult, LinkNsfRecordResult, ListNsfOptions, ListNsfRow, + ListNsfShortcutsOptions, MkdirNsfInput, MkdirNsfResult, + NsfShortcutRow, RemoveNsfFolderInput, RemoveNsfFolderResult, RemoveNsfRecordInput, RemoveNsfRecordResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + UpdateNsfFolderInput, + UpdateNsfFolderResult, UpdateNsfRecordInput, UpdateNsfRecordItemInput, UpdateNsfRecordsInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from './nsfTypes' @@ -104,4 +123,43 @@ export class NestedShareFolderManager { public async addNestedShareRecord(input: AddNsfRecordInput): Promise { return addNestedShareRecord(this.storage, this.requireAuth(), input) } + + public async updateNestedShareFolder(input: UpdateNsfFolderInput): Promise { + return updateNestedShareFolder(this.storage, this.requireAuth(), input) + } + + 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 listNsfShortcuts(options: ListNsfShortcutsOptions = {}): NsfShortcutRow[] { + return listNsfShortcuts(this.storage, options) + } + + public async keepNsfShortcut( + input: KeepNsfShortcutInput, + defaultFolderUid?: string + ): Promise { + return keepNsfShortcut(this.storage, this.requireAuth(), input, defaultFolderUid) + } + + public async transferNestedShareRecords( + input: TransferNestedShareRecordInput + ): Promise { + return transferNestedShareRecords(this.storage, this.requireAuth(), input) + } + + public async updateNestedShareRecordPermissions( + input: UpdateNsfRecordPermissionInput + ): Promise { + return updateNestedShareRecordPermissions(this.storage, this.requireAuth(), input) + } } diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index e4d87594..da1fd6e8 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -27,6 +27,13 @@ export { checkFolderDeletePermission, parseNsfPath, findExistingChildFolder, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + parseShareExpiration, + parseShareExpirationValue, + validateShareExpirationTimestamp, + isShareExpirationNoop, } from './nsfHelpers' export { @@ -58,6 +65,14 @@ export { NsfRemoveOperation, NsfRemoveFolderOperation, GetNsfRecordDetailsFormat, + NsfFolderShareAction, + NsfFolderShareActionTaken, + NsfRecordShareAction, + NsfRecordShareActionTaken, + NsfRecordPermissionAction, + NsfResultStatus, + NsfTransferApiStatus, + NsfPermissionFailureCode, NSF_ACCESS_ROLE_LABELS, resolveRecordPermissionRole, toNsfAccessRoleLabel, @@ -108,8 +123,45 @@ export type { 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, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + ParseShareExpirationInput, + NsfTeamPublicKeys, + NsfResolvedShareRecipient, } from './nsfTypes' +export { + fetchNsfTeamPublicKeys, + encryptNsfFolderKeyForTeam, + resolveNsfShareRecipient, +} from './nsfTeamShare' + +export type { UserShareKeys } from './nsfShareKeys' + export { linkNestedShareRecord } from './linkNsfRecord' export { @@ -119,7 +171,15 @@ export { } from './removeNsfRecord' export { mkdirNestedShareFolder } from './mkdirNsf' -export { NSF_FOLDER_COLORS, NSF_MAX_RECORD_BATCH } from './nsfConstants' +export { + NSF_FOLDER_COLORS, + NSF_MAX_RECORD_BATCH, + MIN_SHARE_EXPIRATION_MS, + NSF_SHARE_EXPIRATION_NEVER, + TeamGetKeysResponseKeyType, + NSFShareRoleName, + NsfShareCommandName, +} from './nsfConstants' export type { NsfFolderColor } from './nsfConstants' export { @@ -149,3 +209,41 @@ export { } 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' + +export { updateNestedShareFolder } from './updateNsfFolder' + +export { + NSF_RECORD_PERMISSION_ROLES, + getFolderPermissionsForRole, +} from './nsfConstants' +export type { NsfRecordPermissionRole } from './nsfConstants' diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index b8d09c45..24c6b2d4 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -1,4 +1,5 @@ import { Folder } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' export const NSF_LEGACY_RECORD_MSG = "Record '{0}' is a legacy vault record. Nested Share Folder commands operate only on Nested Share Records." @@ -85,3 +86,178 @@ export const NSF_STRUCTURED_SUBKEYS: Record> = { 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 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 = { + [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..abd3510a 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -10,9 +10,11 @@ import type { import { Folder, Records, + getFolderAccessMessage, getRecordAccessMessage, getShareObjectsMessage, normal64Bytes, + record, webSafe64FromBytes, } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' @@ -26,9 +28,19 @@ import { 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 } from './nsfTypes' +import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS, type ParseShareExpirationInput } from './nsfTypes' export enum KeeperDriveKind { Folder = 'keeper_drive_folder', @@ -425,12 +437,18 @@ function isCurrentUserRecordAccess( username: string, accountUidStr: string ): boolean { + if ( + entry.accessType !== Folder.AccessType.AT_USER && + entry.accessType !== Folder.AccessType.AT_OWNER + ) { + return false + } return ( - (entry.accessType === Folder.AccessType.AT_USER && entry.accessTypeUid === accountUidStr) || + entry.accessTypeUid === accountUidStr || (username.length > 0 && storage.getAll('user').some( (user) => - user.username === username && + user.username.toLowerCase() === username.toLowerCase() && webSafe64FromBytes(user.accountUid) === entry.accessTypeUid )) ) @@ -465,7 +483,13 @@ function isFolderOwnerAccount(storage: InMemoryStorage, folderUid: string, accou return folder?.ownerInfo?.accountUid === accountUidStr } -type FolderPermissionFlag = 'canRemove' | 'canDelete' +type FolderPermissionFlag = + | 'canRemove' + | 'canDelete' + | 'canUpdateAccess' + | 'canEditRecords' + | 'canUpdateSetting' + | 'canChangeOwnership' function hasFolderPermission( storage: InMemoryStorage, @@ -521,6 +545,102 @@ function canRecordBeDeleted( ) } +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 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.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 + } + + 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, @@ -802,3 +922,442 @@ export function isFolderUserPermission(entry: DKdFolderAccess): boolean { 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 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 { + response = await auth.executeRest( + getRecordAccessMessage({ recordUids: chunk.map((uid) => normal64Bytes(uid)) }) + ) + } catch (err) { + 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 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 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 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 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/nsfRecordPermission.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts new file mode 100644 index 00000000..1f6f3f7f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts @@ -0,0 +1,713 @@ +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/nsfShare.ts b/KeeperSdk/src/nestedShareFolders/nsfShare.ts new file mode 100644 index 00000000..8b0f7ec2 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShare.ts @@ -0,0 +1,815 @@ +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..40fa0fa7 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts @@ -0,0 +1,242 @@ +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[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..c285a6d9 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts @@ -0,0 +1,354 @@ +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..e084c9be --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts @@ -0,0 +1,215 @@ +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..86377642 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts @@ -0,0 +1,130 @@ +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..aa5d4f55 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -393,3 +393,257 @@ export function resolveRecordPermissionRole(entry: NsfRecordPermission): NsfAcce 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 + quiet?: boolean +} + +export type UpdateNsfFolderResult = { + folderUid: string + updated: boolean + message?: string +} diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts new file mode 100644 index 00000000..177620f2 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts @@ -0,0 +1,160 @@ +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, type NsfFolderColor } from './nsfConstants' +import { + checkFolderEditPermission, + ensureNestedShareFolder, + getFolderDisplayName, + getKeeperDriveFolder, + parseFolderModifyStatus, + patchNsfFolderMetadata, + requireAuthAccountUid, + resolveNsfFolderIdentifier, +} from './nsfHelpers' +import type { NsfFolderColorInput, UpdateNsfFolderInput, UpdateNsfFolderResult } from './nsfTypes' + +type NsfFolderMetadata = { + name: string + color?: string +} + +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 +} + +async function buildUpdateFolderData( + storage: InMemoryStorage, + folderUid: string, + metadata: NsfFolderMetadata +): 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 + ) + + return Folder.FolderData.create({ + folderUid: normal64Bytes(folderUid), + data: encryptedData, + }) +} + +function buildSuccessMessage( + folderDisplayName: string, + newName: string | undefined, + color: NsfFolderColor | 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.` + } + return `Folder "${folderDisplayName}" has been updated.` +} + +export async function updateNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfFolderInput +): Promise { + const folderArg = input.folder?.trim() + if (!folderArg) { + throw new KeeperSdkError('Enter the path or UID of existing folder.', ResultCodes.NSF_UPDATE_FAILED) + } + + let newName = input.name + if (newName !== undefined) { + newName = newName.trim() + if (!newName) { + throw new KeeperSdkError('Folder name cannot be empty', ResultCodes.NSF_UPDATE_FAILED) + } + } + + const color = input.color !== undefined ? normalizeColor(input.color) : undefined + if (newName === undefined && color === undefined) { + throw new KeeperSdkError( + 'New folder name and/or color parameters are required.', + ResultCodes.NSF_UPDATE_FAILED + ) + } + + 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) + checkFolderEditPermission(storage, folderUid, auth.username, accountUid) + + const folderDisplayName = getFolderDisplayName(storage, folderUid) + + try { + const existing = readExistingMetadata(folderUid, storage) + const metadata = mergeFolderMetadata(existing, newName, color) + const folderData = await buildUpdateFolderData(storage, folderUid, metadata) + + const response = await auth.executeRest(folderUpdateMessage({ folderData: [folderData] })) + parseFolderModifyStatus(response.folderUpdateResults?.[0], ResultCodes.NSF_UPDATE_FAILED) + + await patchNsfFolderMetadata(storage, folderUid, metadata) + + return { + folderUid, + updated: true, + message: buildSuccessMessage(folderDisplayName, newName, color, 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 + ) + } +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 6146d3ac..f9062376 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -70,6 +70,10 @@ export enum NsfErrorCode { UpdateFailed = 'nsf_update_failed', DetailsFailed = 'nsf_details_failed', AddFailed = 'nsf_add_failed', + ShareFailed = 'nsf_share_failed', + ShortcutFailed = 'nsf_shortcut_failed', + TransferFailed = 'nsf_transfer_failed', + RecordPermissionFailed = 'nsf_record_permission_failed', } export enum TeamErrorCode { @@ -180,6 +184,10 @@ export const ResultCodes = { NSF_UPDATE_FAILED: NsfErrorCode.UpdateFailed, NSF_DETAILS_FAILED: NsfErrorCode.DetailsFailed, NSF_ADD_FAILED: NsfErrorCode.AddFailed, + NSF_SHARE_FAILED: NsfErrorCode.ShareFailed, + NSF_SHORTCUT_FAILED: NsfErrorCode.ShortcutFailed, + NSF_TRANSFER_FAILED: NsfErrorCode.TransferFailed, + NSF_RECORD_PERMISSION_FAILED: NsfErrorCode.RecordPermissionFailed, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 815ecd3d..23bfa386 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -94,6 +94,18 @@ import { } from '../nestedShareFolders/listNsf' import { formatNsfDetail } from '../nestedShareFolders/getNsf' import { formatRemoveNsfPreview } from '../nestedShareFolders/removeNsfRecord' +import { + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, +} from '../nestedShareFolders/nsfShare' +import { + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, +} from '../nestedShareFolders/nsfRecordPermission' +import { formatNsfShortcutOutput, formatKeepNsfShortcutPlan } from '../nestedShareFolders/nsfShortcut' +import { formatTransferNestedShareRecordResults } from '../nestedShareFolders/nsfTransferRecord' import type { AddNsfRecordInput, AddNsfRecordResult, @@ -103,20 +115,34 @@ import type { GetNsfResult, GetNsfRecordDetailsInput, GetNsfRecordDetailsResult, + KeepNsfShortcutInput, + KeepNsfShortcutResult, LinkNsfRecordResult, ListNsfFormatInput, ListNsfOptions, ListNsfRow, FormattedListNsfTable, + ListNsfShortcutsOptions, MkdirNsfInput, MkdirNsfResult, + NsfShortcutRow, RemoveNsfFolderInput, RemoveNsfFolderResult, RemoveNsfRecordInput, RemoveNsfRecordResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + UpdateNsfFolderInput, + UpdateNsfFolderResult, UpdateNsfRecordInput, UpdateNsfRecordItemInput, UpdateNsfRecordsInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from '../nestedShareFolders/nsfTypes' @@ -869,6 +895,104 @@ export class KeeperVault { 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 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 formatNsfRecordSharePlan(result.plan, result.dryRun) + } + + public formatNsfRecordShareResults(results: ShareNestedShareRecordResult['results']): string { + return formatNsfRecordShareResults(results) + } + + public formatNsfFolderShareResults(results: ShareNestedShareFolderResult['results']): string { + return formatNsfFolderShareResults(results) + } + + public listNsfShortcuts(options?: ListNsfShortcutsOptions): NsfShortcutRow[] { + this.getAuthOrThrow() + return this.nestedShareFolderManager.listNsfShortcuts(options ?? {}) + } + + public formatNsfShortcutOutput(rows: NsfShortcutRow[], format?: ListNsfShortcutsOptions['format']): string { + return 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.results.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public formatKeepNsfShortcutPlan(result: KeepNsfShortcutResult): string { + return formatKeepNsfShortcutPlan(result.plan) + } + + public async transferNestedShareRecords( + input: TransferNestedShareRecordInput + ): Promise { + const result = await this.nestedShareFolderManager.transferNestedShareRecords(input) + if (result.results.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public formatTransferNestedShareRecordResults( + results: TransferNestedShareRecordResult['results'] + ): string { + return formatTransferNestedShareRecordResults(results) + } + + 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 formatNsfRecordPermissionPlan(result.plan) + } + + public formatNsfRecordPermissionRequestHeader( + action: UpdateNsfRecordPermissionInput['action'], + role: string | undefined, + folderDisplayName: string, + recursive: boolean + ): string { + return formatNsfRecordPermissionRequestHeader(action, role, folderDisplayName, recursive) + } + + public formatNsfRecordPermissionFailures( + failures: UpdateNsfRecordPermissionResult['grantFailures'], + kind: 'GRANT' | 'REVOKE' + ): string { + return formatNsfRecordPermissionFailures(failures, kind) + } + public async shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() From a422a21612dea93be4cbaa42bf23fc4cbb798176 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Jul 2026 20:39:41 +0000 Subject: [PATCH 47/48] chore: regenerate protobuf files --- keeperapi/src/proto.d.ts | 292 +++++++++++++++++- keeperapi/src/proto/Authentication.js | 58 ++++ keeperapi/src/proto/Automator.js | 14 + keeperapi/src/proto/BI.js | 416 ++++++++++++++++++++++++++ keeperapi/src/proto/Enterprise.js | 126 ++++++++ keeperapi/src/proto/Folder.js | 31 ++ keeperapi/src/proto/PAM.js | 56 ++++ keeperapi/src/proto/Records.js | 54 ++++ keeperapi/src/proto/Router.js | 61 +++- keeperapi/src/proto/Tokens.js | 177 +++++++++++ keeperapi/src/proto/record.js | 29 ++ 11 files changed, 1310 insertions(+), 4 deletions(-) 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; }; From 02615d1a527197b4d0c0a854cc433b325a890051 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Fri, 17 Jul 2026 16:03:15 +0530 Subject: [PATCH 48/48] added support for enterprise role functionality --- .github/workflows/main.yml | 10 + KeeperSdk/README.md | 55 +- KeeperSdk/src/index.ts | 84 +++ KeeperSdk/src/roles/RoleManager.ts | 140 ++++ KeeperSdk/src/roles/copyRole.ts | 341 +++++++++ KeeperSdk/src/roles/index.ts | 67 ++ KeeperSdk/src/roles/manageNode.ts | 447 +++++++++++ KeeperSdk/src/roles/roleEnforcement.ts | 138 ++++ KeeperSdk/src/roles/rolePrivilege.ts | 312 ++++++++ KeeperSdk/src/roles/roleUser.ts | 296 ++++++++ .../src/sharedFolders/SharedFolderManager.ts | 24 + .../src/sharedFolders/applyMembership.ts | 693 ++++++++++++++++++ .../src/sharedFolders/downloadMembership.ts | 316 ++++++++ KeeperSdk/src/sharedFolders/shareFolder.ts | 352 +++++++++ KeeperSdk/src/users/UserManager.ts | 28 + KeeperSdk/src/users/updateTeamUser.ts | 242 ++++++ KeeperSdk/src/utils/constants.ts | 32 + KeeperSdk/src/vault/KeeperVault.ts | 154 ++++ examples/sdk_example/package.json | 8 + examples/sdk_example/src/roles/copyRole.ts | 59 ++ examples/sdk_example/src/roles/manageNode.ts | 113 +++ .../sdk_example/src/roles/roleEnforcements.ts | 63 ++ .../sdk_example/src/roles/rolePrivileges.ts | 71 ++ examples/sdk_example/src/roles/roleUsers.ts | 100 +++ .../src/sharedFolders/apply_membership.ts | 115 +++ .../src/sharedFolders/download_membership.ts | 59 ++ .../sdk_example/src/users/update_team_user.ts | 79 ++ keeperapi/src/commands.ts | 126 +++- 28 files changed, 4469 insertions(+), 55 deletions(-) create mode 100644 KeeperSdk/src/roles/copyRole.ts create mode 100644 KeeperSdk/src/roles/manageNode.ts create mode 100644 KeeperSdk/src/roles/roleEnforcement.ts create mode 100644 KeeperSdk/src/roles/rolePrivilege.ts create mode 100644 KeeperSdk/src/roles/roleUser.ts create mode 100644 KeeperSdk/src/sharedFolders/applyMembership.ts create mode 100644 KeeperSdk/src/sharedFolders/downloadMembership.ts create mode 100644 KeeperSdk/src/users/updateTeamUser.ts create mode 100644 examples/sdk_example/src/roles/copyRole.ts create mode 100644 examples/sdk_example/src/roles/manageNode.ts create mode 100644 examples/sdk_example/src/roles/roleEnforcements.ts create mode 100644 examples/sdk_example/src/roles/rolePrivileges.ts create mode 100644 examples/sdk_example/src/roles/roleUsers.ts create mode 100644 examples/sdk_example/src/sharedFolders/apply_membership.ts create mode 100644 examples/sdk_example/src/sharedFolders/download_membership.ts create mode 100644 examples/sdk_example/src/users/update_team_user.ts 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 b4b5ca70..bb49b99d 100644 --- a/KeeperSdk/README.md +++ b/KeeperSdk/README.md @@ -1,8 +1,6 @@ # @keeper-security/keeper-sdk-javascript -Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folders, and enterprise admin APIs. - -> **CLI:** Commander-style shell commands (`dispatchCliLine`, `help`, `get`, `ls`, …) live in [**@keeper-security/keeper-shell-component**](https://www.npmjs.com/package/@keeper-security/keeper-shell-component) (or this monorepo’s `commander-javascript-cli` package). This SDK package is API-only. +Keeper Javascript SDK for Node.js. [![NPM](https://img.shields.io/npm/v/@keeper-security/keeper-sdk-javascript?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeper-sdk-javascript) @@ -12,14 +10,7 @@ Keeper JavaScript SDK for **Node** and **browser** — vault API, sharing, folde npm install @keeper-security/keeper-sdk-javascript ``` -## Entry points - -| Environment | Import | Notes | -| ----------- | ---------------------------------------------------------- | ------------------------------------------------------------- | -| Node | `@keeper-security/keeper-sdk-javascript` → `dist/index.js` | `ConsoleAuthUI`, `FileConfigLoader`, full auth | -| Browser | same package → `dist/browser.js` | Platform shim only; use in-memory session + `restore-session` | - -## Quickstart (Node) +## Quickstart ```typescript import { @@ -30,60 +21,50 @@ import { const vault = new KeeperVault({ authUI: new ConsoleAuthUI(), - sessionStorage: new FileConfigLoader("./keeper-config.json"), + configLoader: new FileConfigLoader("./keeper-config.json"), }); -await vault.login("user@example.com", "password"); -await vault.sync(); +await vault.login(); +await vault.syncDown(); -console.log(`Loaded ${vault.getRecords().length} records`); +console.log(`Loaded ${vault.records.size} records`); ``` ## Supported functionality -`KeeperVault` exposes vault operations. Enterprise features require an enterprise administrator account. +`KeeperVault` exposes the operations below. Enterprise features require an enterprise administrator account. - **Authentication**: Login, session token login, resume session, sync, logout - **Records**: List, search, add, update, delete, move, history - **Folders**: List, get, create, rename, delete, change directory, folder tree - **Shared folders**: List shared folders, share with users, update permissions - **Sharing**: Share and unshare records, check share info -- **Teams / users / roles** (enterprise admin): Available via the SDK API +- **Teams**: List, view, add, update, delete teams +- **Users**: List, view, add, update, delete users; lock/unlock accounts; expire passwords; manage aliases and team membership -## Examples +Enterprise features need an enterprise administrator account. -Shell CLI (`dispatchCliLine`, categorized `help`, record/folder commands) is provided by **@keeper-security/keeper-shell-component** — see that package’s `src/keeper-cli/README.md`. +## Examples -Runnable SDK scripts are in [`examples/sdk_example`](../examples/sdk_example): +Runnable scripts for the areas above are in [`examples/sdk_example`](../examples/sdk_example): ```bash cd examples/sdk_example npm install npm run auth:login npm run records:list -npm run records:get # interactive; similar to CLI get + share info npm run folders:ls npm run shared-folders:list-sf -``` - -Shell CLI parity (same dispatch path as the vault shell): - -```bash -npm run records:list:shell-cli -- --from-json /path/to/session.json +npm run teams:list +npm run users:list ``` ## Local development -From repo root, build keeperapi first: +From the `KeeperSdk/` directory: ```bash -cd keeperapi && npm install && npm run build -cd ../KeeperSdk && npm install && npm run link-local && npm run build +npm install +npm run link-local +npm run build ``` - -Watch types: `npm run types` (in `KeeperSdk/`). - -## Related - -- [`keeperapi/README.md`](../keeperapi/README.md) — core client -- [`../README.md`](../README.md) — monorepo overview diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index a7963b13..7b2f0022 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -165,6 +165,7 @@ export type { export { shareFolder, + updateSharedFolderMembership, ShareFolderAction, ShareFolderUserResultStatus, } from "./sharedFolders/shareFolder"; @@ -173,8 +174,35 @@ export type { ShareFolderInput, ShareFolderResult, ShareFolderUserStatus, + SharedFolderMembershipUserGrant, + SharedFolderMembershipTeamGrant, + UpdateSharedFolderMembershipInput, + SharedFolderMembershipUpdateResult, } from "./sharedFolders/shareFolder"; +export { + 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 { changeDirectory, createVaultFolderSession, @@ -354,6 +382,27 @@ export { 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 { ListRolesOptions, @@ -387,6 +436,27 @@ export type { 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"; @@ -551,6 +621,20 @@ export { TeamUserSkipReason, } from "./users/teamUser"; +export { + updateUsersOnTeams, + formatUpdateTeamUserResult, + renderUpdateTeamUserAsciiTable, + UpdateTeamUserStatus, + UpdateTeamUserSkipReason, +} from "./users/updateTeamUser"; +export type { + UpdateUsersOnTeamsInput, + UpdateTeamUserItemResult, + UpdateUsersOnTeamsResult, + FormattedUpdateTeamUserTable, +} from "./users/updateTeamUser"; + export { UserManager } from "./users/UserManager"; export { diff --git a/KeeperSdk/src/roles/RoleManager.ts b/KeeperSdk/src/roles/RoleManager.ts index 932f16e2..12ce1ae7 100644 --- a/KeeperSdk/src/roles/RoleManager.ts +++ b/KeeperSdk/src/roles/RoleManager.ts @@ -44,6 +44,48 @@ import { type DeleteRoleResult, type FormattedDeleteRoleTable, } from "./deleteRole"; +import { + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + type SetRoleEnforcementsInput, + type SetRoleEnforcementsResult, + type FormattedRoleEnforcementTable, +} from "./roleEnforcement"; +import { + copyRoles, + formatCopyRoleResult, + renderCopyRoleAsciiTable, + type CopyRoleInput, + type CopyRoleResult, + type FormattedCopyRoleTable, +} from "./copyRole"; +import { + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + type AddUsersToRolesInput, + type RemoveUsersFromRolesInput, + type RoleUserResult, + type FormattedRoleUserTable, +} from "./roleUser"; +import { + 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; @@ -139,6 +181,104 @@ export class RoleManager { return renderDeleteRoleAsciiTable(table); } + 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; + } + + 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()); 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/index.ts b/KeeperSdk/src/roles/index.ts index 9d951a94..1c0e258c 100644 --- a/KeeperSdk/src/roles/index.ts +++ b/KeeperSdk/src/roles/index.ts @@ -75,4 +75,71 @@ export type { FormattedDeleteRoleTable, } from "./deleteRole"; +export { + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + RoleEnforcementStatus, +} from "./roleEnforcement"; +export type { + SetRoleEnforcementsInput, + RoleEnforcementItemResult, + SetRoleEnforcementsResult, + FormattedRoleEnforcementTable, +} from "./roleEnforcement"; + +export { + copyRoles, + formatCopyRoleResult, + renderCopyRoleAsciiTable, +} from "./copyRole"; +export type { + CopyRoleInput, + CopyRoleResult, + FormattedCopyRoleTable, +} from "./copyRole"; + +export { + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + RoleUserStatus, + RoleUserSkipReason, +} from "./roleUser"; +export type { + AddUsersToRolesInput, + RemoveUsersFromRolesInput, + RoleUserItemResult, + RoleUserResult, + FormattedRoleUserTable, +} from "./roleUser"; + +export { + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + RoleManagedNodeStatus, +} from "./manageNode"; +export type { + ManageRoleNodesAction, + ManageRoleNodesInput, + RoleManagedNodeItemResult, + ManageRoleNodesResult, + FormattedManageRoleNodesTable, +} from "./manageNode"; + +export { + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + RolePrivilegeStatus, +} from "./rolePrivilege"; +export type { + ChangeRolePrivilegesInput, + RolePrivilegeItemResult, + ChangeRolePrivilegesResult, + FormattedRolePrivilegeTable, +} from "./rolePrivilege"; + export type { RoleToggleInput, RoleToggle, EnforcementPair } from "./roleUtils"; 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/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/sharedFolders/SharedFolderManager.ts b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts index f088145d..b4df386f 100644 --- a/KeeperSdk/src/sharedFolders/SharedFolderManager.ts +++ b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts @@ -13,6 +13,17 @@ import type { } 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; @@ -61,4 +72,17 @@ export class SharedFolderManager { ): 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/shareFolder.ts b/KeeperSdk/src/sharedFolders/shareFolder.ts index 0c4d8a9a..5e3c64d9 100644 --- a/KeeperSdk/src/sharedFolders/shareFolder.ts +++ b/KeeperSdk/src/sharedFolders/shareFolder.ts @@ -934,3 +934,355 @@ export async function shareFolder( 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", + ); + } + } + + 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 { + success: requestOk && allOk, + sharedFolderUid: sharedFolder.uid, + message: failureReason, + results, + }; +} diff --git a/KeeperSdk/src/users/UserManager.ts b/KeeperSdk/src/users/UserManager.ts index aee8b44a..4af0d466 100644 --- a/KeeperSdk/src/users/UserManager.ts +++ b/KeeperSdk/src/users/UserManager.ts @@ -27,6 +27,11 @@ import { formatTeamUserResult, renderTeamUserAsciiTable, } from "./teamUser"; +import { + updateUsersOnTeams, + formatUpdateTeamUserResult, + renderUpdateTeamUserAsciiTable, +} from "./updateTeamUser"; import { listUsers, formatUsersTable, @@ -62,6 +67,11 @@ import type { TeamUserResult, FormattedTeamUserTable, } from "./userTypes"; +import type { + UpdateUsersOnTeamsInput, + UpdateUsersOnTeamsResult, + FormattedUpdateTeamUserTable, +} from "./updateTeamUser"; export type AuthProvider = () => Auth; @@ -188,6 +198,24 @@ export class UserManager { 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) { 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/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index c869a123..ab21276d 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -52,6 +52,20 @@ export enum RoleErrorCode { 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 { @@ -140,6 +154,8 @@ export enum UserErrorCode { 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 { @@ -173,6 +189,20 @@ export const ResultCodes = { 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, @@ -225,6 +255,8 @@ export const ResultCodes = { 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, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 6eb2889f..72a8ea6f 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -101,6 +101,15 @@ 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"; @@ -122,6 +131,22 @@ import { 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"; @@ -202,6 +227,11 @@ import type { TeamUserResult, FormattedTeamUserTable, } from "../users/userTypes"; +import type { + UpdateUsersOnTeamsInput, + UpdateUsersOnTeamsResult, + FormattedUpdateTeamUserTable, +} from "../users/updateTeamUser"; import { buildWhoamiInfo, type WhoamiInfo } from "../account/whoamiInfo"; import { ConsoleLogger, @@ -790,6 +820,24 @@ export class KeeperVault { 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 { @@ -850,6 +898,94 @@ export class KeeperVault { 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 { @@ -1407,6 +1543,24 @@ export class KeeperVault { 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 { diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 53953f70..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,6 +58,7 @@ "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", 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/src/commands.ts b/keeperapi/src/commands.ts index a7f89c14..49d44e49 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -179,24 +179,6 @@ export const syncDownCommand = (request: SyncDownRequest): RestCommand => createCommand(request, 'team_get_keys') - export type RoleEnforcementAddRequest = { role_id: number enforcement: string @@ -250,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 @@ -397,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