From 02328c8e89f50451dc6a4b86e7d2e45c8e984033 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 30 Apr 2026 18:18:59 +0530 Subject: [PATCH 1/3] Folder and shared folder implementation (#131) --- KeeperSdk/package.json | 3 +- KeeperSdk/src/auth/ConsoleAuthUI.ts | 20 +- KeeperSdk/src/auth/ConsoleLogin.ts | 144 ++++-- KeeperSdk/src/auth/SessionManager.ts | 81 ++-- KeeperSdk/src/folders/FolderManager.ts | 174 +++++++ KeeperSdk/src/folders/addFolder.ts | 294 +++++++++++ KeeperSdk/src/folders/changeDirectory.ts | 217 +++++++++ KeeperSdk/src/folders/deleteFolder.ts | 293 +++++++++++ KeeperSdk/src/folders/folderHelpers.ts | 99 ++++ KeeperSdk/src/folders/folderTree.ts | 321 ++++++++++++ KeeperSdk/src/folders/getFolder.ts | 234 +++++++++ KeeperSdk/src/folders/listFolder.ts | 358 ++++++++++++++ KeeperSdk/src/folders/updateFolder.ts | 210 ++++++++ KeeperSdk/src/index.ts | 140 +++++- KeeperSdk/src/records/RecordOperations.ts | 84 ++-- KeeperSdk/src/records/RecordUtils.ts | 44 +- KeeperSdk/src/records/Totp.ts | 116 +++++ .../src/sharedFolders/SharedFolderManager.ts | 57 +++ .../src/sharedFolders/listSharedFolders.ts | 173 +++++++ KeeperSdk/src/sharedFolders/shareFolder.ts | 457 ++++++++++++++++++ KeeperSdk/src/sharing/Sharing.ts | 131 ++++- KeeperSdk/src/storage/InMemoryStorage.ts | 47 +- KeeperSdk/src/utils/guards.ts | 24 + KeeperSdk/src/utils/index.ts | 12 + KeeperSdk/src/utils/patterns.ts | 24 + KeeperSdk/src/utils/types.ts | 11 + KeeperSdk/src/vault/KeeperVault.ts | 190 ++++++-- KeeperSdk/tsconfig.json | 4 +- examples/sdk_example/package.json | 11 +- .../src/folders/change_directory.ts | 27 ++ .../sdk_example/src/folders/get_folder.ts | 88 ++++ .../sdk_example/src/folders/list_folder.ts | 135 ++++++ examples/sdk_example/src/folders/mkdir.ts | 47 ++ examples/sdk_example/src/folders/removedir.ts | 52 ++ examples/sdk_example/src/folders/tree.ts | 38 ++ examples/sdk_example/src/folders/updatedir.ts | 44 ++ .../sdk_example/src/records/get_record.ts | 148 +++--- .../sdk_example/src/records/move_record.ts | 22 +- .../sdk_example/src/sharedFolders/list_sf.ts | 40 ++ .../src/sharedFolders/share_folder.ts | 152 ++++++ examples/sdk_example/src/utils/format.ts | 46 +- examples/sdk_example/src/utils/runner.ts | 3 + examples/sdk_example/tsconfig.json | 1 + keeperapi/src/commands.ts | 49 ++ keeperapi/src/restMessages.ts | 12 + 45 files changed, 4556 insertions(+), 321 deletions(-) create mode 100644 KeeperSdk/src/folders/FolderManager.ts create mode 100644 KeeperSdk/src/folders/addFolder.ts create mode 100644 KeeperSdk/src/folders/changeDirectory.ts create mode 100644 KeeperSdk/src/folders/deleteFolder.ts create mode 100644 KeeperSdk/src/folders/folderHelpers.ts create mode 100644 KeeperSdk/src/folders/folderTree.ts create mode 100644 KeeperSdk/src/folders/getFolder.ts create mode 100644 KeeperSdk/src/folders/listFolder.ts create mode 100644 KeeperSdk/src/folders/updateFolder.ts create mode 100644 KeeperSdk/src/records/Totp.ts create mode 100644 KeeperSdk/src/sharedFolders/SharedFolderManager.ts create mode 100644 KeeperSdk/src/sharedFolders/listSharedFolders.ts create mode 100644 KeeperSdk/src/sharedFolders/shareFolder.ts create mode 100644 KeeperSdk/src/utils/guards.ts create mode 100644 KeeperSdk/src/utils/patterns.ts create mode 100644 KeeperSdk/src/utils/types.ts create mode 100644 examples/sdk_example/src/folders/change_directory.ts create mode 100644 examples/sdk_example/src/folders/get_folder.ts create mode 100644 examples/sdk_example/src/folders/list_folder.ts create mode 100644 examples/sdk_example/src/folders/mkdir.ts create mode 100644 examples/sdk_example/src/folders/removedir.ts create mode 100644 examples/sdk_example/src/folders/tree.ts create mode 100644 examples/sdk_example/src/folders/updatedir.ts create mode 100644 examples/sdk_example/src/sharedFolders/list_sf.ts create mode 100644 examples/sdk_example/src/sharedFolders/share_folder.ts diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 48f7507..c03925c 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -20,6 +20,7 @@ "typescript": "^4.6.3" }, "devDependencies": { - "@types/node": "^25.6.0" + "@types/node": "^25.6.0", + "prettier": "^3.8.1" } } diff --git a/KeeperSdk/src/auth/ConsoleAuthUI.ts b/KeeperSdk/src/auth/ConsoleAuthUI.ts index a7b2905..69a53da 100644 --- a/KeeperSdk/src/auth/ConsoleAuthUI.ts +++ b/KeeperSdk/src/auth/ConsoleAuthUI.ts @@ -46,7 +46,10 @@ export class ConsoleAuthUI implements AuthUI3 { case Authentication.TwoFactorChannelType.TWO_FA_CT_KEEPER: return 'Keeper' default: - throw new KeeperSdkError(`Unsupported 2FA channel type: ${channelType}`, ResultCodes.UNSUPPORTED_2FA_CHANNEL) + throw new KeeperSdkError( + `Unsupported 2FA channel type: ${channelType}`, + ResultCodes.UNSUPPORTED_2FA_CHANNEL + ) } } @@ -59,7 +62,10 @@ export class ConsoleAuthUI implements AuthUI3 { } public async waitForDeviceApproval(channels: DeviceApprovalChannel[], isCloud: boolean): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) try { logger.info('\n--- Device Approval Required ---') @@ -100,7 +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 = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) try { logger.info('\n--- Two-Factor Authentication Required ---') @@ -146,7 +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 = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) try { const label = isAlternate ? 'alternate master password' : 'master password' return (await rl.question(`Enter your ${label}: `)).trim() diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts index bd7ab51..3d0907e 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -1,4 +1,5 @@ import readline from 'readline/promises' +import type { AuthUI3 } from '@keeper-security/keeperapi' import { KeeperVault } from '../vault/KeeperVault' import { logger, @@ -10,27 +11,54 @@ import { ResultCodes, KEEPER_PUBLIC_HOSTS, } from '../utils' +import { ConsoleAuthUI } from './ConsoleAuthUI' import { FileConfigLoader } from './SessionManager' import type { KeeperJsonConfig } from './SessionManager' -const defaultConfigLoader = new FileConfigLoader() +const DEFAULT_REGION = 'US' +const MASK_CHAR = '*' +const NOOP_WRITE = (() => true) as typeof process.stdout.write -let rlManager: ReadlineManager | null = null -let suppressionDepth = 0 -let originals: { +enum CliCharAction { + 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 -} | null = null +} -enum CliCharAction { - Submit, - Cancel, - Backspace, - Append, +const defaultConfigLoader = new FileConfigLoader() + +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), + } +} + +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 } function classifyInputChar(ch: string): CliCharAction { @@ -114,7 +142,7 @@ export function prompt(question: string, masked = false): Promise { break case CliCharAction.Append: buf += ch - process.stdout.write('*') + process.stdout.write(MASK_CHAR) break } } @@ -135,9 +163,7 @@ export async function resolveServer(username?: string, preloadedConfig?: KeeperJ if (username) { const users = config.users || [] - const userEntry = users.find( - (u) => u.user?.toLowerCase() === username.toLowerCase() - ) + const userEntry = users.find((u) => u.user?.toLowerCase() === username.toLowerCase()) if (userEntry?.server) return userEntry.server } @@ -150,35 +176,26 @@ export async function resolveServer(username?: string, preloadedConfig?: KeeperJ }) logger.info(` Or enter a hostname directly (e.g. dev.keepersecurity.com)`) - const choice = await prompt('Region [1 = US]: ') - - if (!choice) return KEEPER_PUBLIC_HOSTS.US + 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] - const byName = KEEPER_PUBLIC_HOSTS[choice.toUpperCase()] - if (byName) return byName - - return choice + return KEEPER_PUBLIC_HOSTS[choice.toUpperCase()] || choice } export function suppressLogs(): () => void { if (suppressionDepth === 0) { - originals = { - 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), - } - console.log = () => {} - console.warn = () => {} - console.debug = () => {} - console.error = () => {} - process.stdout.write = (() => true) as typeof process.stdout.write - process.stderr.write = (() => true) as typeof process.stderr.write + originals = captureConsoleHandlers() + applyConsoleHandlers({ + log: () => {}, + warn: () => {}, + debug: () => {}, + error: () => {}, + stdoutWrite: NOOP_WRITE, + stderrWrite: NOOP_WRITE, + }) } suppressionDepth++ @@ -188,12 +205,7 @@ export function suppressLogs(): () => void { restored = true suppressionDepth-- if (suppressionDepth === 0 && originals) { - console.log = originals.log - console.warn = originals.warn - console.debug = originals.debug - console.error = originals.error - process.stdout.write = originals.stdoutWrite - process.stderr.write = originals.stderrWrite + applyConsoleHandlers(originals) originals = null } } @@ -208,13 +220,43 @@ async function withSuppressedOutput(fn: () => Promise): Promise { } } +function unsuppressLogs(): () => void { + if (suppressionDepth === 0 || !originals) return () => {} + + const overrides = captureConsoleHandlers() + applyConsoleHandlers(originals) + + 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)), + } +} + 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 host = defaultUsername ? await resolveServer(defaultUsername, config) : undefined if (defaultUsername && host) { const vault = await tryPersistentLogin(host, defaultUsername) @@ -233,12 +275,16 @@ export async function login(): Promise { throw new KeeperSdkError('Username is required.', ResultCodes.MISSING_USERNAME) } - const resolvedHost = host || await resolveServer(username, config) + 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 }) + const vault = new KeeperVault({ + host, + clientVersion: SdkDefaults.CLIENT_VERSION, + authUI: unsuppressedAuthUI(), + }) try { await withSuppressedOutput(() => vault.resumeSession()) logger.info(`Logging in to Keeper as ${username}`) @@ -252,7 +298,11 @@ async function tryPersistentLogin(host: string, username: string): Promise { - const vault = new KeeperVault({ host, clientVersion: SdkDefaults.CLIENT_VERSION }) + 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) diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts index 861ff8e..ec3c7a9 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -1,8 +1,15 @@ import fs from 'fs/promises' import path from 'path' import os from 'os' -import { normal64Bytes, type DeviceConfig, type SessionStorage, type KeeperHost, type SessionParams } from '@keeper-security/keeperapi' +import { + normal64Bytes, + type DeviceConfig, + type SessionStorage, + type KeeperHost, + type SessionParams, +} from '@keeper-security/keeperapi' import { logger, extractErrorMessage, SdkDefaults } from '../utils' +import type { Nullable } from '../utils' export type ConfigurationUser = { user?: string @@ -39,6 +46,11 @@ type ResolvedDevice = { serverInfo: Array> } +type DeviceCacheEntry = { + username: string + device: Nullable +} + export interface ConfigLoader { load(): Promise save(config: KeeperJsonConfig): Promise @@ -68,16 +80,18 @@ export class FileConfigLoader implements ConfigLoader { async save(config: KeeperJsonConfig): Promise { const configPath = path.join(this.configDir, 'config.json') - await fs.writeFile(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }) + await fs.writeFile(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }) } } export class SessionManager implements SessionStorage { private readonly configLoader: ConfigLoader - private sessionParams: SessionParams | null = null + private sessionParams: Nullable = null private _lastUsername?: string - private _keeperConfig: KeeperJsonConfig | null = null - private _deviceCache: { username: string; device: ResolvedDevice | null } | null = null + private _keeperConfig: Nullable = null + private _deviceCache: Nullable = null private sessionDevices = new Map() private sessionCloneCodes = new Map() @@ -101,8 +115,8 @@ export class SessionManager implements SessionStorage { public async getLastUsername(): Promise { if (this._lastUsername) return this._lastUsername - const kc = await this.loadKeeperConfig() - return kc.last_login || kc.user || undefined + const keeperConfig = await this.loadKeeperConfig() + return keeperConfig.last_login || keeperConfig.user || undefined } public async getDeviceConfig(host: string): Promise { @@ -130,7 +144,7 @@ export class SessionManager implements SessionStorage { return `${host}::${username}` } - public async getCloneCode(host: KeeperHost, username: string): Promise { + public async getCloneCode(host: KeeperHost, username: string): Promise> { const hostStr = String(host) const key = this.cloneCodeKey(host, username) @@ -139,7 +153,7 @@ export class SessionManager implements SessionStorage { const device = await this.findDeviceInKeeperConfig(username) if (device) { - const serverInfo = device.serverInfo.find(si => si.server === hostStr) + const serverInfo = device.serverInfo.find((entry) => entry.server === hostStr) if (serverInfo) { return normal64Bytes(serverInfo.clone_code) } @@ -169,14 +183,14 @@ export class SessionManager implements SessionStorage { } const user = (parsed.users || []).find( - u => u.user?.toLowerCase() === username.toLowerCase() + (configUser) => configUser.user?.toLowerCase() === username.toLowerCase() ) if (user?.last_device?.device_token) { const device = (parsed.devices || []).find( - d => d.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(si => si.server === host) + const serverInfo = device.server_info.find((entry) => entry.server === host) if (serverInfo) { serverInfo.clone_code = encodedCloneCode updated = true @@ -194,7 +208,7 @@ export class SessionManager implements SessionStorage { } } - public async getSessionParameters(): Promise { + public async getSessionParameters(): Promise> { return this.sessionParams } @@ -215,7 +229,7 @@ export class SessionManager implements SessionStorage { return this._keeperConfig } - private async findDeviceInKeeperConfig(username: string): Promise { + private async findDeviceInKeeperConfig(username: string): Promise> { const normalizedUsername = username.toLowerCase() if (this._deviceCache?.username === normalizedUsername) { return this._deviceCache.device @@ -226,38 +240,42 @@ export class SessionManager implements SessionStorage { return device } - private async lookupDeviceInKeeperConfig(normalizedUsername: string): Promise { - const kc = await this.loadKeeperConfig() + private async lookupDeviceInKeeperConfig(normalizedUsername: string): Promise> { + const keeperConfig = await this.loadKeeperConfig() - // Prefer explicit user->last_device mapping first when present. - // In mixed configs the top-level device fields can point to an older device. - if (kc.users && kc.devices) { - const user = kc.users.find(u => u.user?.toLowerCase() === normalizedUsername) + 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 = kc.devices.find(d => d.device_token === deviceTokenStr) + 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((si): si is Required => - !!si.server && !!si.clone_code - ), + serverInfo: (device.server_info || []).filter( + (entry): entry is Required => + !!entry.server && !!entry.clone_code + ), } } } } - if (kc.device_token && kc.private_key && kc.user?.toLowerCase() === normalizedUsername) { + if ( + keeperConfig.device_token && + keeperConfig.private_key && + keeperConfig.user?.toLowerCase() === normalizedUsername + ) { const serverInfo: Array> = [] - const server = kc.last_server || kc.server - if (server && kc.clone_code) { - serverInfo.push({ server, clone_code: kc.clone_code }) + const server = keeperConfig.last_server || keeperConfig.server + if (server && keeperConfig.clone_code) { + serverInfo.push({ server, clone_code: keeperConfig.clone_code }) } return { - deviceToken: normal64Bytes(kc.device_token), - privateKey: normal64Bytes(kc.private_key), + deviceToken: normal64Bytes(keeperConfig.device_token), + privateKey: normal64Bytes(keeperConfig.private_key), serverInfo, } } @@ -272,5 +290,4 @@ export class SessionManager implements SessionStorage { if (obj.devices !== undefined && !Array.isArray(obj.devices)) return false return true } - } diff --git a/KeeperSdk/src/folders/FolderManager.ts b/KeeperSdk/src/folders/FolderManager.ts new file mode 100644 index 0000000..54e07cb --- /dev/null +++ b/KeeperSdk/src/folders/FolderManager.ts @@ -0,0 +1,174 @@ +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 + +export type SharedFolderPermissionsInput = { + 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) + } +} diff --git a/KeeperSdk/src/folders/addFolder.ts b/KeeperSdk/src/folders/addFolder.ts new file mode 100644 index 0000000..57f5641 --- /dev/null +++ b/KeeperSdk/src/folders/addFolder.ts @@ -0,0 +1,294 @@ +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 } from './folderHelpers' + +type NewFolderKind = FolderKind + +export type AddFolderInput = { + folderName: string + isSharedFolder?: boolean + parentUid?: string | null + manageUsers?: boolean + manageRecords?: boolean + canShare?: boolean + canEdit?: boolean +} + +export type AddFolderResult = { + folderUid: string + success: boolean + message?: string +} + +export type MkdirOptions = { + sharedFolder?: boolean + userFolder?: boolean + grantAll?: boolean + manageUsers?: boolean + manageRecords?: boolean + canShare?: boolean + canEdit?: boolean +} + +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') +} + +function decideNewFolderType(parent: ParentContext, isSharedFolder: boolean): NewFolderKind { + if (isSharedFolder) { + if (parent.kind !== ParentFolderKind.UserFolder && parent.kind !== ParentFolderKind.VirtualRoot) { + throw new KeeperSdkError( + 'Shared folders cannot be nested inside other shared folders.', + 'shared_folder_nested' + ) + } + return FolderKind.SharedFolder + } + if (parent.kind === ParentFolderKind.VirtualRoot || parent.kind === ParentFolderKind.UserFolder) { + return FolderKind.UserFolder + } + return FolderKind.SharedFolderFolder +} + +async function getEncryptionKeyForNewFolder( + 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 (!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 +): 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 +} + +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') + } + + const parentUid = input.parentUid === undefined || input.parentUid === '' ? null : input.parentUid + const parent = resolveParentContext(storage, parentUid) + + const isShared = input.isSharedFolder === true + const folderType = decideNewFolderType(parent, isShared) + + const folderUid = generateUid() + const folderKey = generateEncryptionKey() + + const sharedScope = folderType === FolderKind.SharedFolderFolder ? parent.sharedScopeUid : null + + const encryptionKey = await getEncryptionKeyForNewFolder(auth, storage, folderType, sharedScope) + + const request: FolderAddRequest = { + folder_uid: folderUid, + folder_type: folderType, + key: await encryptForStorage(folderKey, encryptionKey), + data: await encryptObjectForStorage({ name, title: name }, folderKey), + link: false, + } + + if (parentUid) { + request.parent_uid = parentUid + } + if (folderType === FolderKind.SharedFolderFolder && sharedScope) { + request.shared_folder_uid = sharedScope + } + + if (folderType === FolderKind.SharedFolder) { + request.name = await encryptForStorage(platform.stringToBytes(name), folderKey) + request.manage_users = isBoolean(input.manageUsers) ? input.manageUsers : false + request.manage_records = isBoolean(input.manageRecords) ? input.manageRecords : false + request.can_edit = isBoolean(input.canEdit) ? input.canEdit : false + request.can_share = isBoolean(input.canShare) ? input.canShare : false + } + + 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)}`, + } + } +} + +export async function mkdir( + 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') + } + + 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 + } + + const createAsSharedFolder = isLastSegment && options.sharedFolder === true + + const parentContext = resolveParentContext(storage, currentParent) + if ( + createAsSharedFolder && + parentContext.kind !== ParentFolderKind.UserFolder && + parentContext.kind !== ParentFolderKind.VirtualRoot + ) { + throw new KeeperSdkError( + 'Shared folders can only be created under a personal folder.', + 'shared_folder_invalid_parent' + ) + } + + lastResult = await addFolder(auth, storage, { + folderName: segment, + parentUid: currentParent, + isSharedFolder: createAsSharedFolder, + manageUsers: isLastSegment && createAsSharedFolder ? manageUsers : undefined, + manageRecords: isLastSegment && createAsSharedFolder ? manageRecords : undefined, + canShare: isLastSegment && createAsSharedFolder ? canShare : undefined, + canEdit: isLastSegment && createAsSharedFolder ? canEdit : undefined, + }) + + if (!lastResult.success) { + return { + folderUid: lastResult.folderUid, + success: false, + message: lastResult.message, + } + } + currentParent = lastResult.folderUid + } + + return { folderUid: lastResult.folderUid, success: true } +} diff --git a/KeeperSdk/src/folders/changeDirectory.ts b/KeeperSdk/src/folders/changeDirectory.ts new file mode 100644 index 0000000..7f8aeb9 --- /dev/null +++ b/KeeperSdk/src/folders/changeDirectory.ts @@ -0,0 +1,217 @@ +import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError } from '../utils' +import { listFolder, listRootUserFolders } from './listFolder' +import type { ListFolderFolderSimple } from './listFolder' +import { FolderKind, VaultObjectKind, sharedFolderFolderName, sharedFolderName, userFolderName } from './folderHelpers' + +const VAULT_ROOT_DISPLAY_NAME = 'My Vault' + +const ESCAPED_SEPARATOR_PLACEHOLDER = '\x00' + +export type VaultFolderSession = { + currentFolderUid: string | null +} + +export type ChangeDirectoryResult = { + folderUid: string | null + name: string +} + +export function createVaultFolderSession(): VaultFolderSession { + 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 +} + +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 +} + +async function listFolderChildrenForCd( + storage: InMemoryStorage, + parentUid: string | null +): Promise { + const result = await listFolder(storage, { + folderUid: parentUid === null ? undefined : parentUid, + showFolders: true, + showRecords: false, + }) + return result.folders +} + +function findChildFolder( + 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) +} + +export function splitPathComponents(path: string): string[] { + 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 +} + +export async function tryResolvePath( + storage: InMemoryStorage, + session: VaultFolderSession, + path: string +): Promise { + const trimmed = path.trim() + if (!trimmed) { + return { folderUid: session.currentFolderUid, 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) + + let folderUid: string | null = absolute ? null : session.currentFolderUid || null + + if (!absolute && folderUid !== null) { + if (!getFolderEntryByUid(storage, folderUid)) { + folderUid = null + } + } + + let componentIndex = 0 + while (componentIndex < components.length) { + const component = components[componentIndex] + componentIndex++ + + if (component === '' || component === '.') { + 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 } + } + } + + return { folderUid, remaining: '' } +} + +export async function resolveSingleFolder( + storage: InMemoryStorage, + session: VaultFolderSession, + folderName: string +): Promise { + 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 { 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 } + } + + 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 changeDirectory( + storage: InMemoryStorage, + session: VaultFolderSession, + path: string +): Promise { + const resolved = await resolveSingleFolder(storage, session, path) + session.currentFolderUid = resolved.folderUid + return resolved +} + +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 +} diff --git a/KeeperSdk/src/folders/deleteFolder.ts b/KeeperSdk/src/folders/deleteFolder.ts new file mode 100644 index 0000000..93a512f --- /dev/null +++ b/KeeperSdk/src/folders/deleteFolder.ts @@ -0,0 +1,293 @@ +import type { Auth, DeleteObject, KeeperPreDeleteResponse } from '@keeper-security/keeperapi' +import { preDeleteCommand, recordDeleteCommand } from '@keeper-security/keeperapi' +import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, extractErrorMessage, logger } from '../utils' +import { listFolder } from './listFolder' +import type { ListFolderFolderSimple } from './listFolder' +import { tryResolvePath, findParentFolderUid, type VaultFolderSession } from './changeDirectory' +import { + DeleteResolution, + FolderKind, + globToRegex, + sharedFolderFolderName, + sharedFolderName, + userFolderName, +} from './folderHelpers' +import { findFolder } from './getFolder' + +export type DeleteFolderResult = { + success: boolean + message?: string + cancelled?: boolean +} + +export type RmdirOptions = { + force?: boolean + quiet?: boolean + confirm?: (summary: string) => boolean | Promise +} + +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 +} + +async function listFolderChildrenFolders( + storage: InMemoryStorage, + parentUid: string | null +): Promise { + 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 +} + +export async function buildFolderDeleteObject( + 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 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 + } + 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 null +} + +async function buildDeleteObjectForFolderRef( + storage: InMemoryStorage, + ref: string +): Promise { + const trimmed = ref.trim() + if (!trimmed) return null + + 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) +} + +export async function deleteFolder( + 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 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 + const token = inner?.pre_delete_token + if (!token) { + const reason = + preResp.message || + preResp.result_code || + `pre_delete failed for [${targetUids}]: server did not return a pre_delete_token` + return { + success: false, + message: reason, + } + } + + if (confirm) { + const wouldDelete = inner?.would_delete + const summaryItems = wouldDelete?.deletion_summary + if (Array.isArray(summaryItems) && summaryItems.length > 0) { + const summary = summaryItems.join('\n') + const confirmed = await confirm(summary) + if (!confirmed) { + return { success: false, cancelled: true, message: 'Cancelled.' } + } + } + } + + try { + await auth.executeRestCommand(recordDeleteCommand({ pre_delete_token: token })) + } catch (err) { + return { + success: false, + message: `record_delete failed for [${targetUids}]: ${extractErrorMessage(err)}`, + } + } + + return { success: true } +} + +export async function resolveRmdirPatternsToFolderUids( + 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 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) + } + } + 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 + } + } +} + +export async function rmdir( + auth: Auth, + storage: InMemoryStorage, + session: VaultFolderSession, + patterns: string[], + options: RmdirOptions = {} +): Promise { + const { force = false, quiet = false, confirm } = options + if (!force && !confirm) { + throw new KeeperSdkError( + 'Confirmation is required: pass `confirm` or set `force: true`.', + 'rmdir_confirm_required' + ) + } + const folderUids = new Set() + + for (const pattern of patterns) { + const trimmedPattern = pattern.trim() + if (!trimmedPattern) continue + const resolved = await resolveRmdirPatternsToFolderUids(storage, session, trimmedPattern) + for (const matchedUid of resolved) { + folderUids.add(matchedUid) + } + } + + 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`) + } + + const confirmFn = force ? undefined : confirm + + return deleteFolder(auth, storage, [...folderUids], confirmFn) +} diff --git a/KeeperSdk/src/folders/folderHelpers.ts b/KeeperSdk/src/folders/folderHelpers.ts new file mode 100644 index 0000000..c3680d2 --- /dev/null +++ b/KeeperSdk/src/folders/folderHelpers.ts @@ -0,0 +1,99 @@ +import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { escapeRegExp } from '../utils' + +export enum FolderKind { + 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', +} + +export enum FolderObjectType { + Folder = 'folder', + SharedFolder = 'shared_folder', +} + +export enum DeleteResolution { + Unlink = 'unlink', +} + +export enum DeleteObjectType { + Record = 'record', + UserFolder = 'user_folder', + SharedFolder = 'shared_folder', + SharedFolderFolder = 'shared_folder_folder', +} + +export enum FolderResultStatus { + 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', +} + +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 function userFolderName(folder: DUserFolder): string { + const data = folder.data as { title?: string; name?: string } | undefined + return (data?.title || data?.name || folder.uid).trim() || folder.uid +} + +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 +} + +export function sharedFolderName(folder: DSharedFolder): string { + 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') +} + +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 +} diff --git a/KeeperSdk/src/folders/folderTree.ts b/KeeperSdk/src/folders/folderTree.ts new file mode 100644 index 0000000..03f1dfd --- /dev/null +++ b/KeeperSdk/src/folders/folderTree.ts @@ -0,0 +1,321 @@ +import type { + DRecord, + DSharedFolder, + DSharedFolderFolder, + DSharedFolderTeam, + DSharedFolderUser, + DUser, + DUserFolder, +} from '@keeper-security/keeperapi' +import { webSafe64FromBytes } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { listFolder, listVaultRootFolders } from './listFolder' +import { resolveSingleFolder, type VaultFolderSession } from './changeDirectory' +import { FolderKind, VaultObjectKind, sharedFolderFolderName, sharedFolderName, userFolderName } from './folderHelpers' + +enum TreeItemKind { + Permission = 'perm', + Record = 'record', + Folder = 'folder', +} + +export type FolderTreeBuildOptions = { + 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 }[] +} + +export type FolderTreeResult = { + 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 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 resolveUserDisplayName( + 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' +} + +async function collectSharedFolderPermissions( + 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 })) +} + +type BuildOpts = Required> & { + promotedRootSharedUids?: Set + accountUidEmailMap: Map +} + +async function buildFolderSubtree( + storage: InMemoryStorage, + folderUid: string, + opts: BuildOpts +): Promise { + const userFolder = storage.getByUid(FolderKind.UserFolder, folderUid) + const sharedFolder = storage.getByUid(FolderKind.SharedFolder, folderUid) + const sharedFolderFolder = storage.getByUid(FolderKind.SharedFolderFolder, folderUid) + if (!userFolder && !sharedFolder && !sharedFolderFolder) { + return { displayName: `(missing folder ${folderUid})`, children: [] } + } + + let baseName: string + if (userFolder) baseName = userFolderName(userFolder) + else if (sharedFolder) baseName = sharedFolderName(sharedFolder) + else baseName = sharedFolderFolderName(sharedFolderFolder!) + + let displayName = baseName + if (opts.verbose) { + displayName = `${baseName} (${folderUid})` + } + if (sharedFolder) { + displayName += ' [Shared]' + } + + const node: FolderTreeNode = { displayName, children: [] } + + if (opts.showShares && sharedFolder) { + node.permissions = await collectSharedFolderPermissions( + storage, + sharedFolder, + opts.verbose, + opts.hideSharesKey, + opts.accountUidEmailMap + ) + } + + const listed = await listFolder(storage, { + folderUid: folderUid, + showFolders: true, + showRecords: opts.showRecords, + }) + + for (const childFolder of listed.folders) { + if (opts.promotedRootSharedUids?.has(childFolder.uid)) continue + node.children.push(await buildFolderSubtree(storage, childFolder.uid, opts)) + } + + if (opts.showRecords && 'records' in listed) { + const records = listed.records + node.records = records.map((recordRow) => { + const record = storage.getByUid(VaultObjectKind.Record, recordRow.uid) + const title = record ? getRecordTitle(record) : recordRow.name + const display = opts.verbose && record ? `${title} (${recordRow.uid}) [Record]` : `${title} [Record]` + return { display } + }) + } + + return node +} + +async function buildVaultRootTree(storage: InMemoryStorage, opts: BuildOpts): Promise { + const node: FolderTreeNode = { displayName: '', children: [] } + const { rows, promotedRootSharedUids } = await listVaultRootFolders(storage) + const optsWithPromoted: BuildOpts = { ...opts, promotedRootSharedUids } + for (const folderRow of rows) { + node.children.push(await buildFolderSubtree(storage, folderRow.uid, optsWithPromoted)) + } + const listed = await listFolder(storage, { + folderUid: undefined, + showFolders: true, + showRecords: opts.showRecords, + }) + if (opts.showRecords && listed.records?.length) { + node.records = listed.records.map((recordRow) => { + const record = storage.getByUid(VaultObjectKind.Record, recordRow.uid) + const title = record ? getRecordTitle(record) : recordRow.name + const display = opts.verbose && record ? `${title} (${recordRow.uid}) [Record]` : `${title} [Record]` + return { display } + }) + } + return node +} + +async function resolveTreeStart( + 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 } +} + +export async function buildFolderTree( + 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 } +} + +type TreeItem = + | { 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 }) + } + if (node.records) { + for (const record of node.records) { + items.push({ kind: TreeItemKind.Record, display: record.display }) + } + } + 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') +} + +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) + } + + 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 +): Promise { + const built = await buildFolderTree(storage, session, options || {}) + return renderFolderTreeAscii(built) +} diff --git a/KeeperSdk/src/folders/getFolder.ts b/KeeperSdk/src/folders/getFolder.ts new file mode 100644 index 0000000..cad1b1c --- /dev/null +++ b/KeeperSdk/src/folders/getFolder.ts @@ -0,0 +1,234 @@ +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' + +export enum GetFolderFormat { + Detail = 'detail', + JSON = 'json', +} + +export type GetFolderFormatInput = GetFolderFormat | `${GetFolderFormat}` + +export type GetFolderOptions = { + format?: GetFolderFormatInput +} + +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 +} + +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 +} + +export type FoundFolder = + | { 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 +): 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 + } + } + } + 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 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) + ) +} + +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 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 } + + return undefined +} + +async function formatRegularFolder( + 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 +} + +function formatSharedFolder( + 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 +} + +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 = {} +): Promise { + 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 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) + } +} diff --git a/KeeperSdk/src/folders/listFolder.ts b/KeeperSdk/src/folders/listFolder.ts new file mode 100644 index 0000000..619dd4f --- /dev/null +++ b/KeeperSdk/src/folders/listFolder.ts @@ -0,0 +1,358 @@ +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' +import { + FolderKind, + VaultObjectKind, + getUserFolderParentMap, + globToRegex, + sharedFolderFolderName, + sharedFolderName, + userFolderName, +} from './folderHelpers' + +export type ListFolderOptions = { + folderUid?: string | null + pattern?: string | null + showFolders?: boolean + showRecords?: boolean + detail?: boolean +} + +export type ListFolderFolderSimple = { + uid: string + name: string + folderKind: FolderKind +} + +export type ListFolderRecordSimple = { + uid: string + name: string + type: string +} + +export type ListFolderFolderDetail = ListFolderFolderSimple & { + flags: string + recordCount: number + subfolderCount: number +} + +export type ListFolderRecordDetail = ListFolderRecordSimple & { + 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[] + } + +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 +} + +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' : '-'}` +} + +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 +): 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 }) + } + } + } + } + } + return childToParent +} + +export async function listVaultRootFolders(storage: InMemoryStorage): Promise<{ + rows: ListFolderFolderSimple[] + promotedRootSharedUids: Set +}> { + const rows: ListFolderFolderSimple[] = [] + const seen = new Set() + const promotedRootSharedUids = new Set() + + for (const userFolder of await listRootUserFolders(storage)) { + if (seen.has(userFolder.uid)) continue + seen.add(userFolder.uid) + rows.push({ + uid: userFolder.uid, + name: userFolderName(userFolder), + folderKind: FolderKind.UserFolder, + }) + } + + const rootDependencies = (await storage.getDependencies('')) || [] + for (const dependency of rootDependencies) { + if (dependency.kind === FolderKind.SharedFolder) { + const sharedFolder = storage.getByUid(FolderKind.SharedFolder, dependency.uid) + if (!sharedFolder || seen.has(sharedFolder.uid)) continue + seen.add(sharedFolder.uid) + rows.push({ + uid: sharedFolder.uid, + name: sharedFolderName(sharedFolder), + folderKind: FolderKind.SharedFolder, + }) + } else if (dependency.kind === FolderKind.SharedFolderFolder) { + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + dependency.uid + ) + if (!sharedFolderFolder || seen.has(sharedFolderFolder.uid)) continue + seen.add(sharedFolderFolder.uid) + rows.push({ + uid: sharedFolderFolder.uid, + name: sharedFolderFolderName(sharedFolderFolder), + folderKind: FolderKind.SharedFolderFolder, + }) + } + } + + const 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, + }) + } + + 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 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 + + 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 +} + +async function countFolderChildren( + 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++ + } + } + 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 + folderRows.push({ uid: userFolder.uid, name, folderKind: FolderKind.UserFolder }) + } else if (dependency.kind === FolderKind.SharedFolder && showFolders && parentKey !== null) { + const sharedFolder = storage.getByUid(FolderKind.SharedFolder, dependency.uid) + if (!sharedFolder) continue + const name = sharedFolderName(sharedFolder) + if (!matches(name, sharedFolder.uid)) continue + folderRows.push({ uid: sharedFolder.uid, name, folderKind: FolderKind.SharedFolder }) + } else if (dependency.kind === FolderKind.SharedFolderFolder && showFolders && parentKey !== null) { + const sharedFolderFolder = storage.getByUid( + FolderKind.SharedFolderFolder, + dependency.uid + ) + if (!sharedFolderFolder) continue + const name = sharedFolderFolderName(sharedFolderFolder) + if (!matches(name, sharedFolderFolder.uid)) continue + folderRows.push({ + uid: sharedFolderFolder.uid, + name, + folderKind: FolderKind.SharedFolderFolder, + }) + } else if (dependency.kind === VaultObjectKind.Record && showRecords) { + const record = storage.getByUid(VaultObjectKind.Record, dependency.uid) + if (!record || (record.version !== 2 && record.version !== 3)) continue + const title = getRecordTitle(record) + if (!matches(title, record.uid)) continue + recordRows.push({ + uid: record.uid, + name: title, + type: getRecordType(record), + }) + } + } + + 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 new file mode 100644 index 0000000..d5daae7 --- /dev/null +++ b/KeeperSdk/src/folders/updateFolder.ts @@ -0,0 +1,210 @@ +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 } from './folderHelpers' + +export type UpdateFolderInput = { + 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 +} + +export type RenameFolderResult = { + 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 +} + +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 +): Promise { + 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 trimmedName = input.folderName?.trim() || '' + + 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 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 +): 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 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, + }) + + 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 + } +): 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, + }) +} diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 440874f..6f95e78 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -7,23 +7,37 @@ export type { ConfigurationServerConfig, ConfigurationDeviceConfig, } from './auth/SessionManager' -export { - login, - cleanup, - prompt, - suppressLogs, - loadKeeperConfig, - resolveServer, -} from './auth/ConsoleLogin' +export { login, cleanup, prompt, suppressLogs, loadKeeperConfig, resolveServer } from './auth/ConsoleLogin' export { InMemoryStorage } from './storage/InMemoryStorage' export { - Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger, - KeeperSdkError, isKeeperError, extractErrorMessage, extractResultCode, - SdkDefaults, AuthDefaults, ResultCodes, KEEPER_PUBLIC_HOSTS, + 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 } from './utils' +export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' export { searchRecords, @@ -35,9 +49,12 @@ export { 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, @@ -53,23 +70,110 @@ export type { MoveRecordResult, } from './records/RecordOperations' -export { shareRecord, removeRecordShare } from './sharing/Sharing' +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 { getFolder, findFolder, GetFolderFormat } from './folders/getFolder' +export type { + GetFolderOptions, + GetFolderResult, + GetFolderResultFolder, + GetFolderResultSharedFolder, + GetFolderFormatInput, + FoundFolder, +} from './folders/getFolder' + export { - Auth, - KeeperEnvironment, - syncDown, - Authentication, -} from '@keeper-security/keeperapi' + 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 { Auth, KeeperEnvironment, syncDown, Authentication } from '@keeper-security/keeperapi' export type { DRecord, diff --git a/KeeperSdk/src/records/RecordOperations.ts b/KeeperSdk/src/records/RecordOperations.ts index 0dafed3..acee9af 100644 --- a/KeeperSdk/src/records/RecordOperations.ts +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -30,20 +30,7 @@ import type { import { extractErrorMessage, KeeperSdkError, logger } from '../utils' import { RecordVersion } from './RecordUtils' import { InMemoryStorage } from '../storage/InMemoryStorage' - -enum FolderType { - UserFolder = 'user_folder', - SharedFolder = 'shared_folder', - SharedFolderFolder = 'shared_folder_folder', -} - -enum ObjectType { - Record = 'record', -} - -enum DeleteResolution { - Unlink = 'unlink', -} +import { DeleteResolution, FolderKind, VaultObjectKind } from '../folders/folderHelpers' enum ResultCode { Success = 'success', @@ -175,7 +162,7 @@ async function addPasswordRecord( record_uid: recordUid, record_key: webSafe64FromBytes(encryptedRecordKey), record_type: 'password', - folder_type: FolderType.UserFolder, + folder_type: FolderKind.UserFolder, how_long_ago: 0, folder_uid: folderUid || '', folder_key: '', @@ -312,9 +299,9 @@ export async function deleteRecord( objects: [ { object_uid: recordUid, - object_type: ObjectType.Record, + object_type: VaultObjectKind.Record, from_uid: '', - from_type: FolderType.UserFolder, + from_type: FolderKind.UserFolder, delete_resolution: DeleteResolution.Unlink, } as RecordPreDeleteObject, ], @@ -431,42 +418,55 @@ export type MoveRecordResult = { type FolderInfo = { uid: string - folderType: FolderType + folderType: FolderKind scopeUid: string } function resolveFolder(uid: string, storage: InMemoryStorage): FolderInfo { if (!uid) { - return { uid: '', folderType: FolderType.UserFolder, scopeUid: '' } + return { uid: '', folderType: FolderKind.UserFolder, scopeUid: '' } } - if (storage.getByUid(FolderType.UserFolder, uid)) { - return { uid, folderType: FolderType.UserFolder, scopeUid: '' } + if (storage.getByUid(FolderKind.UserFolder, uid)) { + return { uid, folderType: FolderKind.UserFolder, scopeUid: '' } } - if (storage.getByUid(FolderType.SharedFolder, uid)) { - return { uid, folderType: FolderType.SharedFolder, scopeUid: uid } + if (storage.getByUid(FolderKind.SharedFolder, uid)) { + return { uid, folderType: FolderKind.SharedFolder, scopeUid: uid } } - const sfFolder = storage.getByUid(FolderType.SharedFolderFolder, uid) + const sfFolder = storage.getByUid(FolderKind.SharedFolderFolder, uid) if (sfFolder) { - return { uid, folderType: FolderType.SharedFolderFolder, scopeUid: sfFolder.sharedFolderUid } + return { uid, folderType: FolderKind.SharedFolderFolder, scopeUid: sfFolder.sharedFolderUid } } - return { uid, folderType: FolderType.UserFolder, scopeUid: '' } + return { uid, folderType: FolderKind.UserFolder, scopeUid: '' } } -function findRecordSourceFolder( - recordUid: string, - storage: InMemoryStorage -): { folderUid: string; folderType: FolderType } { - const sfRecords = storage.getAll('shared_folder_record') - const sfr = sfRecords.find((r) => r.recordUid === recordUid) - if (sfr) { - return { folderUid: sfr.sharedFolderUid, folderType: FolderType.SharedFolder } +async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorage): Promise { + const folderKinds = [ + FolderKind.UserFolder, + FolderKind.SharedFolder, + FolderKind.SharedFolderFolder, + ] as const + + for (const kind of folderKinds) { + for (const folder of storage.getAll(kind)) { + const dependencies = (await storage.getDependencies(folder.uid)) || [] + if ( + dependencies.some( + (dependency) => dependency.kind === VaultObjectKind.Record && dependency.uid === recordUid + ) + ) { + return folder.uid + } + } } - return { folderUid: '', folderType: FolderType.UserFolder } + const sharedFolderRecord = storage + .getAll(VaultObjectKind.SharedFolderRecord) + .find((candidate) => candidate.recordUid === recordUid) + return sharedFolderRecord ? sharedFolderRecord.sharedFolderUid : '' } export async function moveRecord( @@ -484,17 +484,13 @@ export async function moveRecord( const dst = resolveFolder(dstFolderUid, storage) - let src: FolderInfo - if (input.srcFolderUid !== undefined) { - src = resolveFolder(input.srcFolderUid, storage) - } else { - const found = findRecordSourceFolder(recordUid, storage) - src = resolveFolder(found.folderUid, storage) - } + const srcUid = + input.srcFolderUid !== undefined ? input.srcFolderUid : await findRecordSourceFolder(recordUid, storage) + const src = resolveFolder(srcUid, storage) const moveObj: MoveObject = { uid: recordUid, - type: ObjectType.Record, + type: VaultObjectKind.Record, cascade: false, from_type: src.folderType, from_uid: src.uid || undefined, @@ -521,7 +517,7 @@ export async function moveRecord( return { recordUid, success: false, message: 'Destination folder key not found' } } - const record = storage.getByUid(ObjectType.Record, recordUid) + const record = storage.getByUid(VaultObjectKind.Record, recordUid) const version = record?.version || RecordVersion.Typed let encryptedKey: Uint8Array diff --git a/KeeperSdk/src/records/RecordUtils.ts b/KeeperSdk/src/records/RecordUtils.ts index ce7f9a3..74630fa 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -1,4 +1,5 @@ import type { DRecord } from '@keeper-security/keeperapi' +import { getTotpCode } from './Totp' enum FieldType { Login = 'login', @@ -13,6 +14,10 @@ export enum RecordVersion { Typed = 3, } +const TOTP_FIELD_TYPES = new Set(['totp', 'oneTimeCode', 'otp']) +const MASKED_VALUE = '********' +const RECORD_SEPARATOR = '-'.repeat(50) + type RecordField = { type: string value: any[] @@ -78,7 +83,7 @@ export function getRecordTitle(record: DRecord): string { try { const parsed = JSON.parse(record.data) return parsed.title || '(untitled)' - } catch (_err) { + } catch { return '(parse error)' } } @@ -182,6 +187,16 @@ export function getRecordSummary(record: DRecord): RecordSummary { return { login, password, url, fields } } +export function getRecordTotpUrl(record: DRecord): string | undefined { + for (const field of getRecordFields(record)) { + if (!TOTP_FIELD_TYPES.has(field.type)) continue + for (const v of field.value) { + if (typeof v === 'string' && v.trim()) return v.trim() + } + } + return undefined +} + export function getRecordPassword(record: DRecord): string | undefined { return getRecordSummary(record).password } @@ -236,15 +251,13 @@ function collectRecordWords(record: DRecord): string[] { } export function formatRecord(record: DRecord, showDetails = false): string { - const title = getRecordTitle(record) - const type = getRecordType(record) const summary = getRecordSummary(record) - const lines: string[] = [] - - lines.push('-'.repeat(50)) - lines.push(`Title: ${title}`) - lines.push(`Record UID: ${record.uid}`) - lines.push(`Record Type: ${type}`) + 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}`) @@ -252,9 +265,16 @@ export function formatRecord(record: DRecord, showDetails = false): string { if (showDetails) { for (const field of summary.fields) { if (field.type === FieldType.Login || field.type === FieldType.Url) continue - const label = field.label || field.type - const value = field.type === FieldType.Password ? '********' : field.value.join(', ') - lines.push(`${label}: ${value}`) + 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(', ')}`) + } + + const totpUrl = getRecordTotpUrl(record) + 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 new file mode 100644 index 0000000..f0b0f31 --- /dev/null +++ b/KeeperSdk/src/records/Totp.ts @@ -0,0 +1,116 @@ +import { createHmac } from 'crypto' + +export type TotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512' + +export type TotpParams = { + secret: string + algorithm: TotpAlgorithm + digits: number + period: number +} + +export type TotpCode = { + 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 + +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) + } + } + 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 +} + +function parsePositiveInt(value: string | null, fallback: number): number { + 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), + } +} + +function counterToBuffer(counter: number): Buffer { + const buf = Buffer.alloc(8) + buf.writeUInt32BE(Math.floor(counter / UINT32_MAX), 0) + buf.writeUInt32BE(counter % UINT32_MAX, 4) + return buf +} + +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 + + const seconds = Math.floor(now / 1000) + const counter = Math.floor(seconds / params.period) + const secondsRemaining = params.period - (seconds % params.period) + + const digest = createHmac(params.algorithm.toLowerCase(), Buffer.from(key)) + .update(counterToBuffer(counter)) + .digest() + + 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 } +} diff --git a/KeeperSdk/src/sharedFolders/SharedFolderManager.ts b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts new file mode 100644 index 0000000..bac940f --- /dev/null +++ b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts @@ -0,0 +1,57 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + formatSharedFoldersTable, + listSharedFolders, + renderSharedFoldersAsciiTable, +} from './listSharedFolders' +import type { + FormattedSharedFoldersTable, + ListSharedFolderRow, + ListSharedFoldersOptions, +} from './listSharedFolders' +import { shareFolder } from './shareFolder' +import type { ShareFolderInput, ShareFolderResult } from './shareFolder' + +export type AuthProvider = () => Auth + +export class SharedFolderManager { + 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) { + 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 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 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 new file mode 100644 index 0000000..ac484bf --- /dev/null +++ b/KeeperSdk/src/sharedFolders/listSharedFolders.ts @@ -0,0 +1,173 @@ +import type { + DSharedFolder, + DSharedFolderRecord, + DSharedFolderTeam, + DSharedFolderUser, +} from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { TOKEN_SEPARATOR_PATTERN } from '../utils' +import { FolderKind, VaultObjectKind } from '../folders/folderHelpers' + +export type ListSharedFoldersOptions = { + pattern?: string | null + verbose?: boolean + includeDetails?: 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 +} + +const DEFAULT_COLUMN_WIDTH = 40 +const MIN_TRUNCATE_PREFIX = 3 + +function sharedFolderDisplayName(folder: DSharedFolder): string { + 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) + } + } + 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 + } + } + } + return false +} + +function tokenize(text: string): string[] { + 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 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 countRecordsForFolder(storage: InMemoryStorage, sharedFolderUid: string): number { + return countBySharedFolderUid( + storage.getAll(VaultObjectKind.SharedFolderRecord), + sharedFolderUid + ) +} + +export function listSharedFolders( + storage: InMemoryStorage, + options: ListSharedFoldersOptions = {} +): ListSharedFolderRow[] { + const { pattern, includeDetails = false } = options + const sharedFolders: DSharedFolder[] = pattern + ? findSharedFolders(storage, pattern) + : storage.getAll(FolderKind.SharedFolder) + + return sharedFolders.map((sharedFolder) => { + const shared_folder_uid = sharedFolder.uid + const name = sharedFolderDisplayName(sharedFolder) + const row: ListSharedFolderRow = { shared_folder_uid, name } + if (includeDetails) { + row.record_count = countRecordsForFolder(storage, shared_folder_uid) + row.user_count = countUsersForFolder(storage, shared_folder_uid) + row.team_count = countTeamsForFolder(storage, shared_folder_uid) + row.default_manage_records = sharedFolder.defaultManageRecords + row.default_manage_users = sharedFolder.defaultManageUsers + row.default_can_edit = sharedFolder.defaultCanEdit + row.default_can_share = sharedFolder.defaultCanShare + } + return row + }) +} + +export type FormattedSharedFoldersTable = { + 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)}...` +} + +export function formatSharedFoldersTable( + 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 } +} + +export function renderSharedFoldersAsciiTable( + 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) + 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') +} diff --git a/KeeperSdk/src/sharedFolders/shareFolder.ts b/KeeperSdk/src/sharedFolders/shareFolder.ts new file mode 100644 index 0000000..98d622c --- /dev/null +++ b/KeeperSdk/src/sharedFolders/shareFolder.ts @@ -0,0 +1,457 @@ +import type { + 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' + +export enum ShareFolderAction { + Grant = 'grant', + Remove = 'remove', +} + +export type ShareFolderActionInput = ShareFolderAction | `${ShareFolderAction}` + +export enum ShareFolderUserResultStatus { + Success = 'success', + Invited = 'invited', + MissingPublicKey = 'missing_public_key', + Unknown = 'unknown', +} + +export type ShareFolderInput = { + folder: string + emails: string[] + action?: ShareFolderActionInput + manageRecords?: boolean + manageUsers?: boolean +} + +export type ShareFolderUserStatus = { + email: string + success: boolean + status: string + message?: string +} + +export type ShareFolderResult = { + 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 } + +type UserPublicKeys = { + 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 +} + +function dataName(data: unknown): string { + 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, + } + } + } + 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, + } + } + } + + 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 +} + +function dedupeEmails(emails: string[]): string[] { + const seen = new Set() + const dedupedEmails: string[] = [] + for (const rawEmail of emails) { + const normalized = (rawEmail || '').trim().toLowerCase() + if (!normalized) continue + if (seen.has(normalized)) continue + seen.add(normalized) + dedupedEmails.push(normalized) + } + return dedupedEmails +} + +async function removeFromSharedFolder( + auth: Auth, + sharedFolder: DSharedFolder, + resolved: Extract, + 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 + + 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 +): 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 }, + }) + } + + 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 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 +): 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) +} diff --git a/KeeperSdk/src/sharing/Sharing.ts b/KeeperSdk/src/sharing/Sharing.ts index 97a3b5e..9046f36 100644 --- a/KeeperSdk/src/sharing/Sharing.ts +++ b/KeeperSdk/src/sharing/Sharing.ts @@ -4,6 +4,7 @@ import { Authentication, platform, getPublicKeysMessage, + getRecordsDetailsMessage, webSafe64FromBytes, recordsShareUpdateMessage, normal64Bytes, @@ -78,12 +79,8 @@ async function loadUserPublicKey(auth: Auth, email: string): Promise { 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, + 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, } } @@ -132,7 +129,13 @@ export async function shareRecord( try { response = await auth.executeRest(msg) } catch (err) { - return { recordUid, email, success: false, status: ShareStatus.Error, message: extractErrorMessage(err) } + return { + recordUid, + email, + success: false, + status: ShareStatus.Error, + message: extractErrorMessage(err), + } } const addStatuses = response.addSharedRecordStatus || [] @@ -148,27 +151,38 @@ export async function shareRecord( } } - return { recordUid, email, success: true, status: ShareStatus.Success, message: 'Record shared successfully' } + return { + recordUid, + email, + success: true, + status: ShareStatus.Success, + message: 'Record shared successfully', + } } -export async function removeRecordShare( - auth: Auth, - input: RemoveShareInput -): Promise { +export async function removeRecordShare(auth: Auth, input: RemoveShareInput): Promise { const { recordUid, email } = input const msg = recordsShareUpdateMessage({ - removeSharedRecord: [{ - toUsername: email, - recordUid: normal64Bytes(recordUid), - }], + 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) } + return { + recordUid, + email, + success: false, + status: ShareStatus.Error, + message: extractErrorMessage(err), + } } const removeStatuses = response.removeSharedRecordStatus || [] @@ -183,5 +197,86 @@ export async function removeRecordShare( } } - return { recordUid, email, success: true, status: ShareStatus.Success, message: 'Share removed successfully' } + 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 +} + +export type RecordSharedFolderPermission = { + sharedFolderUid: string + resharable: boolean + editable: boolean + revision?: number + expiration?: number +} + +export type RecordShareInfo = { + recordUid: string + userPermissions: RecordUserPermission[] + sharedFolderPermissions: RecordSharedFolderPermission[] +} + +function bytesToUid(bytes: Uint8Array | null | undefined): string | 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() +} + +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 91cfcd1..89424b9 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -8,13 +8,15 @@ import type { 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 deps = new Map() + private dependenciesByParent = new Map() private arrayCache = new Map() public async getKeyBytes(keyId: string): Promise { @@ -55,9 +57,7 @@ export class InMemoryStorage implements VaultStorage { } public async delete(kind: VaultStorageKind, uid: string | Uint8Array): Promise { - const uidStr = typeof uid === 'string' - ? uid - : Buffer.from(uid).toString('base64url') + const uidStr = typeof uid === 'string' ? uid : webSafe64FromBytes(uid) this.store.get(kind)?.delete(uidStr) this.arrayCache.delete(kind) } @@ -66,25 +66,25 @@ export class InMemoryStorage implements VaultStorage { this.store.clear() this.keys.clear() this.objects.clear() - this.deps.clear() + this.dependenciesByParent.clear() this.arrayCache.clear() } public async getDependencies(uid: string): Promise { - return this.deps.get(uid) + return this.dependenciesByParent.get(uid) } public async addDependencies(dependencies: Dependencies): Promise { for (const [parentUid, children] of Object.entries(dependencies)) { - if (!this.deps.has(parentUid)) { - this.deps.set(parentUid, []) + if (!this.dependenciesByParent.has(parentUid)) { + this.dependenciesByParent.set(parentUid, []) } - const existing = this.deps.get(parentUid)! - const seen = new Set(existing.map(d => d.uid)) + const existing = this.dependenciesByParent.get(parentUid)! + const seenChildUids = new Set(existing.map((dependency) => dependency.uid)) for (const child of children) { - if (!seen.has(child.uid)) { + if (!seenChildUids.has(child.uid)) { existing.push(child) - seen.add(child.uid) + seenChildUids.add(child.uid) } } } @@ -93,14 +93,14 @@ export class InMemoryStorage implements VaultStorage { public async removeDependencies(dependencies: RemovedDependencies): Promise { for (const [parentUid, children] of Object.entries(dependencies)) { if (children === '*') { - this.deps.delete(parentUid) + this.dependenciesByParent.delete(parentUid) } else { - const existing = this.deps.get(parentUid) + const existing = this.dependenciesByParent.get(parentUid) if (existing) { const removeSet = children as Set - this.deps.set( + this.dependenciesByParent.set( parentUid, - existing.filter((d) => !removeSet.has(d.uid)) + existing.filter((dependency) => !removeSet.has(dependency.uid)) ) } } @@ -120,7 +120,7 @@ export class InMemoryStorage implements VaultStorage { } public getRecords(): DRecord[] { - return this.getAll('record') + return this.getAll(VaultObjectKind.Record) } public getByUid(kind: VaultStorageKind, uid: string): T | undefined { @@ -137,20 +137,27 @@ export class InMemoryStorage implements VaultStorage { token?: string sharedFolderUid?: string recordUid?: string - accountUid?: 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 && record.accountUid) { - return `${record.sharedFolderUid}:${record.accountUid}` + if (record.sharedFolderUid && accountUidStr) { + return `${record.sharedFolderUid}:${accountUidStr}` } if (record.sharedFolderUid && record.teamUid) { return `${record.sharedFolderUid}:${record.teamUid}` } + if (item.kind === VaultObjectKind.User && accountUidStr) return accountUidStr return '_singleton_' } } diff --git a/KeeperSdk/src/utils/guards.ts b/KeeperSdk/src/utils/guards.ts new file mode 100644 index 0000000..972f776 --- /dev/null +++ b/KeeperSdk/src/utils/guards.ts @@ -0,0 +1,24 @@ +export function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean' +} + +export function isString(value: unknown): value is string { + return typeof value === 'string' +} + +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +export function isNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function isObject(value: unknown): value is Record { + 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) +} diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index 6b234ef..ed3825a 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -2,3 +2,15 @@ export { SdkDefaults, AuthDefaults, ResultCodes, KEEPER_PUBLIC_HOSTS } from './c 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' +export { + EMAIL_PATTERN, + EMAIL_LIST_SEPARATOR_PATTERN, + TOKEN_SEPARATOR_PATTERN, + REGEX_ESCAPE_PATTERN, + TRAILING_EQUALS_PATTERN, + WHITESPACE_PATTERN, + isValidEmail, + escapeRegExp, +} from './patterns' diff --git a/KeeperSdk/src/utils/patterns.ts b/KeeperSdk/src/utils/patterns.ts new file mode 100644 index 0000000..c4e186b --- /dev/null +++ b/KeeperSdk/src/utils/patterns.ts @@ -0,0 +1,24 @@ +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +/** Splits user-entered email lists on whitespace, commas, and semicolons. */ +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\-_.,;:!?@#$%^&*()[\]{}|\\/<>]+/ + +/** Characters that must be escaped when embedding user input into a RegExp. */ +export const REGEX_ESCAPE_PATTERN = /[.+^${}()|[\]\\]/g + +/** 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 EMAIL_PATTERN.test(value) +} + +export function escapeRegExp(value: string): string { + return value.replace(REGEX_ESCAPE_PATTERN, '\\$&') +} diff --git a/KeeperSdk/src/utils/types.ts b/KeeperSdk/src/utils/types.ts new file mode 100644 index 0000000..02113ea --- /dev/null +++ b/KeeperSdk/src/utils/types.ts @@ -0,0 +1,11 @@ +/** A value of type T or null. Use this for caches and computed-but-empty results. */ +export type Nullable = T | null + +/** A value of type T or undefined. Use this for "not provided" inputs. */ +export type Optional = T | undefined + +/** Recursively makes every property optional. */ +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] } diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 524ad14..f0551e2 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -34,13 +34,28 @@ import type { import { shareRecord as shareRecordOp, removeRecordShare as removeRecordShareOp, + getRecordShareInfo as getRecordShareInfoOp, } from '../sharing/Sharing' import type { ShareRecordInput, ShareRecordResult, RemoveShareInput, RemoveShareResult, + RecordShareInfo, } from '../sharing/Sharing' +import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' +import { FolderKind, VaultObjectKind } from '../folders/folderHelpers' +import type { ChangeDirectoryResult, VaultFolderSession } from '../folders/changeDirectory' +import type { AddFolderInput, AddFolderResult, MkdirOptions } from '../folders/addFolder' +import type { UpdateFolderInput, UpdateFolderResult, RenameFolderResult } from '../folders/updateFolder' +import type { DeleteFolderResult, RmdirOptions } from '../folders/deleteFolder' +import type { FolderTreeBuildOptions } from '../folders/folderTree' +import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' +import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' +import type { ShareFolderInput, ShareFolderResult } from '../sharedFolders/shareFolder' +import { FolderManager } from '../folders/FolderManager' +import type { SharedFolderPermissionsInput } from '../folders/FolderManager' +import { SharedFolderManager } from '../sharedFolders/SharedFolderManager' import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' import type { ILogger } from '../utils' @@ -78,6 +93,9 @@ export class KeeperVault { private readonly log: ILogger private synced = false private batchDepth = 0 + private readonly folderSession: VaultFolderSession = FolderManager.createSession() + private readonly folderManager: FolderManager + private readonly sharedFolderManager: SharedFolderManager constructor(config?: KeeperVaultConfig) { this.config = { @@ -94,6 +112,18 @@ export class KeeperVault { this.storage = config?.storage || new InMemoryStorage() this.sessionManager = config?.sessionStorage || new SessionManager(this.config.configDir || undefined) this.authUI = config?.authUI || new ConsoleAuthUI() + + const authProvider = () => this.getAuthOrThrow() + this.folderManager = new FolderManager(this.storage, this.folderSession, authProvider) + this.sharedFolderManager = new SharedFolderManager(this.storage, authProvider) + } + + public getFolderManager(): FolderManager { + return this.folderManager + } + + public getSharedFolderManager(): SharedFolderManager { + return this.sharedFolderManager } private async createAuth(options?: { useSessionResumption?: boolean }): Promise { @@ -104,14 +134,15 @@ export class KeeperVault { 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 + 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, @@ -189,6 +220,7 @@ export class KeeperVault { ResultCodes.NO_PREVIOUS_LOGIN ) } + this.sessionManager.setLastUsername(username) const deviceConfig = await this.sessionManager.getDeviceConfig(this.config.host) @@ -267,15 +299,15 @@ export class KeeperVault { } public getRecordByUid(uid: string): DRecord | undefined { - return this.storage.getByUid('record', uid) + return this.storage.getByUid(VaultObjectKind.Record, uid) } public findRecord(uidOrTitle: string): DRecord | undefined { const byUid = this.getRecordByUid(uidOrTitle) if (byUid) return byUid - const needle = uidOrTitle.toLowerCase() - return this.getRecords().find((r) => getRecordTitle(r).toLowerCase() === needle) + const lowerUidOrTitle = uidOrTitle.toLowerCase() + return this.getRecords().find((record) => getRecordTitle(record).toLowerCase() === lowerUidOrTitle) } public findRecords(criteria: string): DRecord[] { @@ -283,39 +315,119 @@ export class KeeperVault { } public getRecordsByVersion(version: number): DRecord[] { - return this.getRecords().filter((r) => r.version === version) + return this.getRecords().filter((record) => record.version === version) } public getRecordsByType(recordType: string): DRecord[] { - return this.getRecords().filter((r) => getRecordType(r) === recordType) + return this.getRecords().filter((record) => getRecordType(record) === recordType) } public getRecordMetadata(): DRecordMetadata[] { - return this.storage.getAll('metadata') + return this.storage.getAll(VaultObjectKind.Metadata) } public getRecordMetadataByUid(uid: string): DRecordMetadata | undefined { - return this.storage.getByUid('metadata', uid) + return this.storage.getByUid(VaultObjectKind.Metadata, uid) } public getSharedFolders(): DSharedFolder[] { - return this.storage.getAll('shared_folder') + return this.storage.getAll(FolderKind.SharedFolder) } public getTeams(): DTeam[] { - return this.storage.getAll('team') + return this.storage.getAll(VaultObjectKind.Team) } public getUserFolders(): DUserFolder[] { - return this.storage.getAll('user_folder') + 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 changeDirectory(path: string): Promise { + return this.folderManager.changeDirectory(path) + } + + public getCurrentFolderUid(): string | null { + return this.folderManager.getCurrentFolderUid() + } + + public getWorkingFolderDisplayName(): string { + return this.folderManager.getWorkingFolderDisplayName() + } + + public async getFolder(uidOrName: string, options?: GetFolderOptions): Promise { + return this.folderManager.getFolder(uidOrName, options ?? {}) + } + + public async addFolder(input: AddFolderInput): Promise { + const result = await this.folderManager.addFolder(input) + if (result.success) await this.syncIfNeeded() + return result + } + + public async mkdir( + path: string, + options?: MkdirOptions + ): Promise<{ folderUid: string; success: boolean; message?: string }> { + const result = await this.folderManager.mkdir(path, options ?? {}) + if (result.success) await this.syncIfNeeded() + return result + } + + public async updateFolder(input: UpdateFolderInput): Promise { + const result = await this.folderManager.updateFolder(input) + if (result.success) await this.syncIfNeeded() + return result + } + + public async renameFolder(folderPath: string, newName: string): Promise { + const result = await this.folderManager.renameFolder(folderPath, newName) + if (result.success) await this.syncIfNeeded() + return result + } + + public async updateSharedFolderPermissions( + sharedFolderUid: string, + permissions: SharedFolderPermissionsInput + ): Promise { + const result = await this.folderManager.updateSharedFolderPermissions(sharedFolderUid, permissions) + if (result.success) await this.syncIfNeeded() + return result + } + + public async deleteFolder( + folderRefs: string[], + confirm?: (summary: string) => boolean | Promise + ): Promise { + const result = await this.folderManager.deleteFolder(folderRefs, confirm) + if (result.success) await this.syncIfNeeded() + return result + } + + public async rmdir(patterns: string[], options?: RmdirOptions): Promise { + const result = await this.folderManager.rmdir(patterns, options ?? {}) + if (result.success) await this.syncIfNeeded() + return result + } + + public async tree(options?: FolderTreeBuildOptions): Promise { + this.getAuthOrThrow() + return this.folderManager.folderTreeAscii(options ?? {}) } public getSummary(): VaultSummary { return { - recordCount: this.storage.getCount('record'), - sharedFolderCount: this.storage.getCount('shared_folder'), - teamCount: this.storage.getCount('team'), - folderCount: this.storage.getCount('user_folder'), + recordCount: this.storage.getCount(VaultObjectKind.Record), + sharedFolderCount: this.storage.getCount(FolderKind.SharedFolder), + teamCount: this.storage.getCount(VaultObjectKind.Team), + folderCount: this.storage.getCount(FolderKind.UserFolder), } } @@ -348,7 +460,11 @@ export class KeeperVault { const keyBytes = await this.storage.getKeyBytes(recordUid) if (!keyBytes) { - return { recordUid, success: false, status: VaultStatus.RecordKeyNotFound } + return { + recordUid, + success: false, + status: VaultStatus.RecordKeyNotFound, + } } const result = await updateRecordOp(auth, recordUid, data, record.revision, keyBytes) @@ -373,8 +489,7 @@ export class KeeperVault { public async shareRecord(input: ShareRecordInput): Promise { const auth = this.getAuthOrThrow() - const record = this.getRecordByUid(input.recordUid) - || this.findRecord(input.recordUid) + const record = this.getRecordByUid(input.recordUid) || this.findRecord(input.recordUid) if (!record) { return { recordUid: input.recordUid, @@ -396,7 +511,10 @@ export class KeeperVault { } } - const result = await shareRecordOp(auth, keyBytes, { ...input, recordUid: record.uid }) + const result = await shareRecordOp(auth, keyBytes, { + ...input, + recordUid: record.uid, + }) if (result.success) await this.syncIfNeeded() return result } @@ -408,6 +526,17 @@ export class KeeperVault { return result } + public async getRecordShareInfo(recordUid: string): Promise { + const auth = this.getAuthOrThrow() + return getRecordShareInfoOp(auth, recordUid) + } + + 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() @@ -429,17 +558,22 @@ export class KeeperVault { public disconnect(): void { if (this.auth) { - try { this.auth.disconnect() } catch (err) { + 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) { + try { + await this.auth.logout() + } catch (err) { this.log.debug('logout error:', extractErrorMessage(err)) } } diff --git a/KeeperSdk/tsconfig.json b/KeeperSdk/tsconfig.json index 3e2083d..74c9931 100644 --- a/KeeperSdk/tsconfig.json +++ b/KeeperSdk/tsconfig.json @@ -7,7 +7,9 @@ "skipLibCheck": true, "esModuleInterop": true, "declaration": true, - "outDir": "dist" + "rootDir": ".", + "outDir": "dist", + "types": ["node"] }, "include": ["src"], "exclude": ["node_modules", "dist"] diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index b14b476..217d0b1 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -1,7 +1,7 @@ { "name": "sdk-example", "version": "1.0.0", - "description": "Example usage of the KeeperSdk wrapper — mirrors Python SDK examples", + "description": "Example usage of the KeeperSdk wrapper", "scripts": { "auth:login": "ts-node src/auth/login.ts", "auth:session-token": "ts-node src/auth/session_token_login.ts", @@ -14,6 +14,15 @@ "records:find-password": "ts-node src/records/find_password.ts", "records:move": "ts-node src/records/move_record.ts", "sharing:share-record": "ts-node src/sharing/share_record.ts", + "folders:ls": "ts-node src/folders/list_folder.ts", + "folders:cd": "ts-node src/folders/change_directory.ts", + "folders:get": "ts-node src/folders/get_folder.ts", + "folders:mkdir": "ts-node src/folders/mkdir.ts", + "folders:updatedir": "ts-node src/folders/updatedir.ts", + "folders:removedir": "ts-node src/folders/removedir.ts", + "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", "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/folders/change_directory.ts b/examples/sdk_example/src/folders/change_directory.ts new file mode 100644 index 0000000..2391fe6 --- /dev/null +++ b/examples/sdk_example/src/folders/change_directory.ts @@ -0,0 +1,27 @@ +import { login, cleanup, logger, prompt, extractErrorMessage } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function cdCommand() { + const vault = await login() + + try { + const target = (await prompt('Folder name or UID: ')).trim() + if (!target) { + logger.info('No folder name or UID given.') + return + } + + try { + const result = await vault.changeDirectory(target) + logger.info(`Working folder: ${result.name}`) + logger.info(` (UID: ${result.folderUid || '(vault root)'})`) + } catch (err) { + logger.error(`Failed to change directory: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } + } finally { + cleanup(vault) + } +} + +runExample(cdCommand) diff --git a/examples/sdk_example/src/folders/get_folder.ts b/examples/sdk_example/src/folders/get_folder.ts new file mode 100644 index 0000000..db9a412 --- /dev/null +++ b/examples/sdk_example/src/folders/get_folder.ts @@ -0,0 +1,88 @@ +import { + cleanup, + extractErrorMessage, + FolderObjectType, + GetFolderFormat, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import type { GetFolderResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +const LABEL_WIDTH = 22 + +function row(label: string, value: string | number | boolean): void { + logger.info(`${label.padEnd(LABEL_WIDTH)} ${value}`) +} + +function printFolder(result: GetFolderResult): void { + if (result.objectType === FolderObjectType.Folder) { + row('Folder UID:', result.folder_uid) + row('Folder Type:', result.folder_type) + row('Name:', result.name) + row('Parent Folder UID:', result.parent_uid || '(root)') + if (result.shared_folder_scope_uid) { + row('Shared Folder UID:', result.shared_folder_scope_uid) + } + return + } + + row('Shared Folder UID:', result.shared_folder_uid) + row('Name:', result.name) + row('Default Can Edit:', result.default_can_edit) + row('Default Can Share:', result.default_can_share) + row('Default Manage Records:', result.default_manage_records) + row('Default Manage Users:', result.default_manage_users) + if (result.record_permissions?.length) { + logger.info('Record permissions:') + for (const recordPermission of result.record_permissions) { + logger.info( + ` ${recordPermission.record_uid} edit=${recordPermission.can_edit} share=${recordPermission.can_share} owner=${recordPermission.owner}` + ) + } + } + if (result.user_permissions?.length) { + logger.info('User permissions:') + for (const userPermission of result.user_permissions) { + logger.info( + ` ${userPermission.account_username || '?'} manage_records=${userPermission.manage_records} manage_users=${userPermission.manage_users}` + ) + } + } +} + +async function getCommand() { + const vault = await login() + + try { + const target = (await prompt('Folder name or UID: ')).trim() + if (!target) { + logger.info('No folder name or UID given.') + return + } + + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + + try { + const result = await vault.getFolder(target, { + format: asJson ? GetFolderFormat.JSON : GetFolderFormat.Detail, + }) + logger.info('') + if (asJson) { + logger.info(JSON.stringify(result.json || result, null, 2)) + } else { + printFolder(result) + } + logger.info('') + } catch (err) { + logger.error(`Folder lookup failed for "${target}": ${extractErrorMessage(err)}`) + process.exitCode = 1 + } + } finally { + cleanup(vault) + } +} + +runExample(getCommand) diff --git a/examples/sdk_example/src/folders/list_folder.ts b/examples/sdk_example/src/folders/list_folder.ts new file mode 100644 index 0000000..b487dbc --- /dev/null +++ b/examples/sdk_example/src/folders/list_folder.ts @@ -0,0 +1,135 @@ +import { login, cleanup, logger, prompt, extractErrorMessage } from '@keeper-security/keeper-sdk-javascript' +import type { ListFolderResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +type LsMode = 'default' | 'list' | 'records' | 'folders' + +const MODE_BY_INPUT: Record = { + '': 'default', + '1': 'default', + '2': 'list', + '3': 'records', + '4': 'folders', + 'd': 'default', + 'default': 'default', + 'l': 'list', + 'list': 'list', + 'r': 'records', + 'records': 'records', + 'f': 'folders', + 'folders': 'folders', +} + +const UID_COL_WIDTH = 36 +const UID_TRUNCATED_PREFIX = 18 + +function parseMode(input: string): LsMode { + const mode = MODE_BY_INPUT[input.trim().toLowerCase()] + if (!mode) { + logger.warn(`Unknown choice "${input}" — using default.`) + return 'default' + } + return mode +} + +function truncateLabel(label: string, verbose: boolean, max = 40): string { + if (verbose || label.length <= max) return label + return label.slice(0, 25) + '...' + label.slice(-12) +} + +function printColumnar(cells: string[], termWidth: number): void { + if (cells.length === 0) return + const maxCellLength = Math.max(...cells.map((cell) => cell.length), 1) + const gap = 2 + const colWidth = maxCellLength + gap + const columnCount = Math.max(1, Math.floor((termWidth + gap) / colWidth)) + const rowCount = Math.ceil(cells.length / columnCount) + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const parts: string[] = [] + for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { + const cellIndex = rowIndex * columnCount + columnIndex + if (cellIndex >= cells.length) break + parts.push(cells[cellIndex].padEnd(maxCellLength)) + } + logger.info(parts.join(' ')) + } +} + +function formatUidCell(uid: string): string { + return uid.length > UID_COL_WIDTH + ? `${uid.slice(0, UID_TRUNCATED_PREFIX)}...` + : uid.padEnd(UID_COL_WIDTH) +} + +function printDetail(result: ListFolderResult): void { + if (!result.detail) return + logger.info('') + logger.info('# Flags UID Name Type') + logger.info('- ----- ------------------------------------ ------------------------ ----------') + let rowNumber = 1 + for (const folder of result.folders) { + logger.info( + `${String(rowNumber++).padStart(2)} ${folder.flags} ${formatUidCell(folder.uid)} ${folder.name}`.trimEnd() + ) + } + for (const record of result.records) { + logger.info( + `${String(rowNumber++).padStart(2)} ${record.flags} ${formatUidCell(record.uid)} ${record.name.padEnd(24)} ${record.type}` + ) + } +} + +function printMenu(): void { + logger.info('Display mode:') + logger.info(' 1. Default (full names)') + logger.info(' 2. List (detailed table with flags, UIDs, types)') + logger.info(' 3. Records (records only)') + logger.info(' 4. Folders (folders only)') +} + +async function lsCommand() { + const vault = await login() + + try { + printMenu() + const mode = parseMode(await prompt('Choose [1]: ')) + + const detail = mode === 'list' + const showFolders = mode !== 'records' + const showRecords = mode !== 'folders' + const verbose = mode === 'default' + + let result: ListFolderResult + try { + result = await vault.listFolder({ + folderUid: vault.getCurrentFolderUid(), + showFolders, + showRecords, + detail, + }) + } catch (err) { + logger.error(`Failed to list folder: ${extractErrorMessage(err)}`) + process.exitCode = 1 + return + } + + if (result.folders.length === 0 && result.records.length === 0) { + logger.info('Folder is empty.') + return + } + + const width = Math.max(40, process.stdout.columns || 80) + + if (result.detail) { + printDetail(result) + } else { + const folderCells = result.folders.map((folder) => `${truncateLabel(folder.name, verbose)}/`) + const recordCells = result.records.map((record) => truncateLabel(record.name, verbose)) + printColumnar([...folderCells, ...recordCells], width) + } + } finally { + cleanup(vault) + } +} + +runExample(lsCommand) diff --git a/examples/sdk_example/src/folders/mkdir.ts b/examples/sdk_example/src/folders/mkdir.ts new file mode 100644 index 0000000..3929c93 --- /dev/null +++ b/examples/sdk_example/src/folders/mkdir.ts @@ -0,0 +1,47 @@ +import { login, cleanup, logger, prompt, suppressLogs, extractErrorMessage } from '@keeper-security/keeper-sdk-javascript' +import type { MkdirOptions } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function mkdirCommand() { + const vault = await login() + + try { + const path = (await prompt('Folder path to create: ')).trim() + if (!path) { + logger.info('No path given.') + return + } + + const sharedFolder = isYes(await prompt('Create as shared folder? [y/N]: ')) + const options: MkdirOptions = { sharedFolder } + if (sharedFolder) { + options.grantAll = isYes( + await prompt('Grant all default permissions (manage users/records, share, edit)? [y/N]: '), + ) + } + + try { + let result + const restore = suppressLogs() + try { + result = await vault.mkdir(path, options) + } finally { + restore() + } + if (result.success) { + logger.info(`Created folder UID: ${result.folderUid}`) + } else { + logger.error(`Failed: ${result.message || 'unknown error'}`) + process.exitCode = 1 + } + } catch (err) { + logger.error(`Failed to create folder: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } + } finally { + cleanup(vault) + } +} + +runExample(mkdirCommand) diff --git a/examples/sdk_example/src/folders/removedir.ts b/examples/sdk_example/src/folders/removedir.ts new file mode 100644 index 0000000..d9e5024 --- /dev/null +++ b/examples/sdk_example/src/folders/removedir.ts @@ -0,0 +1,52 @@ +import { login, cleanup, logger, prompt, suppressLogs, extractErrorMessage } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function removedirCommand() { + const vault = await login() + + try { + const line = (await prompt('Folder(s) to remove (path, UID, name, or glob; space-separated): ')).trim() + const patterns = line.split(/\s+/).filter(Boolean) + if (patterns.length === 0) { + logger.info('No folders given.') + return + } + + let restore = suppressLogs() + try { + const result = await vault.rmdir(patterns, { + confirm: async (summary) => { + restore() + try { + logger.info(summary) + return isYes(await prompt('Do you want to proceed? (y/n) ')) + } finally { + restore = suppressLogs() + } + }, + }) + + restore() + + if (result.cancelled) { + logger.info('Cancelled.') + return + } + if (result.success) { + logger.info('Removal completed.') + } else { + logger.error(`Failed: ${result.message || 'unknown error'}`) + process.exitCode = 1 + } + } catch (err) { + restore() + logger.error(`Failed to remove folder(s): ${extractErrorMessage(err)}`) + process.exitCode = 1 + } + } finally { + cleanup(vault) + } +} + +runExample(removedirCommand) diff --git a/examples/sdk_example/src/folders/tree.ts b/examples/sdk_example/src/folders/tree.ts new file mode 100644 index 0000000..796cccb --- /dev/null +++ b/examples/sdk_example/src/folders/tree.ts @@ -0,0 +1,38 @@ +import { login, cleanup, logger, prompt, extractErrorMessage } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function treeCommand() { + const vault = await login() + + try { + const folder = (await prompt('Folder path or UID (Enter for current/root): ')).trim() + const verbose = isYes(await prompt('Show UIDs next to names? [y/N]: ')) + const showRecords = isYes(await prompt('Include records under each folder? [y/N]: ')) + const showShares = isYes(await prompt('Show share permissions for shared folders? [y/N]: ')) + const hideSharesKey = showShares + ? isYes(await prompt('Hide [User]/[Team] suffix on share lines? [y/N]: ')) + : false + const titleInput = (await prompt('Custom title above the tree (Enter to skip): ')).trim() + logger.info('') + try { + const ascii = await vault.tree({ + folderPath: folder || undefined, + verbose, + showRecords, + showShares, + hideSharesKey, + title: titleInput || null, + }) + logger.info(ascii) + } catch (err) { + logger.error(`Failed to render tree: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } + logger.info('') + } finally { + cleanup(vault) + } +} + +runExample(treeCommand) diff --git a/examples/sdk_example/src/folders/updatedir.ts b/examples/sdk_example/src/folders/updatedir.ts new file mode 100644 index 0000000..b59c066 --- /dev/null +++ b/examples/sdk_example/src/folders/updatedir.ts @@ -0,0 +1,44 @@ +import { login, cleanup, logger, prompt, suppressLogs, extractErrorMessage } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function updatedirCommand() { + const vault = await login() + + try { + const folder = (await prompt('Folder to rename (path, name, or UID): ')).trim() + if (!folder) { + logger.info('No folder given.') + return + } + + const newName = (await prompt('New folder name: ')).trim() + if (!newName) { + logger.info('New name is required.') + return + } + + try { + let result + const restore = suppressLogs() + try { + result = await vault.renameFolder(folder, newName) + } finally { + restore() + } + if (result.success) { + logger.info(`Folder "${result.oldName}" has been renamed to "${result.newName}"`) + logger.info(` UID: ${result.folderUid}`) + } else { + logger.error(`Failed: ${result.message || 'unknown error'}`) + process.exitCode = 1 + } + } catch (err) { + logger.error(`Failed to rename folder: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } + } finally { + cleanup(vault) + } +} + +runExample(updatedirCommand) diff --git a/examples/sdk_example/src/records/get_record.ts b/examples/sdk_example/src/records/get_record.ts index 571ad02..9ed05cb 100644 --- a/examples/sdk_example/src/records/get_record.ts +++ b/examples/sdk_example/src/records/get_record.ts @@ -2,117 +2,109 @@ import { login, cleanup, prompt, + suppressLogs, getRecordTitle, getRecordType, - getRecordFields, getRecordLogin, getRecordPassword, getRecordUrl, + getRecordTotpUrl, + getTotpCode, + extractErrorMessage, logger, } from '@keeper-security/keeper-sdk-javascript' +import type { DRecord, KeeperVault, RecordUserPermission } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { formatFieldValue, LEGACY_RECORD_MAX_VERSION } from '../utils/format' +import { isYes } from '../utils/format' + +const MASKED_VALUE = '********' +const LABEL_WIDTH = 20 + +function formatRow(label: string, value: string): string { + return `${label.padStart(LABEL_WIDTH)}: ${value}` +} + +function displayRecord(record: DRecord, unmask: boolean): void { + logger.info(`UID: ${record.uid}`) + logger.info(formatRow('Type', getRecordType(record))) + logger.info(formatRow('Title', getRecordTitle(record))) + + const loginVal = getRecordLogin(record) + const password = getRecordPassword(record) + const url = getRecordUrl(record) + + if (loginVal) logger.info(formatRow('login', loginVal)) + if (password) logger.info(formatRow('password', unmask ? password : MASKED_VALUE)) + if (url) logger.info(formatRow('url', url)) + + const totpUrl = getRecordTotpUrl(record) + if (totpUrl) { + logger.info(formatRow('TOTP URL', unmask ? totpUrl : MASKED_VALUE)) + const code = getTotpCode(totpUrl) + if (code) { + logger.info(formatRow('Two Factor Code', `${code.code} valid for ${code.secondsRemaining} sec`)) + } + } +} + +async function displayUserPermissions(vault: KeeperVault, uid: string): Promise { + const restore = suppressLogs() + let info + try { + info = await vault.getRecordShareInfo(uid) + } catch (err) { + logger.warn(`\nCould not load share information: ${extractErrorMessage(err)}`) + return + } finally { + restore() + } + + if (!info || info.userPermissions.length === 0) return + + logger.info('\nUser Permissions:') + for (const u of info.userPermissions) { + printUserPermission(u) + } +} + +function printUserPermission(u: RecordUserPermission): void { + logger.info('') + if (u.username) logger.info(`User: ${u.username}`) + if (u.accountUid) logger.info(`User UID: ${u.accountUid}`) + if (u.owner) logger.info('Owner: Yes') + logger.info(`Shareable: ${u.shareable ? 'Yes' : 'No'}`) + logger.info(`Read-Only: ${u.editable ? 'No' : 'Yes'}`) +} async function getRecord() { const vault = await login() try { - const records = vault.getRecords() - - if (records.length === 0) { + if (vault.getRecords().length === 0) { logger.info('No records found in vault.') return } const searchInput = await prompt('Enter record UID or title: ') - if (!searchInput) { logger.info('No input provided. Exiting.') return } const record = vault.findRecord(searchInput) - if (!record) { logger.info(`\nRecord "${searchInput}" not found.`) return } - const title = getRecordTitle(record) - const type = getRecordType(record) - const version = record.version - - logger.info('\n' + '-'.repeat(50)) - logger.info('Record Details') - logger.info('-'.repeat(50)) - logger.info(` Title: ${title}`) - logger.info(` Record UID: ${record.uid}`) - logger.info(` Version: ${version}`) - logger.info(` Revision: ${record.revision}`) - logger.info(` Shared: ${record.shared}`) - - if (version <= LEGACY_RECORD_MAX_VERSION) { - displayLegacyRecord(record) - } else { - displayTypedRecord(record, type) - } + const unmask = isYes(await prompt('Unmask sensitive fields? [y/N]: ')) - displayMetadata(vault, record.uid) - logger.info('-'.repeat(50)) + logger.info('') + displayRecord(record, unmask) + await displayUserPermissions(vault, record.uid) } finally { cleanup(vault) } } -function displayLegacyRecord(record: { data?: Record }): void { - const version = (record as { version: number }).version - logger.info(` Type: password (legacy v${version})`) - const loginVal = getRecordLogin(record as Parameters[0]) - const password = getRecordPassword(record as Parameters[0]) - const url = getRecordUrl(record as Parameters[0]) - - if (loginVal) logger.info(` Login: ${loginVal}`) - if (password) logger.info(` Password: ${'*'.repeat(password.length)}`) - if (url) logger.info(` URL: ${url}`) - - const data = record.data as Record | undefined - if (data?.notes) logger.info(` Notes: ${data.notes}`) - - if (data?.custom && Array.isArray(data.custom)) { - logger.info('\n Custom Fields:') - for (const cf of data.custom) { - const entry = cf as { name?: string; type?: string; value?: string } - logger.info(` ${entry.name || entry.type || 'custom'}: ${entry.value}`) - } - } -} - -function displayTypedRecord(record: Parameters[0], type: string): void { - logger.info(` Type: ${type} (v${(record as { version: number }).version})`) - - const fields = getRecordFields(record) - if (fields.length > 0) { - logger.info('\n Fields:') - for (const field of fields) { - const label = field.label || field.type - logger.info(` ${label}: ${formatFieldValue(field)}`) - } - } - - const data = (record as { data?: { notes?: string } }).data - if (data?.notes) { - logger.info(`\n Notes: ${data.notes}`) - } -} - -function displayMetadata(vault: { getRecordMetadataByUid: (uid: string) => { owner: boolean; canShare: boolean; canEdit: boolean } | undefined }, uid: string): void { - const meta = vault.getRecordMetadataByUid(uid) - if (meta) { - logger.info('\n Permissions:') - logger.info(` Owner: ${meta.owner}`) - logger.info(` Can Share: ${meta.canShare}`) - logger.info(` Can Edit: ${meta.canEdit}`) - } -} - runExample(getRecord) diff --git a/examples/sdk_example/src/records/move_record.ts b/examples/sdk_example/src/records/move_record.ts index 5c4b8ad..635556b 100644 --- a/examples/sdk_example/src/records/move_record.ts +++ b/examples/sdk_example/src/records/move_record.ts @@ -20,24 +20,24 @@ async function moveRecord() { const title = getRecordTitle(record) logger.info(`\nRecord: "${title}" (${record.uid})`) - const folders = vault.getSharedFolders() + const sharedFolders = vault.getSharedFolders() const userFolders = vault.getUserFolders() - if (folders.length > 0 || userFolders.length > 0) { + if (sharedFolders.length > 0 || userFolders.length > 0) { logger.info('\nAvailable folders:') - for (const uf of userFolders) { - const name = uf.data?.name || uf.uid - logger.info(` [User] ${uf.uid} ${name}`) + for (const userFolder of userFolders) { + const name = userFolder.data?.name || userFolder.uid + logger.info(` [User] ${userFolder.uid} ${name}`) } - for (const sf of folders) { - const name = sf.name || sf.data?.name || sf.uid - logger.info(` [Shared] ${sf.uid} ${name}`) + for (const sharedFolder of sharedFolders) { + const name = sharedFolder.name || sharedFolder.data?.name || sharedFolder.uid + logger.info(` [Shared] ${sharedFolder.uid} ${name}`) } } - const dstFolderUid = await prompt('\nEnter destination folder UID (empty for root): ') + const destinationFolderUid = await prompt('\nEnter destination folder UID (empty for root): ') - logger.info(`\nMoving "${title}" to ${dstFolderUid || '(root)'}...`) + logger.info(`\nMoving "${title}" to ${destinationFolderUid || '(root)'}...`) let result { @@ -45,7 +45,7 @@ async function moveRecord() { try { result = await vault.moveRecord({ recordUid: record.uid, - dstFolderUid: dstFolderUid || '', + dstFolderUid: destinationFolderUid || '', }) } finally { restore() diff --git a/examples/sdk_example/src/sharedFolders/list_sf.ts b/examples/sdk_example/src/sharedFolders/list_sf.ts new file mode 100644 index 0000000..7e79eea --- /dev/null +++ b/examples/sdk_example/src/sharedFolders/list_sf.ts @@ -0,0 +1,40 @@ +import { + cleanup, + extractErrorMessage, + formatSharedFoldersTable, + login, + logger, + prompt, + renderSharedFoldersAsciiTable, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function listSf() { + const vault = await login() + + try { + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + + const data = vault.listSharedFolders() + if (data.length === 0) { + logger.info('No shared folders are found') + return + } + + if (asJson) { + logger.info(JSON.stringify(data, null, 2)) + return + } + + const table = formatSharedFoldersTable(data) + logger.info(renderSharedFoldersAsciiTable(table)) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(listSf) diff --git a/examples/sdk_example/src/sharedFolders/share_folder.ts b/examples/sdk_example/src/sharedFolders/share_folder.ts new file mode 100644 index 0000000..f9974f3 --- /dev/null +++ b/examples/sdk_example/src/sharedFolders/share_folder.ts @@ -0,0 +1,152 @@ +import { + cleanup, + extractErrorMessage, + FolderKind, + FolderObjectType, + login, + logger, + prompt, + ShareFolderAction, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { + GetFolderResult, + GetFolderResultFolder, + GetFolderResultSharedFolder, + ShareFolderInput, + ShareFolderResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, parseEmails } from '../utils/format' + +type SharedFolderTarget = + | GetFolderResultSharedFolder + | (GetFolderResultFolder & { folder_type: FolderKind.SharedFolderFolder }) + +function isSharedTarget(obj: GetFolderResult): obj is SharedFolderTarget { + if (obj.objectType === FolderObjectType.SharedFolder) return true + if (obj.objectType === FolderObjectType.Folder && obj.folder_type === FolderKind.SharedFolderFolder) return true + return false +} + +function describeTarget(obj: SharedFolderTarget): string { + if (obj.objectType === FolderObjectType.SharedFolder) { + return `shared folder "${obj.name}" (${obj.shared_folder_uid})` + } + return `subfolder of a shared folder "${obj.name}" (${obj.folder_uid})` +} + +function summarize(result: ShareFolderResult): void { + if (result.results.length === 0) { + if (result.message) logger.info(result.message) + return + } + const succeeded = result.results.filter((userResult) => userResult.success) + const failed = result.results.filter((userResult) => !userResult.success) + + if (succeeded.length > 0) { + logger.info(`Succeeded (${succeeded.length}):`) + for (const userResult of succeeded) { + logger.info(` ${userResult.email} status=${userResult.status}`) + } + } + if (failed.length > 0) { + logger.error(`Failed (${failed.length}):`) + for (const userResult of failed) { + logger.error( + ` ${userResult.email} status=${userResult.status}${userResult.message ? ` - ${userResult.message}` : ''}` + ) + } + } +} + +async function promptAction(): Promise { + const raw = (await prompt('Action [grant/remove] (default grant): ')).trim().toLowerCase() + return raw === ShareFolderAction.Remove ? ShareFolderAction.Remove : ShareFolderAction.Grant +} + +async function shareFolderCommand() { + const vault = await login() + + try { + const action = await promptAction() + const verb = action === ShareFolderAction.Remove ? 'remove users from' : 'share' + const folderInput = (await prompt(`Folder name or UID to ${verb}: `)).trim() + if (!folderInput) { + logger.info('No folder given.') + return + } + + let target: GetFolderResult + try { + target = await vault.getFolder(folderInput) + } catch (err) { + logger.error(`Folder lookup failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + return + } + + if (!isSharedTarget(target)) { + logger.error('Only shared folders (or subfolders of a shared folder) can be shared. Personal folders are not supported.') + process.exitCode = 1 + return + } + + logger.info(`Target: ${describeTarget(target)}`) + + const emailsInput = (await prompt('User email(s), comma-separated: ')).trim() + if (!emailsInput) { + logger.info('No emails given.') + return + } + const { emails, invalid } = parseEmails(emailsInput) + if (invalid.length > 0) { + logger.error(`Invalid email(s): ${invalid.join(', ')}`) + process.exitCode = 1 + return + } + if (emails.length === 0) { + logger.info('No valid emails given.') + return + } + + const input: ShareFolderInput = { + folder: folderInput, + emails, + action, + } + + if (action === ShareFolderAction.Grant) { + input.manageRecords = isYes( + await prompt('Allow these users to add/remove records (manage_records)? [y/N]: '), + ) + input.manageUsers = isYes( + await prompt('Allow these users to add/remove users (manage_users)? [y/N]: '), + ) + } + + let result: ShareFolderResult + const restore = suppressLogs() + try { + result = await vault.shareFolder(input) + } finally { + restore() + } + + const opLabel = action === ShareFolderAction.Remove ? 'Remove' : 'Share' + if (result.success) { + logger.info(`${opLabel} completed for shared folder ${result.sharedFolderUid}.`) + } else { + logger.error(`${opLabel} completed with errors${result.message ? `: ${result.message}` : ''}`) + process.exitCode = 1 + } + summarize(result) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(shareFolderCommand) diff --git a/examples/sdk_example/src/utils/format.ts b/examples/sdk_example/src/utils/format.ts index ffbee66..b9482be 100644 --- a/examples/sdk_example/src/utils/format.ts +++ b/examples/sdk_example/src/utils/format.ts @@ -1,3 +1,11 @@ +import { + EMAIL_LIST_SEPARATOR_PATTERN, + EMAIL_PATTERN, + isValidEmail, +} from '@keeper-security/keeper-sdk-javascript' + +export { EMAIL_PATTERN } + 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) @@ -7,8 +15,8 @@ export function padRight(str: string, len: number): string { export function formatFieldValue(field: { type: string; value: unknown[] }): string { if (field.type === 'password') { - const pw = field.value[0] - return pw ? '*'.repeat(String(pw).length) : '(empty)' + const passwordValue = field.value[0] + return passwordValue ? '*'.repeat(String(passwordValue).length) : '(empty)' } if (field.type === 'fileRef') { @@ -16,13 +24,39 @@ export function formatFieldValue(field: { type: string; value: unknown[] }): str } return field.value - .map((v: unknown) => { - if (typeof v === 'string') return v - if (v && typeof v === 'object') return JSON.stringify(v) - return String(v) + .map((value: unknown) => { + if (typeof value === 'string') return value + if (value && typeof value === 'object') return JSON.stringify(value) + return String(value) }) .filter(Boolean) .join(', ') || '(empty)' } export const LEGACY_RECORD_MAX_VERSION = 2 + +export function isYes(answer: string): boolean { + const normalized = answer.trim().toLowerCase() + return normalized === 'y' || normalized === 'yes' +} + +export function parseEmails(raw: string): { emails: string[]; invalid: string[] } { + const tokens = raw + .split(EMAIL_LIST_SEPARATOR_PATTERN) + .map((token) => token.trim()) + .filter((token) => token.length > 0) + const emails: string[] = [] + const invalid: string[] = [] + const seen = new Set() + for (const token of tokens) { + const normalized = token.toLowerCase() + if (seen.has(normalized)) continue + seen.add(normalized) + if (isValidEmail(token)) { + emails.push(token) + } else { + invalid.push(token) + } + } + return { emails, invalid } +} diff --git a/examples/sdk_example/src/utils/runner.ts b/examples/sdk_example/src/utils/runner.ts index ac2caf7..1e3c94f 100644 --- a/examples/sdk_example/src/utils/runner.ts +++ b/examples/sdk_example/src/utils/runner.ts @@ -6,4 +6,7 @@ export function runExample(fn: () => Promise): void { logger.error('Error:', extractErrorMessage(err)) process.exitCode = 1 }) + .finally(() => { + process.exit(process.exitCode ?? 0) + }) } diff --git a/examples/sdk_example/tsconfig.json b/examples/sdk_example/tsconfig.json index 04af1c1..c00f5c2 100644 --- a/examples/sdk_example/tsconfig.json +++ b/examples/sdk_example/tsconfig.json @@ -3,6 +3,7 @@ "module": "commonjs", "target": "es2018", "sourceMap": true, + "noEmit": true, "strict": false, "skipLibCheck": true, "esModuleInterop": true, diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index 46b2fcd..b7a3631 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -274,6 +274,55 @@ export const recordPreDeleteCommand = ( export const recordDeleteCommand = (request: RecordDeleteRequest): RestCommand => createCommand(request, 'delete') +export interface DeleteObject { + object_uid: string + object_type: 'record' | 'user_folder' | 'shared_folder' | 'shared_folder_folder' + from_uid?: string + from_type: 'user_folder' | 'shared_folder' | 'shared_folder_folder' + delete_resolution: 'unlink' +} + +export type PreDeleteRequest = { + objects: DeleteObject[] +} + +export const preDeleteCommand = (request: PreDeleteRequest): RestCommand => + createCommand(request, 'pre_delete') + +export type FolderAddRequest = { + folder_uid: string + folder_type: 'user_folder' | 'shared_folder' | 'shared_folder_folder' + key: string + data: string + link: boolean + parent_uid?: string + shared_folder_uid?: string + name?: string + manage_users?: boolean + manage_records?: boolean + can_edit?: boolean + can_share?: boolean +} + +export const folderAddCommand = (request: FolderAddRequest): RestCommand => + createCommand(request, 'folder_add') + +export type FolderUpdateRequest = { + folder_uid: string + folder_type: 'user_folder' | 'shared_folder' | 'shared_folder_folder' + data: string + parent_uid?: string + shared_folder_uid?: string + name?: string + manage_users?: boolean + manage_records?: boolean + can_edit?: boolean + can_share?: boolean +} + +export const folderUpdateCommand = (request: FolderUpdateRequest): RestCommand => + createCommand(request, 'folder_update') + export type GetRecordHistoryRequest = { record_uid: string client_time: number diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index fa0c3a3..0c2017a 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -8,6 +8,7 @@ import { BreachWatch, Automator, Enterprise, + Folder, GraphSync, PAM, Records, @@ -454,6 +455,17 @@ export const recordsShareUpdateMessage = ( Records.RecordShareUpdateResponse ) +export const sharedFolderUpdateV3Message = ( + data: Folder.ISharedFolderUpdateV3RequestV2 +): RestMessage => + createMessage( + data, + 'vault/shared_folder_update_v3', + Folder.SharedFolderUpdateV3RequestV2, + Folder.SharedFolderUpdateV3ResponseV2, + 1 + ) + export const recordsRevertMessage = ( data: Records.IRecordsRevertRequest ): RestMessage => From a1a758f5fce28e19249b29e7b29ddbdd6412a4fe Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 4 May 2026 17:33:02 +0530 Subject: [PATCH 2/3] CodeQL fix changes --- KeeperSdk/src/records/Totp.ts | 5 ++++- KeeperSdk/src/utils/constants.ts | 2 +- KeeperSdk/src/utils/index.ts | 2 -- KeeperSdk/src/utils/patterns.ts | 10 +++------- examples/sdk_example/src/records/get_record.ts | 12 ++++++++++-- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/KeeperSdk/src/records/Totp.ts b/KeeperSdk/src/records/Totp.ts index f0b0f31..1ba67d5 100644 --- a/KeeperSdk/src/records/Totp.ts +++ b/KeeperSdk/src/records/Totp.ts @@ -22,7 +22,10 @@ const DEFAULT_ALGORITHM: TotpAlgorithm = 'SHA1' const UINT32_MAX = 0x100000000 function decodeBase32(input: string): Uint8Array { - const cleaned = input.replace(/=+$/g, '').replace(/\s+/g, '').toUpperCase() + const noWhitespace = input.replace(/\s+/g, '') + let endIndex = noWhitespace.length + while (endIndex > 0 && noWhitespace.charCodeAt(endIndex - 1) === 0x3d) endIndex-- + const cleaned = noWhitespace.slice(0, endIndex).toUpperCase() const out: number[] = [] let buffer = 0 let bits = 0 diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 3748293..52f8240 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,5 +1,5 @@ export const SdkDefaults = { - CLIENT_VERSION: 'c17.0.0', + CLIENT_VERSION: 'w18.2.0', DEVICE_NAME: 'JavaScript Keeper SDK', CONFIG_DIR: '.keeper', LOG_FORMAT: '!', diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index ed3825a..4d49c62 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -9,8 +9,6 @@ export { EMAIL_LIST_SEPARATOR_PATTERN, TOKEN_SEPARATOR_PATTERN, REGEX_ESCAPE_PATTERN, - TRAILING_EQUALS_PATTERN, - WHITESPACE_PATTERN, isValidEmail, escapeRegExp, } from './patterns' diff --git a/KeeperSdk/src/utils/patterns.ts b/KeeperSdk/src/utils/patterns.ts index c4e186b..bc11629 100644 --- a/KeeperSdk/src/utils/patterns.ts +++ b/KeeperSdk/src/utils/patterns.ts @@ -1,4 +1,4 @@ -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,;]+/ @@ -9,14 +9,10 @@ export const TOKEN_SEPARATOR_PATTERN = /[\s\-_.,;:!?@#$%^&*()[\]{}|\\/<>]+/ /** Characters that must be escaped when embedding user input into a RegExp. */ export const REGEX_ESCAPE_PATTERN = /[.+^${}()|[\]\\]/g -/** 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 +const MAX_EMAIL_LENGTH = 254 export function isValidEmail(value: string): boolean { - return EMAIL_PATTERN.test(value) + return value.length <= MAX_EMAIL_LENGTH && EMAIL_PATTERN.test(value) } export function escapeRegExp(value: string): string { diff --git a/examples/sdk_example/src/records/get_record.ts b/examples/sdk_example/src/records/get_record.ts index 9ed05cb..a5f3c5d 100644 --- a/examples/sdk_example/src/records/get_record.ts +++ b/examples/sdk_example/src/records/get_record.ts @@ -24,6 +24,10 @@ function formatRow(label: string, value: string): string { return `${label.padStart(LABEL_WIDTH)}: ${value}` } +function writeSecretLine(label: string, value: string): void { + process.stdout.write(`${formatRow(label, value)}\n`) +} + function displayRecord(record: DRecord, unmask: boolean): void { logger.info(`UID: ${record.uid}`) logger.info(formatRow('Type', getRecordType(record))) @@ -34,12 +38,16 @@ function displayRecord(record: DRecord, unmask: boolean): void { const url = getRecordUrl(record) if (loginVal) logger.info(formatRow('login', loginVal)) - if (password) logger.info(formatRow('password', unmask ? password : MASKED_VALUE)) + if (password) { + if (unmask) writeSecretLine('password', password) + else logger.info(formatRow('password', MASKED_VALUE)) + } if (url) logger.info(formatRow('url', url)) const totpUrl = getRecordTotpUrl(record) if (totpUrl) { - logger.info(formatRow('TOTP URL', unmask ? totpUrl : MASKED_VALUE)) + if (unmask) writeSecretLine('TOTP URL', totpUrl) + else logger.info(formatRow('TOTP URL', MASKED_VALUE)) const code = getTotpCode(totpUrl) if (code) { logger.info(formatRow('Two Factor Code', `${code.code} valid for ${code.secondsRemaining} sec`)) From 96fc6154606ab6d52c59f2d6397a466db8fb1230 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 5 May 2026 10:03:56 +0530 Subject: [PATCH 3/3] Undo CLIENT_VERSION 'w18.2.0' back to 'c17.0.0' --- KeeperSdk/src/utils/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 52f8240..3748293 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,5 +1,5 @@ export const SdkDefaults = { - CLIENT_VERSION: 'w18.2.0', + CLIENT_VERSION: 'c17.0.0', DEVICE_NAME: 'JavaScript Keeper SDK', CONFIG_DIR: '.keeper', LOG_FORMAT: '!',