From b5f34af33f17b06acaed77ff6b63e366ebf35d36 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 17 Jun 2026 11:28:59 +0530 Subject: [PATCH 1/4] nsf record initial commit --- KeeperSdk/src/index.ts | 27 ++ .../NestedShareFolderManager.ts | 51 ++++ .../nestedShareFolders/getNsfRecordDetails.ts | 130 +++++++++ KeeperSdk/src/nestedShareFolders/index.ts | 43 +++ KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 213 +++++++++++++++ .../src/nestedShareFolders/nsfConstants.ts | 6 + .../src/nestedShareFolders/nsfHelpers.ts | 142 ++++++++++ .../src/nestedShareFolders/nsfRecordCrypto.ts | 95 +++++++ .../src/nestedShareFolders/removeNsfFolder.ts | 247 ++++++++++++++++++ .../src/nestedShareFolders/updateNsfRecord.ts | 193 ++++++++++++++ KeeperSdk/src/utils/constants.ts | 8 + KeeperSdk/src/vault/KeeperVault.ts | 50 ++++ examples/sdk_example/package.json | 4 + .../get_nsf_record_details.ts | 46 ++++ .../src/nestedShareFolders/mkdir_nsf.ts | 56 ++++ .../src/nestedShareFolders/rmdir_nsf.ts | 131 ++++++++++ .../nestedShareFolders/update_nsf_record.ts | 71 +++++ keeperapi/src/restMessages.ts | 83 +++++- 18 files changed, 1594 insertions(+), 2 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts create mode 100644 KeeperSdk/src/nestedShareFolders/mkdirNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts create mode 100644 KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts create mode 100644 KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 317c7da8..75bdd02f 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -475,6 +475,17 @@ export { NsfRemoveOperation, removeNestedShareRecords, formatRemoveNsfPreview, + mkdirNestedShareFolder, + NSF_FOLDER_COLORS, + NsfRemoveFolderOperation, + removeNestedShareFolders, + formatRemoveNsfFolderPreview, + GetNsfRecordDetailsFormat, + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, + updateNestedShareRecords, + checkRecordEditPermission, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -495,6 +506,22 @@ export type { RemoveNsfRecordInput, NsfRemovePreviewItem, RemoveNsfRecordResult, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, + NsfFolderColor, + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, + UpdateNsfRecordInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, + UpdateNsfRecordFieldMap, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index 4a21c74f..552f28d6 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -28,6 +28,24 @@ import { type RemoveNsfRecordInput, type RemoveNsfRecordResult, } from './removeNsfRecord' +import { mkdirNestedShareFolder, type MkdirNsfInput, type MkdirNsfResult } from './mkdirNsf' +import { + formatRemoveNsfFolderPreview, + removeNestedShareFolders, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from './removeNsfFolder' +import { + getNestedShareRecordDetails, + formatNsfRecordDetailsOutput, + type GetNsfRecordDetailsInput, + type GetNsfRecordDetailsResult, +} from './getNsfRecordDetails' +import { + updateNestedShareRecords, + type UpdateNsfRecordInput, + type UpdateNsfRecordResult, +} from './updateNsfRecord' export type AuthProvider = () => Auth @@ -94,4 +112,37 @@ export class NestedShareFolderManager { public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { return formatRemoveNsfPreview(preview) } + + public async mkdirNestedShareFolder(input: MkdirNsfInput): Promise { + return mkdirNestedShareFolder(this.storage, this.requireAuth(), input) + } + + public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { + return removeNestedShareFolders(this.storage, this.requireAuth(), input) + } + + public formatRemoveNsfFolderPreview( + preview: RemoveNsfFolderResult['preview'], + operation: RemoveNsfFolderResult['operation'], + quiet?: boolean + ): string { + return formatRemoveNsfFolderPreview(preview, operation, quiet) + } + + public async getNestedShareRecordDetails( + input: GetNsfRecordDetailsInput + ): Promise { + return getNestedShareRecordDetails(this.storage, this.requireAuth(), input) + } + + public formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format?: GetNsfRecordDetailsInput['format'] + ): string { + return formatNsfRecordDetailsOutput(result, format) + } + + public async updateNestedShareRecords(input: UpdateNsfRecordInput): Promise { + return updateNestedShareRecords(this.storage, this.requireAuth(), input) + } } diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts new file mode 100644 index 00000000..4e7d0b2b --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -0,0 +1,130 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { normal64Bytes, recordDetailsDataMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + ensureNestedShareRecord, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { decryptRecordTitleAndType } from './nsfRecordCrypto' + +function longToNumber(value: number | { toNumber: () => number } | null | undefined): number { + if (value == null) return 0 + return typeof value === 'number' ? value : value.toNumber() +} + +export enum GetNsfRecordDetailsFormat { + Table = 'table', + JSON = 'json', +} + +export type GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat | `${GetNsfRecordDetailsFormat}` + +export type NsfRecordDetailsItem = { + recordUid: string + title: string + type: string + revision: number + version: number +} + +export type GetNsfRecordDetailsResult = { + data: NsfRecordDetailsItem[] + forbiddenRecords: string[] +} + +export type GetNsfRecordDetailsInput = { + records: string[] + format?: GetNsfRecordDetailsFormatInput +} + +function resolveRecordUids(storage: InMemoryStorage, identifiers: string[]): string[] { + if (identifiers.length === 0) { + throw new KeeperSdkError('At least one record UID or title is required.', ResultCodes.NSF_DETAILS_FAILED) + } + + const recordUids: string[] = [] + for (const identifier of identifiers) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + recordUids.push(recordUid) + } + return recordUids +} + +export function formatNsfRecordDetailsTable(result: GetNsfRecordDetailsResult): string { + const lines: string[] = [] + for (const record of result.data) { + lines.push(`Record UID: ${record.recordUid}`) + lines.push(` Title: ${record.title}`) + lines.push(` Type: ${record.type}`) + lines.push(` Version: ${record.version}`) + lines.push(` Revision: ${record.revision}`) + lines.push('') + } + if (result.forbiddenRecords.length > 0) { + lines.push(`Forbidden records: ${result.forbiddenRecords.length}`) + for (const uid of result.forbiddenRecords) { + lines.push(` ${uid}`) + } + lines.push('') + } + lines.push(`Total records retrieved: ${result.data.length}`) + return lines.join('\n').trimEnd() +} + +export function formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table +): string { + const value = String(format).toLowerCase() + if (value === GetNsfRecordDetailsFormat.JSON || value === 'json') { + return JSON.stringify(result, null, 2) + } + return formatNsfRecordDetailsTable(result) +} + +export async function getNestedShareRecordDetails( + storage: InMemoryStorage, + auth: Auth, + input: GetNsfRecordDetailsInput +): Promise { + const recordUids = resolveRecordUids(storage, input.records) + + try { + const response = await auth.executeRest( + recordDetailsDataMessage({ + recordUids: recordUids.map((uid) => normal64Bytes(uid)), + clientTime: Date.now(), + }) + ) + + const data: NsfRecordDetailsItem[] = [] + for (const item of response.data) { + const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' + if (!recordUid) continue + const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) + data.push({ + recordUid, + title, + type, + revision: longToNumber(item.revision), + version: item.version ?? 0, + }) + } + + return { + data, + forbiddenRecords: response.forbiddenRecords.map((uid) => webSafe64FromBytes(uid)), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to get nested share record details: ${extractErrorMessage(err)}`, + ResultCodes.NSF_DETAILS_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index d73f6513..e33fa890 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -19,8 +19,13 @@ export { ensureNestedShareFolder, resolveNsfRecordIdentifier, resolveNsfFolderIdentifier, + resolveNsfFolderUidOrName, findNestedShareFoldersForRecord, checkRecordDeletePermission, + checkRecordEditPermission, + checkFolderDeletePermission, + parseNsfPath, + findExistingChildFolder, } from './nsfHelpers' export { @@ -74,4 +79,42 @@ export type { RemoveNsfRecordResult, } from './removeNsfRecord' +export { mkdirNestedShareFolder } from './mkdirNsf' +export type { MkdirNsfInput, MkdirNsfResult, NsfFolderColorInput } from './mkdirNsf' +export { NSF_FOLDER_COLORS } from './nsfConstants' +export type { NsfFolderColor } from './nsfConstants' + +export { + NsfRemoveFolderOperation, + removeNestedShareFolders, + formatRemoveNsfFolderPreview, +} from './removeNsfFolder' +export type { + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, +} from './removeNsfFolder' + +export { + GetNsfRecordDetailsFormat, + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, +} from './getNsfRecordDetails' +export type { + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, +} from './getNsfRecordDetails' + +export { updateNestedShareRecords } from './updateNsfRecord' +export type { + UpdateNsfRecordInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, + UpdateNsfRecordFieldMap, +} from './updateNsfRecord' + export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts new file mode 100644 index 00000000..4d616786 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -0,0 +1,213 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + folderAddMessage, + generateUid, + normal64Bytes, + platform, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + NSF_FOLDER_COLORS, + type NsfFolderColor, +} from './nsfConstants' +import { + cacheNewNsfFolder, + findExistingChildFolder, + isNestedShareFolder, + isRootFolderUid, + parseNsfPath, +} from './nsfHelpers' + +export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` + +export type MkdirNsfInput = { + folder: string + color?: NsfFolderColorInput + noInheritPermissions?: boolean + baseFolderUid?: string | null +} + +export type MkdirNsfResult = { + folderUid: string + created: boolean + message?: string +} + +function normalizeColor(color?: NsfFolderColorInput): NsfFolderColor | undefined { + if (!color) return undefined + const value = color as NsfFolderColor + if (!(NSF_FOLDER_COLORS as readonly string[]).includes(value)) { + throw new KeeperSdkError( + `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(', ')}.`, + ResultCodes.NSF_MKDIR_FAILED + ) + } + return value +} + +function resolveBaseFolderUid( + storage: InMemoryStorage, + baseFolderUid: string | null | undefined +): string | null { + if (!baseFolderUid) return null + if (isNestedShareFolder(storage, baseFolderUid)) return baseFolderUid + return null +} + +async function resolveFolderKeyEncryptionKey( + storage: InMemoryStorage, + auth: Auth, + parentUid: string | null +): Promise { + const normalizedParent = parentUid && !isRootFolderUid(parentUid) ? parentUid : null + if (normalizedParent) { + const parentKey = await storage.getKeyBytes(normalizedParent) + if (parentKey) return parentKey + } + if (!auth.dataKey) { + throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) + } + return auth.dataKey +} + +async function prepareFolderData( + storage: InMemoryStorage, + auth: Auth, + folderName: string, + parentUid: string | null, + color: NsfFolderColor | undefined, + inheritPermissions: boolean +): Promise<{ folderUid: string; folderData: Folder.IFolderData }> { + const folderUid = generateUid() + const folderKey = platform.getRandomBytes(32) + await storage.saveKeyBytes(folderUid, folderKey) + + const metadata: { name: string; color?: string } = { name: folderName } + if (color && color !== 'none') metadata.color = color + + const encryptedData = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(metadata)), + folderKey + ) + + const normalizedParent = parentUid && !isRootFolderUid(parentUid) ? parentUid : null + const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, normalizedParent) + const encryptedFolderKey = await platform.aesGcmEncrypt(folderKey, encryptionKey) + + return { + folderUid, + folderData: { + folderUid: normal64Bytes(folderUid), + parentUid: normalizedParent ? normal64Bytes(normalizedParent) : undefined, + data: encryptedData, + folderKey: encryptedFolderKey, + type: Folder.FolderUsageType.UT_NORMAL, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + }, + } +} + +async function createFolderV3( + storage: InMemoryStorage, + auth: Auth, + folderName: string, + parentUid: string | null, + color: NsfFolderColor | undefined, + inheritPermissions: boolean +): Promise<{ folderUid: string; success: boolean; message: string }> { + const { folderUid, folderData } = await prepareFolderData( + storage, + auth, + folderName, + parentUid, + color, + inheritPermissions + ) + + const response = await auth.executeRest(folderAddMessage({ folderData: [folderData] })) + const result = response.folderAddResults?.[0] + if (!result) { + throw new KeeperSdkError('No results from folder creation.', ResultCodes.NSF_MKDIR_FAILED) + } + + const statusName = Folder.FolderModifyStatus[result.status ?? Folder.FolderModifyStatus.SUCCESS] ?? 'UNKNOWN' + const success = result.status === Folder.FolderModifyStatus.SUCCESS + if (!success) { + throw new KeeperSdkError(result.message || `Folder creation failed (${statusName}).`, ResultCodes.NSF_MKDIR_FAILED) + } + + await cacheNewNsfFolder(storage, auth, folderUid, folderName, parentUid, inheritPermissions) + return { + folderUid, + success, + message: result.message || 'Folder created successfully', + } +} + +export async function mkdirNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: MkdirNsfInput +): Promise { + const folderPath = (input.folder ?? '').trim() + if (!folderPath) { + throw new KeeperSdkError('Folder name is required.', ResultCodes.NSF_MKDIR_FAILED) + } + + const color = normalizeColor(input.color) + const inheritPermissions = !input.noInheritPermissions + const baseFolderUid = resolveBaseFolderUid(storage, input.baseFolderUid) + const segments = parseNsfPath(folderPath) + let parentUid = baseFolderUid + const lastIdx = segments.length - 1 + let createdUid: string | undefined + + try { + for (let idx = 0; idx < segments.length; idx++) { + const segment = segments[idx] + const isLeaf = idx === lastIdx + const existingUid = findExistingChildFolder(storage, segment, parentUid) + + if (existingUid) { + if (isLeaf) { + return { + folderUid: existingUid, + created: false, + message: `Folder "${segment}" already exists.`, + } + } + parentUid = existingUid + continue + } + + const segColor = isLeaf ? color : undefined + const segInherit = isLeaf ? inheritPermissions : true + const result = await createFolderV3(storage, auth, segment, parentUid, segColor, segInherit) + createdUid = result.folderUid + parentUid = createdUid + } + + if (!createdUid) { + throw new KeeperSdkError('Folder creation did not return a UID.', ResultCodes.NSF_MKDIR_FAILED) + } + + return { + folderUid: createdUid, + created: true, + message: + segments.length > 1 + ? `Created folder path "${folderPath}".` + : `Created folder "${segments[lastIdx]}".`, + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to create nested share folder: ${extractErrorMessage(err)}`, + ResultCodes.NSF_MKDIR_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index 9cb0514c..e687147a 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -54,3 +54,9 @@ export const NSF_LIST_DEFAULT_COLUMN_WIDTH = 40 export const NSF_LIST_MIN_TRUNCATE_PREFIX = 3 export const NSF_MAX_REMOVALS = 500 +export const NSF_MAX_FOLDER_REMOVALS = 100 + +export const NSF_PATH_SENTINEL = '\x00' + +export const NSF_FOLDER_COLORS = ['none', 'red', 'orange', 'yellow', 'green', 'blue', 'gray'] as const +export type NsfFolderColor = (typeof NSF_FOLDER_COLORS)[number] diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 646fd074..622adaf0 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -17,6 +17,7 @@ import { NSF_LEGACY_FOLDER_MSG, NSF_LEGACY_RECORD_MSG, NSF_NOTE_FIELD_TYPES, + NSF_PATH_SENTINEL, NSF_RECORD_DESCRIPTION_MAX_LENGTH, NSF_SENSITIVE_FIELD_TYPES, ROOT_FOLDER_UID, @@ -141,6 +142,112 @@ export function resolveNsfFolderIdentifier(storage: InMemoryStorage, identifier: return resolveFolderByPath(storage, trimmed) } +export function resolveNsfFolderUidOrName(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim() + if (!trimmed) return undefined + + if (getKeeperDriveFolder(storage, trimmed)) return trimmed + + return resolveByUidOrName( + getKeeperDriveFolders(storage), + trimmed, + (folder) => folder.uid, + (folder) => folder.data.name || '' + )?.uid +} + +export function parseNsfPath(folderPath: string): string[] { + const collapsed = folderPath.replace(/\/\//g, NSF_PATH_SENTINEL) + const segments: string[] = [] + for (const raw of collapsed.split('/')) { + const name = raw.replace(new RegExp(NSF_PATH_SENTINEL, 'g'), '/').trim() + if (name) segments.push(name) + } + if (segments.length === 0) { + throw new KeeperSdkError('Invalid folder name.', ResultCodes.NSF_MKDIR_FAILED) + } + return segments +} + +export function findExistingChildFolder( + storage: InMemoryStorage, + segment: string, + parentUid: string | null | undefined +): string | undefined { + const normalizedParent = normalizeParentUid(parentUid) + const lower = segment.toLowerCase() + for (const folder of getKeeperDriveFolders(storage)) { + const folderParent = normalizeParentUid(folder.parentUid) + const name = folder.data.name || '' + if (folderParent === normalizedParent && name.toLowerCase() === lower) { + return folder.uid + } + } + return undefined +} + +export async function cacheNewNsfFolder( + storage: InMemoryStorage, + auth: { username?: string; accountUid?: Uint8Array }, + folderUid: string, + name: string, + parentUid: string | null | undefined, + inheritPermissions: boolean +): Promise { + const normalizedParent = isRootFolderUid(parentUid) ? undefined : parentUid?.trim() || undefined + await storage.put({ + kind: 'keeper_drive_folder', + uid: folderUid, + data: { name }, + parentUid: normalizedParent, + ownerInfo: { + accountUid: auth.accountUid?.length ? webSafe64FromBytes(auth.accountUid) : undefined, + username: auth.username, + }, + type: Folder.FolderUsageType.UT_NORMAL, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + }) +} + +export function checkFolderDeletePermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid?: Uint8Array +): void { + if (isRootFolderUid(folderUid)) { + throw new KeeperSdkError('The root folder cannot be removed.', ResultCodes.NSF_PERMISSION_DENIED) + } + + const entries = getFolderAccessEntries(storage, folderUid) + if (entries.length === 0) return + + const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' + for (const entry of entries) { + const isCurrentUser = + (entry.accessType === Folder.AccessType.AT_USER && entry.accessTypeUid === accountUidStr) || + (entry.accessType === Folder.AccessType.AT_OWNER && entry.accessTypeUid === accountUidStr) || + (username && + storage.getAll('user').some( + (user) => + user.username === username && + webSafe64FromBytes(user.accountUid) === entry.accessTypeUid + )) + if (!isCurrentUser) continue + if (entry.accessType === Folder.AccessType.AT_OWNER || entry.permission?.canDelete) return + throw new KeeperSdkError( + 'You do not have permission to delete this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to delete this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + export function findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { return storage .getAll(KeeperDriveKind.FolderRecord) @@ -183,6 +290,41 @@ export function checkRecordDeletePermission( ) } +export function checkRecordEditPermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid?: Uint8Array +): void { + const entries = storage + .getAll(KeeperDriveKind.RecordAccess) + .filter((entry) => entry.recordUid === recordUid) + if (entries.length === 0) return + + const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' + for (const entry of entries) { + const isCurrentUser = + (entry.accessType === Folder.AccessType.AT_USER && + entry.accessTypeUid === accountUidStr) || + (username && + storage.getAll('user').some( + (user) => + user.username === username && + webSafe64FromBytes(user.accountUid) === entry.accessTypeUid + )) + if (!isCurrentUser) continue + if (entry.owner || entry.canEdit) return + throw new KeeperSdkError( + 'You do not have permission to edit this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to edit this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + export function formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): string { if (role == null) return 'unknown' return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}` diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts new file mode 100644 index 00000000..8ffb0d0f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts @@ -0,0 +1,95 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Records, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { findNestedShareFoldersForRecord } from './nsfHelpers' + +function decodePayload(value: string | Uint8Array | null | undefined): Uint8Array { + if (!value) return new Uint8Array(0) + if (value instanceof Uint8Array) return value + return normal64Bytes(value) +} + +async function decryptWithFolderKeys( + storage: InMemoryStorage, + recordUid: string, + encryptedKey: Uint8Array +): Promise { + for (const folderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + const folderKey = await storage.getKeyBytes(folderUid) + if (!folderKey) continue + try { + return await platform.aesGcmDecrypt(encryptedKey, folderKey) + } catch { + try { + return await platform.aesCbcDecrypt(encryptedKey, folderKey, true) + } catch { + // try next folder key + } + } + } + return undefined +} + +export async function resolveRecordKeyBytes( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + encryptedKey?: Uint8Array | null, + keyType?: Records.RecordKeyType | null +): Promise { + const cached = await storage.getKeyBytes(recordUid) + if (cached) return cached + + if (!encryptedKey?.length || !auth.dataKey) { + return decryptWithFolderKeys(storage, recordUid, encryptedKey ?? new Uint8Array(0)) + } + + try { + if (keyType === Records.RecordKeyType.ENCRYPTED_BY_DATA_KEY) { + return await platform.aesCbcDecrypt(encryptedKey, auth.dataKey, true) + } + return await platform.aesGcmDecrypt(encryptedKey, auth.dataKey) + } catch { + // fall through to folder keys + } + + return decryptWithFolderKeys(storage, recordUid, encryptedKey) +} + +export async function decryptRecordTitleAndType( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + recordData: Records.IRecordData +): Promise<{ title: string; type: string }> { + const uid = recordData.recordUid?.length ? webSafe64FromBytes(recordData.recordUid) : recordUid + const recordKey = await resolveRecordKeyBytes( + storage, + auth, + uid, + recordData.recordKey, + recordData.recordKeyType + ) + if (!recordKey) { + return { title: 'Unknown', type: 'Unknown' } + } + + const encryptedData = decodePayload(recordData.encryptedRecordData) + if (!encryptedData.length) { + return { title: 'Unknown', type: 'Unknown' } + } + + try { + const decrypted = await platform.aesGcmDecrypt(encryptedData, recordKey) + const parsed = JSON.parse(platform.bytesToString(decrypted).replace(/\s+$/, '')) as { + title?: string + type?: string + } + return { + title: parsed.title?.trim() || 'Unknown', + type: parsed.type?.trim() || 'Unknown', + } + } catch { + return { title: 'Unknown', type: 'Unknown' } + } +} diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts new file mode 100644 index 00000000..21b17b47 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -0,0 +1,247 @@ +import type { Auth, folder as FolderProto } from '@keeper-security/keeperapi' +import { folder, normal64Bytes, removeFolderMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_MAX_FOLDER_REMOVALS } from './nsfConstants' +import { + checkFolderDeletePermission, + ensureNestedShareFolder, + getFolderDisplayName, + resolveNsfFolderUidOrName, +} from './nsfHelpers' + +const { RemoveAction, FolderOperationType, RemoveStatus } = folder.v3.remove +const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] + +export enum NsfRemoveFolderOperation { + FolderTrash = 'folder-trash', + DeletePermanent = 'delete-permanent', +} + +export type NsfRemoveFolderOperationInput = NsfRemoveFolderOperation | `${NsfRemoveFolderOperation}` + +export type RemoveNsfFolderInput = { + folders: string[] + operation?: NsfRemoveFolderOperationInput + force?: boolean + dryRun?: boolean + quiet?: boolean +} + +export type NsfRemoveFolderPreviewItem = { + folderUid: string + name: string + status: string + impact?: { + foldersCount: number + recordsCount: number + affectedUsersCount: number + affectedTeamsCount: number + warnings: string[] + } + error?: { code: number; message: string } +} + +export type RemoveNsfFolderResult = { + confirmed: boolean + dryRun: boolean + operation: NsfRemoveFolderOperation + preview: NsfRemoveFolderPreviewItem[] + message?: string +} + +const OPERATION_MAP: Record = { + [NsfRemoveFolderOperation.FolderTrash]: FolderOperationType.FOLDER_MOVE_TO_FOLDER_TRASH, + [NsfRemoveFolderOperation.DeletePermanent]: FolderOperationType.FOLDER_DELETE_PERMANENT, +} + +function normalizeOperation( + operation: NsfRemoveFolderOperationInput = NsfRemoveFolderOperation.FolderTrash +): NsfRemoveFolderOperation { + const value = operation as NsfRemoveFolderOperation + if (value in OPERATION_MAP) return value + throw new KeeperSdkError( + `Invalid operation '${operation}'. Use: folder-trash, delete-permanent.`, + ResultCodes.NSF_REMOVE_FAILED + ) +} + +type RemovalSpec = { + folderUid: string + name: string + operation: FolderProto.v3.remove.FolderOperationType +} + +function buildRemovals( + storage: InMemoryStorage, + auth: Auth, + folderIdentifiers: string[], + operation: NsfRemoveFolderOperation +): RemovalSpec[] { + if (folderIdentifiers.length === 0) { + throw new KeeperSdkError('Enter the name or UID of at least one folder.', ResultCodes.NSF_NOT_FOUND) + } + if (folderIdentifiers.length > NSF_MAX_FOLDER_REMOVALS) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_FOLDER_REMOVALS} folders per request.`, + ResultCodes.NSF_TOO_MANY_FOLDERS + ) + } + + const removals: RemovalSpec[] = [] + for (const identifier of folderIdentifiers) { + const folderUid = resolveNsfFolderUidOrName(storage, identifier) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, identifier) + checkFolderDeletePermission(storage, folderUid, auth.username, auth.accountUid) + removals.push({ + folderUid, + name: getFolderDisplayName(storage, folderUid), + operation: OPERATION_MAP[operation], + }) + } + return removals +} + +function toRemovalInput(spec: RemovalSpec): FolderProto.v3.remove.IFolderRemoval { + return { + folderUid: normal64Bytes(spec.folderUid), + operationType: spec.operation, + } +} + +async function executeRemove( + auth: Auth, + removals: RemovalSpec[], + action: FolderProto.v3.remove.RemoveAction, + confirmationToken?: Uint8Array +): Promise { + return auth.executeRest( + removeFolderMessage({ + action, + folders: removals.map(toRemovalInput), + confirmationToken, + }) + ) +} + +function mapPreviewItem(spec: RemovalSpec, item: FolderProto.v3.remove.IRemoveResult): NsfRemoveFolderPreviewItem { + return { + folderUid: item.itemUid?.length ? webSafe64FromBytes(item.itemUid) : spec.folderUid, + name: spec.name, + status: item.status == null ? 'REMOVE_STATUS_UNKNOWN' : (RemoveStatus[item.status] ?? String(item.status)), + impact: item.impact + ? { + foldersCount: item.impact.foldersCount ?? 0, + recordsCount: item.impact.recordsCount ?? 0, + affectedUsersCount: item.impact.affectedUsersCount ?? 0, + affectedTeamsCount: item.impact.affectedTeamsCount ?? 0, + warnings: [...(item.impact.warnings ?? [])], + } + : undefined, + error: item.error + ? { + code: item.error.code ?? 0, + message: item.error.message ?? '', + } + : undefined, + } +} + +function mapPreview( + removals: RemovalSpec[], + response: FolderProto.v3.remove.IRemoveResponse +): NsfRemoveFolderPreviewItem[] { + return response.results.map((item, index) => mapPreviewItem(removals[index] ?? removals[0], item)) +} + +function hasPreviewErrors(preview: NsfRemoveFolderPreviewItem[]): boolean { + return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) +} + +export function formatRemoveNsfFolderPreview( + preview: NsfRemoveFolderPreviewItem[], + operation: NsfRemoveFolderOperation, + quiet = false +): string { + const action = + operation === NsfRemoveFolderOperation.DeletePermanent ? 'permanently deleted' : 'moved to trash' + const lines: string[] = [] + + for (const item of preview) { + lines.push(`\nThe following folder will be ${action}:`) + lines.push(` ${item.name} [${item.folderUid}]`) + if (item.impact && !quiet) { + const parts = [ + `sub-folders=${item.impact.foldersCount}`, + `records=${item.impact.recordsCount}`, + `users=${item.impact.affectedUsersCount}`, + `teams=${item.impact.affectedTeamsCount}`, + ] + lines.push(` Impact: ${parts.join(', ')}`) + for (const warning of item.impact.warnings) { + lines.push(` Warning: ${warning}`) + } + } + if (item.error?.message) { + lines.push(` Error: ${item.error.message}`) + } + } + + return lines.join('\n').trimEnd() +} + +export async function removeNestedShareFolders( + storage: InMemoryStorage, + auth: Auth, + input: RemoveNsfFolderInput +): Promise { + const operation = normalizeOperation(input.operation) + const dryRun = input.dryRun ?? false + const removals = buildRemovals(storage, auth, input.folders, operation) + + try { + const previewResponse = await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_PREVIEW) + const preview = mapPreview(removals, previewResponse) + + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError( + formatRemoveNsfFolderPreview(preview, operation, input.quiet) || 'Folder removal preview failed.', + ResultCodes.NSF_REMOVE_FAILED + ) + } + + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, operation, preview } + } + + if (!input.force) { + return { + confirmed: false, + dryRun: false, + operation, + preview, + message: 'Confirmation required. Set force=true to proceed.', + } + } + + await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_CONFIRM, previewResponse.confirmationToken) + const actionLabel = + operation === NsfRemoveFolderOperation.DeletePermanent ? 'Permanently deleted' : 'Moved to trash' + return { + confirmed: true, + dryRun: false, + operation, + preview, + message: `${actionLabel} ${removals.length} folder(s).`, + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to remove nested share folder(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_REMOVE_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts new file mode 100644 index 00000000..8f42c298 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -0,0 +1,193 @@ +import type { Auth, DRecord } from '@keeper-security/keeperapi' +import { + Records, + keeperDriveRecordsUpdate, + normal64Bytes, + platform, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { VaultObjectKind } from '../folders/folderHelpers' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + checkRecordEditPermission, + ensureNestedShareRecord, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' + +export type UpdateNsfRecordFieldMap = Record + +export type UpdateNsfRecordInput = { + records: string[] + title?: string + recordType?: string + notes?: string + fields?: UpdateNsfRecordFieldMap +} + +export type UpdateNsfRecordResultItem = { + recordUid: string + success: boolean + status: string + message?: string + revision?: number +} + +export type UpdateNsfRecordResult = { + updated: UpdateNsfRecordResultItem[] +} + +const MIN_RECORD_PAD_BYTES = 384 +const PAD_BLOCK_SIZE = 16 + +function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { + if (value == null) return undefined + return typeof value === 'number' ? value : value.toNumber() +} + +function getPaddedJsonBytes(data: Record): Uint8Array { + const json = JSON.stringify(data) + const paddedLength = Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * PAD_BLOCK_SIZE + const padded = json.padEnd(paddedLength, ' ') + return platform.stringToBytes(padded) +} + +function normalizeFieldValue(value: string | string[]): string[] { + return Array.isArray(value) ? value : [value] +} + +function mergeRecordData( + existing: Record, + input: Pick +): Record { + const data: Record = { + ...existing, + fields: Array.isArray(existing.fields) ? [...(existing.fields as Record[])] : [], + } + + if (input.title !== undefined) data.title = input.title + if (input.recordType !== undefined) data.type = input.recordType + if (input.notes !== undefined) data.notes = input.notes + + if (input.fields) { + const fields = data.fields as { type?: string; value?: string[] }[] + const byType = new Map() + for (const field of fields) { + const fieldType = field?.type + if (!fieldType) continue + if (!byType.has(fieldType)) byType.set(fieldType, []) + byType.get(fieldType)!.push(field) + } + + for (const [fieldType, value] of Object.entries(input.fields)) { + const normalized = normalizeFieldValue(value) + const matches = byType.get(fieldType) + if (matches?.length) { + matches[0].value = normalized + } else { + fields.push({ type: fieldType, value: normalized }) + } + } + data.fields = fields + } + + return data +} + +function loadExistingRecordData(storage: InMemoryStorage, recordUid: string): Record { + const record = storage.getByUid(VaultObjectKind.Record, recordUid) + if (record?.data && typeof record.data === 'object') { + return structuredClone(record.data) as Record + } + return { fields: [] } +} + +async function updateSingleRecord( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + input: UpdateNsfRecordInput +): Promise { + const record = storage.getByUid(VaultObjectKind.Record, recordUid) + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + + const merged = mergeRecordData(loadExistingRecordData(storage, recordUid), input) + const encryptedData = await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey) + + const response = await auth.executeRest( + keeperDriveRecordsUpdate({ + records: [ + { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + revision: record?.revision ?? 0, + data: encryptedData, + }, + ], + clientTime: Date.now(), + }) + ) + + const result = response.records?.[0] + const success = result?.status === Records.RecordModifyResult.RS_SUCCESS || result?.status == null + const statusName = + result?.status != null ? Records.RecordModifyResult[result.status] ?? String(result.status) : 'RS_SUCCESS' + + if (!success) { + throw new KeeperSdkError(result?.message || `Record update failed (${statusName}).`, ResultCodes.NSF_UPDATE_FAILED) + } + + if (record) { + await storage.put({ + ...record, + data: merged, + revision: longToNumber(response.revision) ?? record.revision, + clientModifiedTime: Date.now(), + }) + } + + return { + recordUid, + success: true, + status: statusName, + message: result?.message || 'Record updated successfully', + revision: longToNumber(response.revision ?? record?.revision), + } +} + +export async function updateNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordInput +): Promise { + const identifiers = input.records ?? [] + if (identifiers.length === 0) { + throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) + } + + const updated: UpdateNsfRecordResultItem[] = [] + try { + for (const identifier of identifiers) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + checkRecordEditPermission(storage, recordUid, auth.username, auth.accountUid) + updated.push(await updateSingleRecord(storage, auth, recordUid, input)) + } + return { updated } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to update nested share record(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_UPDATE_FAILED + ) + } +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index dfa22bee..ece9907f 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -64,7 +64,11 @@ export enum NsfErrorCode { RemoveFailed = 'nsf_remove_failed', FolderRequired = 'nsf_folder_required', TooManyRecords = 'nsf_too_many_records', + TooManyFolders = 'nsf_too_many_folders', MissingKey = 'nsf_missing_key', + MkdirFailed = 'nsf_mkdir_failed', + UpdateFailed = 'nsf_update_failed', + DetailsFailed = 'nsf_details_failed', } export enum TeamErrorCode { @@ -143,7 +147,11 @@ export const ResultCodes = { NSF_REMOVE_FAILED: NsfErrorCode.RemoveFailed, NSF_FOLDER_REQUIRED: NsfErrorCode.FolderRequired, NSF_TOO_MANY_RECORDS: NsfErrorCode.TooManyRecords, + NSF_TOO_MANY_FOLDERS: NsfErrorCode.TooManyFolders, NSF_MISSING_KEY: NsfErrorCode.MissingKey, + NSF_MKDIR_FAILED: NsfErrorCode.MkdirFailed, + NSF_UPDATE_FAILED: NsfErrorCode.UpdateFailed, + NSF_DETAILS_FAILED: NsfErrorCode.DetailsFailed, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 80f6e5c0..0766f654 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -80,6 +80,11 @@ import type { ListNsfOptions, ListNsfRow, ListNsfFormatInput, FormattedListNsfTa import type { GetNsfOptions, GetNsfResult } from '../nestedShareFolders/getNsf' import type { LinkNsfRecordResult } from '../nestedShareFolders/linkNsfRecord' import type { RemoveNsfRecordInput, RemoveNsfRecordResult } from '../nestedShareFolders/removeNsfRecord' +import type { MkdirNsfInput, MkdirNsfResult } from '../nestedShareFolders/mkdirNsf' +import type { RemoveNsfFolderInput, RemoveNsfFolderResult } from '../nestedShareFolders/removeNsfFolder' +import type { GetNsfRecordDetailsInput, GetNsfRecordDetailsResult } from '../nestedShareFolders/getNsfRecordDetails' +import type { UpdateNsfRecordInput, UpdateNsfRecordResult } from '../nestedShareFolders/updateNsfRecord' +import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' import type { ListUserRow, ListUsersOptions, @@ -751,6 +756,51 @@ export class KeeperVault { return this.nestedShareFolderManager.formatRemoveNsfPreview(preview) } + public async mkdirNestedShareFolder(input: Omit): Promise { + const current = this.folderSession.currentFolderUid + const baseFolderUid = + current && isNestedShareFolder(this.storage, current) ? current : null + const result = await this.nestedShareFolderManager.mkdirNestedShareFolder({ + ...input, + baseFolderUid, + }) + if (result.created) await this.syncIfNeeded() + return result + } + + public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { + const result = await this.nestedShareFolderManager.removeNestedShareFolders(input) + if (result.confirmed) await this.syncIfNeeded() + return result + } + + public formatRemoveNsfFolderPreview( + preview: RemoveNsfFolderResult['preview'], + operation: RemoveNsfFolderResult['operation'], + quiet?: boolean + ): string { + return this.nestedShareFolderManager.formatRemoveNsfFolderPreview(preview, operation, quiet) + } + + public async getNestedShareRecordDetails( + input: GetNsfRecordDetailsInput + ): Promise { + return this.nestedShareFolderManager.getNestedShareRecordDetails(input) + } + + public formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format?: GetNsfRecordDetailsInput['format'] + ): string { + return this.nestedShareFolderManager.formatNsfRecordDetailsOutput(result, format) + } + + public async updateNestedShareRecords(input: UpdateNsfRecordInput): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareRecords(input) + if (result.updated.some((item) => item.success)) await this.syncIfNeeded() + return result + } + public async shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 165e6c7b..2785e3ea 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -33,6 +33,10 @@ "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", + "nsf:mkdir": "ts-node src/nestedShareFolders/mkdir_nsf.ts", + "nsf:rmdir": "ts-node src/nestedShareFolders/rmdir_nsf.ts", + "nsf:record-details": "ts-node src/nestedShareFolders/get_nsf_record_details.ts", + "nsf:record-update": "ts-node src/nestedShareFolders/update_nsf_record.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts new file mode 100644 index 00000000..61ccb2d2 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts @@ -0,0 +1,46 @@ +import { + cleanup, + extractErrorMessage, + GetNsfRecordDetailsFormat, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function getNsfRecordDetails() { + const vault = await login() + + try { + const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() + const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const formatInput = (await prompt('Output format (table/json) [table]: ')).trim().toLowerCase() + const format = + formatInput === 'json' ? GetNsfRecordDetailsFormat.JSON : GetNsfRecordDetailsFormat.Table + + const restore = suppressLogs() + let result + try { + result = await vault.getNestedShareRecordDetails({ records, format }) + } finally { + restore() + } + + logger.info('') + logger.info(vault.formatNsfRecordDetailsOutput(result, format)) + logger.info('') + } catch (err) { + logger.error(`Record details failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(getNsfRecordDetails) \ No newline at end of file diff --git a/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts new file mode 100644 index 00000000..a4a82898 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts @@ -0,0 +1,56 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_FOLDER_COLORS, + prompt, + suppressLogs, + type MkdirNsfInput, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function mkdirNsf() { + const vault = await login() + + try { + const folder = (await prompt('Folder name or path (e.g. Team/Projects/Q1): ')).trim() + if (!folder) { + logger.info('Folder name is required.') + return + } + + logger.info(`Colors: ${NSF_FOLDER_COLORS.join(', ')}`) + const colorInput = (await prompt('Color (optional, leaf only) [none]: ')).trim().toLowerCase() + const color = + colorInput && (NSF_FOLDER_COLORS as readonly string[]).includes(colorInput) + ? (colorInput as MkdirNsfInput['color']) + : undefined + const noInherit = (await prompt('Do not inherit parent permissions? [y/N]: ')).trim().toLowerCase() === 'y' + + const restore = suppressLogs() + let result + try { + result = await vault.mkdirNestedShareFolder({ + folder, + color, + noInheritPermissions: noInherit, + }) + } finally { + restore() + } + + logger.info('') + if (result.message) logger.info(result.message) + logger.info(`Folder UID: ${result.folderUid}`) + logger.info(`Created: ${result.created ? 'Yes' : 'No'}`) + logger.info('') + } catch (err) { + logger.error(`mkdir failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(mkdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts new file mode 100644 index 00000000..19b95260 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts @@ -0,0 +1,131 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfRemoveFolderOperation, + prompt, + suppressLogs, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveFolderOperation.FolderTrash, + '1': NsfRemoveFolderOperation.FolderTrash, + '2': NsfRemoveFolderOperation.DeletePermanent, + 'folder-trash': NsfRemoveFolderOperation.FolderTrash, + 'delete-permanent': NsfRemoveFolderOperation.DeletePermanent, +} + +function parseOperation(input: string): NsfRemoveFolderOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash +} + +function printPreview( + vault: Awaited>, + result: RemoveNsfFolderResult, + quiet: boolean +): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(vault.formatRemoveNsfFolderPreview(result.preview, result.operation, quiet)) + logger.info('') +} + +function printPreviewWarnings(result: RemoveNsfFolderResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function removeNestedShareFolders( + vault: Awaited>, + input: RemoveNsfFolderInput +): Promise { + const restore = suppressLogs() + try { + return await vault.removeNestedShareFolders(input) + } finally { + restore() + } +} + +async function rmdirNsf() { + const vault = await login() + + try { + const foldersInput = (await prompt('Folder UID(s) or name(s), comma-separated: ')).trim() + const folders = foldersInput.split(',').map((value) => value.trim()).filter(Boolean) + if (folders.length === 0) { + logger.info('At least one folder is required.') + return + } + + logger.info('Operation: 1) folder-trash 2) delete-permanent') + const operation = parseOperation(await prompt('Choose [1]: ')) + const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) + const quiet = isYes(await prompt('Quiet (summary only)? [y/N]: ')) + const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + + if (operation === NsfRemoveFolderOperation.DeletePermanent && !force && !dryRun) { + logger.info('') + logger.info('*** WARNING ***') + logger.info('delete-permanent is IRREVERSIBLE.') + logger.info('All sub-folders and records inside will be permanently destroyed.') + logger.info('') + } + + const baseInput: RemoveNsfFolderInput = { + folders, + operation, + quiet, + } + + if (dryRun) { + const result = await removeNestedShareFolders(vault, { ...baseInput, dryRun: true }) + printPreview(vault, result, quiet) + logger.info('[Dry-run] No folders were deleted.') + return + } + + if (force) { + const result = await removeNestedShareFolders(vault, { ...baseInput, force: true }) + printPreview(vault, result, quiet) + if (result.confirmed && result.message) { + logger.info(result.message) + } + return + } + + const preview = await removeNestedShareFolders(vault, { ...baseInput, force: false }) + printPreview(vault, preview, quiet) + printPreviewWarnings(preview) + + const promptText = + operation === NsfRemoveFolderOperation.DeletePermanent + ? 'Do you want to permanently delete the folder(s) and all their contents? [y/n]: ' + : 'Do you want to proceed with the folder deletion? [y/n]: ' + + if (!isYes(await prompt(promptText))) { + logger.info('Removal cancelled.') + return + } + + const result = await removeNestedShareFolders(vault, { ...baseInput, force: true }) + if (result.confirmed && result.message) { + logger.info(result.message) + } + } catch (err) { + logger.error(`rmdir failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rmdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts new file mode 100644 index 00000000..5ea33197 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts @@ -0,0 +1,71 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, + type UpdateNsfRecordFieldMap, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +function parseFieldSpecs(input: string): UpdateNsfRecordFieldMap { + const fields: UpdateNsfRecordFieldMap = {} + for (const spec of input.split(',').map((value) => value.trim()).filter(Boolean)) { + const separator = spec.indexOf('=') + if (separator <= 0) continue + const type = spec.slice(0, separator).trim() + const value = spec.slice(separator + 1).trim() + if (type) fields[type] = value + } + return fields +} + +async function updateNsfRecord() { + const vault = await login() + + try { + const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() + const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const title = (await prompt('New title (optional): ')).trim() + const recordType = (await prompt('Record type (optional): ')).trim() + const notes = (await prompt('Notes (optional): ')).trim() + const fieldsInput = (await prompt('Fields (type=value, comma-separated, optional): ')).trim() + const fields = fieldsInput ? parseFieldSpecs(fieldsInput) : undefined + + const restore = suppressLogs() + let result + try { + result = await vault.updateNestedShareRecords({ + records, + title: title || undefined, + recordType: recordType || undefined, + notes: notes || undefined, + fields, + }) + } finally { + restore() + } + + logger.info('') + for (const item of result.updated) { + logger.info(`Record: ${item.recordUid}`) + logger.info(` Status: ${item.status}`) + if (item.message) logger.info(` Message: ${item.message}`) + if (item.revision != null) logger.info(` Revision: ${item.revision}`) + logger.info('') + } + } catch (err) { + logger.error(`Record update failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(updateNsfRecord) \ No newline at end of file diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 6d9ab14f..bfb8f9ec 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -1,6 +1,6 @@ // noinspection JSUnusedGlobalSymbols -import { Writer } from 'protobufjs' +import { Reader, Writer } from 'protobufjs' import { AccountSummary, Authentication, @@ -472,6 +472,85 @@ export const removeRecordMessage = ( folder.v3.remove.RemoveResponse ) +export const removeFolderMessage = ( + data: folder.v3.remove.IRemoveFolderRequest +): RestMessage => + createMessage( + data, + 'vault/folders/v3/remove_folder', + folder.v3.remove.RemoveFolderRequest, + folder.v3.remove.RemoveResponse + ) + +export interface IRecordDetailsDataRequest { + recordUids: Uint8Array[] + clientTime?: number +} + +export interface IRecordDetailsDataResponse { + data: Records.IRecordData[] + forbiddenRecords: Uint8Array[] +} + +const RecordDetailsDataRequest = { + create(properties?: IRecordDetailsDataRequest): IRecordDetailsDataRequest { + return { + recordUids: properties?.recordUids ?? [], + clientTime: properties?.clientTime, + } + }, + encode(message: IRecordDetailsDataRequest, writer?: Writer): Writer { + if (!writer) writer = Writer.create() + if (message.clientTime != null) { + writer.uint32(8).int64(message.clientTime) + } + for (const uid of message.recordUids) { + writer.uint32(26).bytes(uid) + } + return writer + }, +} + +const RecordDetailsDataResponse = { + decode(data: Uint8Array): IRecordDetailsDataResponse { + const reader = Reader.create(data) + const response: IRecordDetailsDataResponse = { + data: [], + forbiddenRecords: [], + } + while (reader.pos < reader.len) { + const tag = reader.uint32() + switch (tag >>> 3) { + case 1: + response.data.push(Records.RecordData.decode(reader, reader.uint32())) + break + case 2: + response.forbiddenRecords.push(reader.bytes() as Uint8Array) + break + default: + reader.skipType(tag & 7) + break + } + } + return response + }, +} + +export const recordDetailsDataMessage = ( + data: IRecordDetailsDataRequest +): RestMessage => + createMessage( + data, + 'vault/records/v3/details/data', + RecordDetailsDataRequest, + RecordDetailsDataResponse + ) + +export const folderAddMessage = ( + data: Folder.IFolderAddRequest +): RestMessage => + createMessage(data, 'vault/folders/v3/add', Folder.FolderAddRequest, Folder.FolderAddResponse) + export const recordsAddMessage = ( data: Records.IRecordsAddRequest ): RestMessage => @@ -998,7 +1077,7 @@ export const keeperDriveRecordsAdd = ( export const keeperDriveRecordsUpdate = ( data: Records.IRecordsUpdateRequest ): RestMessage => - createMessage(data, '/vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) + createMessage(data, 'vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) export const getSharingAdminsMessage = ( data: Enterprise.IGetSharingAdminsRequest From 291651aa889a3ee720e1de5cf946ebd22d36c96c Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 18 Jun 2026 15:52:26 +0530 Subject: [PATCH 2/4] nsf record and folder implementation --- KeeperSdk/src/index.ts | 8 + .../NestedShareFolderManager.ts | 5 + .../src/nestedShareFolders/addNsfRecord.ts | 168 +++++++++++++++++ .../nestedShareFolders/getNsfRecordDetails.ts | 19 +- KeeperSdk/src/nestedShareFolders/index.ts | 11 ++ KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 10 +- .../src/nestedShareFolders/nsfHelpers.ts | 87 ++++++--- .../src/nestedShareFolders/nsfRecordCrypto.ts | 18 +- .../src/nestedShareFolders/nsfRecordData.ts | 131 ++++++++++++++ .../src/nestedShareFolders/removeNsfFolder.ts | 21 ++- .../src/nestedShareFolders/updateNsfRecord.ts | 84 +++------ KeeperSdk/src/utils/constants.ts | 2 + KeeperSdk/src/vault/KeeperVault.ts | 7 + examples/sdk_example/package.json | 6 +- .../src/nestedShareFolders/add_nsf_record.ts | 51 ++++++ .../get_nsf_record_details.ts | 15 +- .../src/nestedShareFolders/mkdir_nsf.ts | 56 ------ .../src/nestedShareFolders/nsf_folder.ts | 169 ++++++++++++++++++ .../src/nestedShareFolders/rmdir_nsf.ts | 131 -------------- .../nestedShareFolders/update_nsf_record.ts | 35 ++-- examples/sdk_example/src/utils/format.ts | 14 ++ keeperapi/src/restMessages.ts | 2 +- 22 files changed, 706 insertions(+), 344 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/addNsfRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordData.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts delete mode 100644 examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/nsf_folder.ts delete mode 100644 examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 75bdd02f..f4dd449e 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -485,6 +485,9 @@ export { formatNsfRecordDetailsTable, formatNsfRecordDetailsOutput, updateNestedShareRecords, + addNestedShareRecord, + buildNsfRecordData, + parseNsfFieldStrings, checkRecordEditPermission, NestedShareFolderManager, } from './nestedShareFolders' @@ -522,6 +525,11 @@ export type { UpdateNsfRecordResult, UpdateNsfRecordResultItem, UpdateNsfRecordFieldMap, + AddNsfRecordInput, + AddNsfRecordResult, + NsfRecordFieldMap, + NsfRecordCustomField, + ParsedNsfFieldStrings, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index 552f28d6..d98b5c54 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -46,6 +46,7 @@ import { type UpdateNsfRecordInput, type UpdateNsfRecordResult, } from './updateNsfRecord' +import { addNestedShareRecord, type AddNsfRecordInput, type AddNsfRecordResult } from './addNsfRecord' export type AuthProvider = () => Auth @@ -145,4 +146,8 @@ export class NestedShareFolderManager { public async updateNestedShareRecords(input: UpdateNsfRecordInput): Promise { return updateNestedShareRecords(this.storage, this.requireAuth(), input) } + + public async addNestedShareRecord(input: AddNsfRecordInput): Promise { + return addNestedShareRecord(this.storage, this.requireAuth(), input) + } } diff --git a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts new file mode 100644 index 00000000..d5e0f6bb --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts @@ -0,0 +1,168 @@ +import type { Auth, record as RecordProto } from '@keeper-security/keeperapi' +import { + Folder, + Records, + generateUid, + keeperDriveRecordsAdd, + normal64Bytes, + platform, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + buildNsfRecordData, + getPaddedJsonBytes, + type NsfRecordCustomField, + type NsfRecordFieldMap, +} from './nsfRecordData' +import { + ensureNestedShareFolder, + nsfToNumber, + resolveNsfFolderIdentifier, +} from './nsfHelpers' + +const RECORD_KEY_BYTE_LENGTH = 32 + +export type { NsfRecordFieldMap, NsfRecordCustomField } from './nsfRecordData' + +export type AddNsfRecordInput = { + title: string + recordType: string + folder?: string + notes?: string + fields?: NsfRecordFieldMap + custom?: NsfRecordCustomField[] + recordData?: Record + force?: boolean + hasFileFields?: boolean +} + +export type AddNsfRecordResult = { + recordUid: string + success: boolean + status: string + message?: string + revision?: number +} + +function resolveFolderUid(storage: InMemoryStorage, folderInput?: string): string | undefined { + const trimmed = folderInput?.trim() + if (!trimmed) return undefined + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed) + if (!folderUid) { + throw new KeeperSdkError(`No such folder: ${trimmed}`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, trimmed) + return folderUid +} + +async function resolveFolderKey(storage: InMemoryStorage, folderUid: string): Promise { + const folderKey = await storage.getKeyBytes(folderUid) + if (folderKey) return folderKey + throw new KeeperSdkError( + `Folder key not found for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) +} + +async function buildRecordAdd( + storage: InMemoryStorage, + auth: Auth, + recordData: Record, + folderUid?: string +): Promise<{ recordUid: string; recordAdd: RecordProto.v3.IRecordAdd }> { + const recordUid = generateUid() + const recordKey = platform.getRandomBytes(RECORD_KEY_BYTE_LENGTH) + await storage.saveKeyBytes(recordUid, recordKey) + + const recordAdd: RecordProto.v3.IRecordAdd = { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + data: await platform.aesGcmEncrypt(getPaddedJsonBytes(recordData), recordKey), + } + + if (folderUid) { + const folderKey = await resolveFolderKey(storage, folderUid) + recordAdd.folderUid = normal64Bytes(folderUid) + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, folderKey) + recordAdd.recordKeyEncryptedBy = Folder.FolderKeyEncryptionType.ENCRYPTED_BY_PARENT_KEY + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm + } else { + if (!auth.dataKey) { + throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) + } + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, auth.dataKey) + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm + } + + return { recordUid, recordAdd } +} + +export async function addNestedShareRecord( + storage: InMemoryStorage, + auth: Auth, + input: AddNsfRecordInput +): Promise { + if (!input.title?.trim()) { + throw new KeeperSdkError('Record title is required.', ResultCodes.NSF_ADD_FAILED) + } + if (!input.recordType?.trim() && !input.recordData) { + throw new KeeperSdkError('Record type is required.', ResultCodes.NSF_ADD_FAILED) + } + if (input.hasFileFields && !input.force) { + throw new KeeperSdkError( + 'File attachments are not supported in nested share record add.', + ResultCodes.NSF_ADD_FAILED + ) + } + + const recordData = + input.recordData ?? + buildNsfRecordData({ + title: input.title, + recordType: input.recordType, + notes: input.notes, + fields: input.fields, + custom: input.custom, + }) + + const folderUid = resolveFolderUid(storage, input.folder) + + try { + const { recordUid, recordAdd } = await buildRecordAdd(storage, auth, recordData, folderUid) + const response = await auth.executeRest( + keeperDriveRecordsAdd({ + records: [recordAdd], + clientTime: Date.now(), + }) + ) + + const result = response.records?.[0] + if (!result) { + throw new KeeperSdkError('No results from record creation.', ResultCodes.NSF_ADD_FAILED) + } + + const success = result.status === Records.RecordModifyResult.RS_SUCCESS || result.status == null + const statusName = + result.status != null ? Records.RecordModifyResult[result.status] ?? String(result.status) : 'RS_SUCCESS' + + if (!success) { + throw new KeeperSdkError(result.message || `Record creation failed (${statusName}).`, ResultCodes.NSF_ADD_FAILED) + } + + return { + recordUid, + success: true, + status: statusName, + message: result.message || 'Record created successfully', + revision: nsfToNumber(response.revision), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to add nested share record: ${extractErrorMessage(err)}`, + ResultCodes.NSF_ADD_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts index 4e7d0b2b..25ef429f 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -2,16 +2,8 @@ import type { Auth } from '@keeper-security/keeperapi' import { normal64Bytes, recordDetailsDataMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { - ensureNestedShareRecord, - resolveNsfRecordIdentifier, -} from './nsfHelpers' import { decryptRecordTitleAndType } from './nsfRecordCrypto' - -function longToNumber(value: number | { toNumber: () => number } | null | undefined): number { - if (value == null) return 0 - return typeof value === 'number' ? value : value.toNumber() -} +import { ensureNestedShareRecord, nsfToNumber, resolveNsfRecordIdentifier } from './nsfHelpers' export enum GetNsfRecordDetailsFormat { Table = 'table', @@ -80,8 +72,7 @@ export function formatNsfRecordDetailsOutput( result: GetNsfRecordDetailsResult, format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table ): string { - const value = String(format).toLowerCase() - if (value === GetNsfRecordDetailsFormat.JSON || value === 'json') { + if (String(format).toLowerCase() === GetNsfRecordDetailsFormat.JSON) { return JSON.stringify(result, null, 2) } return formatNsfRecordDetailsTable(result) @@ -103,7 +94,7 @@ export async function getNestedShareRecordDetails( ) const data: NsfRecordDetailsItem[] = [] - for (const item of response.data) { + for (const item of response.data ?? []) { const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' if (!recordUid) continue const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) @@ -111,14 +102,14 @@ export async function getNestedShareRecordDetails( recordUid, title, type, - revision: longToNumber(item.revision), + revision: nsfToNumber(item.revision, 0) ?? 0, version: item.version ?? 0, }) } return { data, - forbiddenRecords: response.forbiddenRecords.map((uid) => webSafe64FromBytes(uid)), + forbiddenRecords: (response.forbiddenRecords ?? []).map((uid) => webSafe64FromBytes(uid)), } } catch (err) { if (err instanceof KeeperSdkError) throw err diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index ccc024cd..48c9d683 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -118,4 +118,15 @@ export type { UpdateNsfRecordFieldMap, } from './updateNsfRecord' +export { addNestedShareRecord } from './addNsfRecord' +export type { AddNsfRecordInput, AddNsfRecordResult } from './addNsfRecord' + +export { + buildNsfRecordData, + parseNsfFieldStrings, + type NsfRecordFieldMap, + type NsfRecordCustomField, + type ParsedNsfFieldStrings, +} from './nsfRecordData' + export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts index 4d616786..08fb4d98 100644 --- a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -20,6 +20,8 @@ import { parseNsfPath, } from './nsfHelpers' +const FOLDER_KEY_BYTE_LENGTH = 32 + export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` export type MkdirNsfInput = { @@ -81,7 +83,7 @@ async function prepareFolderData( inheritPermissions: boolean ): Promise<{ folderUid: string; folderData: Folder.IFolderData }> { const folderUid = generateUid() - const folderKey = platform.getRandomBytes(32) + const folderKey = platform.getRandomBytes(FOLDER_KEY_BYTE_LENGTH) await storage.saveKeyBytes(folderUid, folderKey) const metadata: { name: string; color?: string } = { name: folderName } @@ -118,7 +120,7 @@ async function createFolderV3( parentUid: string | null, color: NsfFolderColor | undefined, inheritPermissions: boolean -): Promise<{ folderUid: string; success: boolean; message: string }> { +): Promise<{ folderUid: string; message: string }> { const { folderUid, folderData } = await prepareFolderData( storage, auth, @@ -135,15 +137,13 @@ async function createFolderV3( } const statusName = Folder.FolderModifyStatus[result.status ?? Folder.FolderModifyStatus.SUCCESS] ?? 'UNKNOWN' - const success = result.status === Folder.FolderModifyStatus.SUCCESS - if (!success) { + if (result.status !== Folder.FolderModifyStatus.SUCCESS) { throw new KeeperSdkError(result.message || `Folder creation failed (${statusName}).`, ResultCodes.NSF_MKDIR_FAILED) } await cacheNewNsfFolder(storage, auth, folderUid, folderName, parentUid, inheritPermissions) return { folderUid, - success, message: result.message || 'Folder created successfully', } } diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index c5f97e2e..2ffbf9d7 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -37,6 +37,14 @@ export enum NsfItemType { Record = 'Record', } +export function nsfToNumber( + value: number | { toNumber: () => number } | null | undefined, + fallback?: number +): number | undefined { + if (value == null) return fallback + return typeof value === 'number' ? value : value.toNumber() +} + export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string): boolean { return !!getKeeperDriveRecord(storage, recordUid) } @@ -169,18 +177,62 @@ export function parseNsfPath(folderPath: string): string[] { return segments } +function getKnownKeeperDriveFolderUids(storage: InMemoryStorage): Set { + return new Set(getKeeperDriveFolders(storage).map((folder) => folder.uid)) +} + +function isVirtualDriveRootParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { + const trimmed = parentUid?.trim() + if (!trimmed || isRootFolderUid(trimmed)) return true + return !getKnownKeeperDriveFolderUids(storage).has(trimmed) +} + +function isRootLevelParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { + return isVirtualDriveRootParent(storage, parentUid) +} + +function folderParentsMatch( + storage: InMemoryStorage, + folderParentUid: string | undefined, + searchParentUid: string | null | undefined +): boolean { + if (normalizeParentUid(folderParentUid) === normalizeParentUid(searchParentUid)) return true + return isRootLevelParent(storage, searchParentUid) && isRootLevelParent(storage, folderParentUid) +} + export function findExistingChildFolder( storage: InMemoryStorage, segment: string, parentUid: string | null | undefined ): string | undefined { - const normalizedParent = normalizeParentUid(parentUid) const lower = segment.toLowerCase() + const exactMatches: string[] = [] + const rootMatches: string[] = [] + for (const folder of getKeeperDriveFolders(storage)) { - const folderParent = normalizeParentUid(folder.parentUid) const name = folder.data.name || '' - if (folderParent === normalizedParent && name.toLowerCase() === lower) { - return folder.uid + if (name.toLowerCase() !== lower) continue + + if (normalizeParentUid(folder.parentUid) === normalizeParentUid(parentUid)) { + exactMatches.push(folder.uid) + continue + } + if (folderParentsMatch(storage, folder.parentUid, parentUid)) { + rootMatches.push(folder.uid) + } + } + + if (exactMatches.length > 0) return exactMatches[0] + if (rootMatches.length > 0) return rootMatches[0] + return undefined +} + +function resolveKeeperDriveRootParentUid(storage: InMemoryStorage): string | undefined { + const knownFolderUids = getKnownKeeperDriveFolderUids(storage) + for (const folder of getKeeperDriveFolders(storage)) { + const parentUid = folder.parentUid?.trim() + if (parentUid && !knownFolderUids.has(parentUid) && !isRootFolderUid(parentUid)) { + return parentUid } } return undefined @@ -194,7 +246,10 @@ export async function cacheNewNsfFolder( parentUid: string | null | undefined, inheritPermissions: boolean ): Promise { - const normalizedParent = isRootFolderUid(parentUid) ? undefined : parentUid?.trim() || undefined + const normalizedParent = + parentUid && !isRootFolderUid(parentUid) + ? parentUid.trim() + : resolveKeeperDriveRootParentUid(storage) await storage.put({ kind: 'keeper_drive_folder', uid: folderUid, @@ -372,9 +427,6 @@ export function checkFolderRemovePermission( accountUid: Uint8Array ): void { if (hasFolderPermission(storage, folderUid, username, accountUid, 'canRemove')) return - // Folder-trash and unlink are less destructive than owner-trash. Allow when the user - // can permanently delete the record, or owns it without explicit record-access entries - // (common for records in a personal drive root with no folder-access sync data). if (canRecordBeDeleted(storage, recordUid, username, accountUid, folderUid)) return throw new KeeperSdkError( 'You do not have permission to remove records from this folder.', @@ -400,25 +452,14 @@ export function checkRecordEditPermission( storage: InMemoryStorage, recordUid: string, username: string, - accountUid?: Uint8Array + accountUid: Uint8Array ): void { - const entries = storage - .getAll(KeeperDriveKind.RecordAccess) - .filter((entry) => entry.recordUid === recordUid) + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getRecordAccessEntries(storage, recordUid) if (entries.length === 0) return - const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' for (const entry of entries) { - const isCurrentUser = - (entry.accessType === Folder.AccessType.AT_USER && - entry.accessTypeUid === accountUidStr) || - (username && - storage.getAll('user').some( - (user) => - user.username === username && - webSafe64FromBytes(user.accountUid) === entry.accessTypeUid - )) - if (!isCurrentUser) continue + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue if (entry.owner || entry.canEdit) return throw new KeeperSdkError( 'You do not have permission to edit this record.', diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts index 8ffb0d0f..1a76c12d 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts @@ -3,6 +3,8 @@ import { Records, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-se import type { InMemoryStorage } from '../storage/InMemoryStorage' import { findNestedShareFoldersForRecord } from './nsfHelpers' +const UNKNOWN_RECORD_LABEL = 'Unknown' + function decodePayload(value: string | Uint8Array | null | undefined): Uint8Array { if (!value) return new Uint8Array(0) if (value instanceof Uint8Array) return value @@ -23,7 +25,7 @@ async function decryptWithFolderKeys( try { return await platform.aesCbcDecrypt(encryptedKey, folderKey, true) } catch { - // try next folder key + continue } } } @@ -50,10 +52,8 @@ export async function resolveRecordKeyBytes( } return await platform.aesGcmDecrypt(encryptedKey, auth.dataKey) } catch { - // fall through to folder keys + return decryptWithFolderKeys(storage, recordUid, encryptedKey) } - - return decryptWithFolderKeys(storage, recordUid, encryptedKey) } export async function decryptRecordTitleAndType( @@ -71,12 +71,12 @@ export async function decryptRecordTitleAndType( recordData.recordKeyType ) if (!recordKey) { - return { title: 'Unknown', type: 'Unknown' } + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } } const encryptedData = decodePayload(recordData.encryptedRecordData) if (!encryptedData.length) { - return { title: 'Unknown', type: 'Unknown' } + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } } try { @@ -86,10 +86,10 @@ export async function decryptRecordTitleAndType( type?: string } return { - title: parsed.title?.trim() || 'Unknown', - type: parsed.type?.trim() || 'Unknown', + title: parsed.title?.trim() || UNKNOWN_RECORD_LABEL, + type: parsed.type?.trim() || UNKNOWN_RECORD_LABEL, } } catch { - return { title: 'Unknown', type: 'Unknown' } + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } } } diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts new file mode 100644 index 00000000..56dc7b3e --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts @@ -0,0 +1,131 @@ +import { platform } from '@keeper-security/keeperapi' + +const MIN_RECORD_PAD_BYTES = 384 +const PAD_BLOCK_SIZE = 16 +const LEGACY_RECORD_TYPES = new Set(['legacy', 'general']) + +export type NsfRecordFieldMap = Record + +export type NsfRecordCustomField = { + type: string + label?: string + value: string | string[] +} + +export type BuildNsfRecordDataInput = { + title: string + recordType: string + notes?: string + fields?: NsfRecordFieldMap + custom?: NsfRecordCustomField[] +} + +export function normalizeNsfFieldValue(value: string | string[]): string[] { + return Array.isArray(value) ? value : [value] +} + +export function getPaddedJsonBytes(data: Record): Uint8Array { + const json = JSON.stringify(data) + const paddedLength = Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * PAD_BLOCK_SIZE + return platform.stringToBytes(json.padEnd(paddedLength, ' ')) +} + +function fieldsMapToArray(fields: NsfRecordFieldMap): { type: string; value: string[] }[] { + return Object.entries(fields).map(([type, value]) => ({ + type, + value: normalizeNsfFieldValue(value), + })) +} + +export function buildNsfRecordData(input: BuildNsfRecordDataInput): Record { + const title = input.title.trim() + const recordType = input.recordType.trim() + const data: Record = { + type: LEGACY_RECORD_TYPES.has(recordType) ? 'login' : recordType, + title, + fields: input.fields ? fieldsMapToArray(input.fields) : [], + custom: (input.custom ?? []).map((field) => ({ + type: field.type, + label: field.label ?? '', + value: normalizeNsfFieldValue(field.value), + })), + } + const notes = input.notes?.trim() + if (notes) data.notes = notes + return data +} + +export function mergeNsfRecordData( + existing: Record, + input: Partial +): Record { + const data: Record = { + ...existing, + fields: Array.isArray(existing.fields) ? [...(existing.fields as Record[])] : [], + } + + if (input.title !== undefined) data.title = input.title.trim() + if (input.recordType !== undefined) { + const recordType = input.recordType.trim() + data.type = LEGACY_RECORD_TYPES.has(recordType) ? 'login' : recordType + } + if (input.notes !== undefined) data.notes = input.notes + + if (input.fields) { + const fields = data.fields as { type?: string; value?: string[] }[] + const byType = new Map() + for (const field of fields) { + const fieldType = field?.type + if (!fieldType) continue + if (!byType.has(fieldType)) byType.set(fieldType, []) + byType.get(fieldType)!.push(field) + } + for (const [fieldType, value] of Object.entries(input.fields)) { + const normalized = normalizeNsfFieldValue(value) + const matches = byType.get(fieldType) + if (matches?.length) matches[0].value = normalized + else fields.push({ type: fieldType, value: normalized }) + } + data.fields = fields + } + + return data +} + +export type ParsedNsfFieldStrings = { + fields: NsfRecordFieldMap + custom: NsfRecordCustomField[] + hasFileFields: boolean +} + +export function parseNsfFieldStrings(rawFields: string[]): ParsedNsfFieldStrings { + const fields: NsfRecordFieldMap = {} + const custom: NsfRecordCustomField[] = [] + let hasFileFields = false + + for (const raw of rawFields) { + const field = raw.trim() + if (!field) continue + const separator = field.indexOf('=') + if (separator <= 0) continue + + const key = field.slice(0, separator).trim() + const value = field.slice(separator + 1).trim() + if (!key) continue + + if (key.toLowerCase() === 'file') { + hasFileFields = true + continue + } + + const labeled = key.match(/^"(.+)"$/) + if (labeled) { + custom.push({ type: 'text', label: labeled[1], value }) + continue + } + + fields[key] = value + } + + return { fields, custom, hasFileFields } +} diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts index 21b17b47..19878b37 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -12,6 +12,8 @@ import { const { RemoveAction, FolderOperationType, RemoveStatus } = folder.v3.remove const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] +const TRASH_ACTION_LABEL = 'moved to trash' +const PERMANENT_DELETE_ACTION_LABEL = 'permanently deleted' export enum NsfRemoveFolderOperation { FolderTrash = 'folder-trash', @@ -88,6 +90,11 @@ function buildRemovals( ) } + const accountUid = auth.accountUid + if (!accountUid?.length) { + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + } + const removals: RemovalSpec[] = [] for (const identifier of folderIdentifiers) { const folderUid = resolveNsfFolderUidOrName(storage, identifier) @@ -95,7 +102,7 @@ function buildRemovals( throw new KeeperSdkError(`Folder '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) } ensureNestedShareFolder(storage, folderUid, identifier) - checkFolderDeletePermission(storage, folderUid, auth.username, auth.accountUid) + checkFolderDeletePermission(storage, folderUid, auth.username, accountUid) removals.push({ folderUid, name: getFolderDisplayName(storage, folderUid), @@ -154,7 +161,13 @@ function mapPreview( removals: RemovalSpec[], response: FolderProto.v3.remove.IRemoveResponse ): NsfRemoveFolderPreviewItem[] { - return response.results.map((item, index) => mapPreviewItem(removals[index] ?? removals[0], item)) + return (response.results ?? []).map((item, index) => { + const spec = removals[index] + if (!spec) { + throw new KeeperSdkError('Folder removal preview mismatch.', ResultCodes.NSF_REMOVE_FAILED) + } + return mapPreviewItem(spec, item) + }) } function hasPreviewErrors(preview: NsfRemoveFolderPreviewItem[]): boolean { @@ -167,7 +180,9 @@ export function formatRemoveNsfFolderPreview( quiet = false ): string { const action = - operation === NsfRemoveFolderOperation.DeletePermanent ? 'permanently deleted' : 'moved to trash' + operation === NsfRemoveFolderOperation.DeletePermanent + ? PERMANENT_DELETE_ACTION_LABEL + : TRASH_ACTION_LABEL const lines: string[] = [] for (const item of preview) { diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts index 8f42c298..cdc0a5c0 100644 --- a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -8,21 +8,23 @@ import { import type { InMemoryStorage } from '../storage/InMemoryStorage' import { VaultObjectKind } from '../folders/folderHelpers' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import { getPaddedJsonBytes, mergeNsfRecordData, type NsfRecordFieldMap } from './nsfRecordData' import { checkRecordEditPermission, ensureNestedShareRecord, + nsfToNumber, resolveNsfRecordIdentifier, } from './nsfHelpers' -import { resolveRecordKeyBytes } from './nsfRecordCrypto' -export type UpdateNsfRecordFieldMap = Record +export type { NsfRecordFieldMap as UpdateNsfRecordFieldMap } from './nsfRecordData' export type UpdateNsfRecordInput = { records: string[] title?: string recordType?: string notes?: string - fields?: UpdateNsfRecordFieldMap + fields?: NsfRecordFieldMap } export type UpdateNsfRecordResultItem = { @@ -37,63 +39,6 @@ export type UpdateNsfRecordResult = { updated: UpdateNsfRecordResultItem[] } -const MIN_RECORD_PAD_BYTES = 384 -const PAD_BLOCK_SIZE = 16 - -function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { - if (value == null) return undefined - return typeof value === 'number' ? value : value.toNumber() -} - -function getPaddedJsonBytes(data: Record): Uint8Array { - const json = JSON.stringify(data) - const paddedLength = Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * PAD_BLOCK_SIZE - const padded = json.padEnd(paddedLength, ' ') - return platform.stringToBytes(padded) -} - -function normalizeFieldValue(value: string | string[]): string[] { - return Array.isArray(value) ? value : [value] -} - -function mergeRecordData( - existing: Record, - input: Pick -): Record { - const data: Record = { - ...existing, - fields: Array.isArray(existing.fields) ? [...(existing.fields as Record[])] : [], - } - - if (input.title !== undefined) data.title = input.title - if (input.recordType !== undefined) data.type = input.recordType - if (input.notes !== undefined) data.notes = input.notes - - if (input.fields) { - const fields = data.fields as { type?: string; value?: string[] }[] - const byType = new Map() - for (const field of fields) { - const fieldType = field?.type - if (!fieldType) continue - if (!byType.has(fieldType)) byType.set(fieldType, []) - byType.get(fieldType)!.push(field) - } - - for (const [fieldType, value] of Object.entries(input.fields)) { - const normalized = normalizeFieldValue(value) - const matches = byType.get(fieldType) - if (matches?.length) { - matches[0].value = normalized - } else { - fields.push({ type: fieldType, value: normalized }) - } - } - data.fields = fields - } - - return data -} - function loadExistingRecordData(storage: InMemoryStorage, recordUid: string): Record { const record = storage.getByUid(VaultObjectKind.Record, recordUid) if (record?.data && typeof record.data === 'object') { @@ -102,6 +47,14 @@ function loadExistingRecordData(storage: InMemoryStorage, recordUid: string): Re return { fields: [] } } +function requireAccountUid(auth: Auth): Uint8Array { + const accountUid = auth.accountUid + if (!accountUid?.length) { + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + } + return accountUid +} + async function updateSingleRecord( storage: InMemoryStorage, auth: Auth, @@ -117,7 +70,7 @@ async function updateSingleRecord( ) } - const merged = mergeRecordData(loadExistingRecordData(storage, recordUid), input) + const merged = mergeNsfRecordData(loadExistingRecordData(storage, recordUid), input) const encryptedData = await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey) const response = await auth.executeRest( @@ -143,11 +96,12 @@ async function updateSingleRecord( throw new KeeperSdkError(result?.message || `Record update failed (${statusName}).`, ResultCodes.NSF_UPDATE_FAILED) } + const revision = nsfToNumber(response.revision) ?? record?.revision if (record) { await storage.put({ ...record, data: merged, - revision: longToNumber(response.revision) ?? record.revision, + revision: revision ?? record.revision, clientModifiedTime: Date.now(), }) } @@ -157,7 +111,7 @@ async function updateSingleRecord( success: true, status: statusName, message: result?.message || 'Record updated successfully', - revision: longToNumber(response.revision ?? record?.revision), + revision, } } @@ -171,7 +125,9 @@ export async function updateNestedShareRecords( throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) } + const accountUid = requireAccountUid(auth) const updated: UpdateNsfRecordResultItem[] = [] + try { for (const identifier of identifiers) { const recordUid = resolveNsfRecordIdentifier(storage, identifier) @@ -179,7 +135,7 @@ export async function updateNestedShareRecords( throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) } ensureNestedShareRecord(storage, recordUid, identifier) - checkRecordEditPermission(storage, recordUid, auth.username, auth.accountUid) + checkRecordEditPermission(storage, recordUid, auth.username, accountUid) updated.push(await updateSingleRecord(storage, auth, recordUid, input)) } return { updated } diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index ece9907f..f67696b5 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -69,6 +69,7 @@ export enum NsfErrorCode { MkdirFailed = 'nsf_mkdir_failed', UpdateFailed = 'nsf_update_failed', DetailsFailed = 'nsf_details_failed', + AddFailed = 'nsf_add_failed', } export enum TeamErrorCode { @@ -152,6 +153,7 @@ export const ResultCodes = { NSF_MKDIR_FAILED: NsfErrorCode.MkdirFailed, NSF_UPDATE_FAILED: NsfErrorCode.UpdateFailed, NSF_DETAILS_FAILED: NsfErrorCode.DetailsFailed, + NSF_ADD_FAILED: NsfErrorCode.AddFailed, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 0766f654..363236ea 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -84,6 +84,7 @@ import type { MkdirNsfInput, MkdirNsfResult } from '../nestedShareFolders/mkdirN import type { RemoveNsfFolderInput, RemoveNsfFolderResult } from '../nestedShareFolders/removeNsfFolder' import type { GetNsfRecordDetailsInput, GetNsfRecordDetailsResult } from '../nestedShareFolders/getNsfRecordDetails' import type { UpdateNsfRecordInput, UpdateNsfRecordResult } from '../nestedShareFolders/updateNsfRecord' +import type { AddNsfRecordInput, AddNsfRecordResult } from '../nestedShareFolders/addNsfRecord' import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' import type { ListUserRow, @@ -801,6 +802,12 @@ export class KeeperVault { return result } + public async addNestedShareRecord(input: AddNsfRecordInput): Promise { + const result = await this.nestedShareFolderManager.addNestedShareRecord(input) + if (result.success) await this.syncIfNeeded() + return result + } + public async shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 2785e3ea..a84d3b35 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -33,10 +33,12 @@ "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", - "nsf:mkdir": "ts-node src/nestedShareFolders/mkdir_nsf.ts", - "nsf:rmdir": "ts-node src/nestedShareFolders/rmdir_nsf.ts", + "nsf:folder": "ts-node src/nestedShareFolders/nsf_folder.ts", + "nsf:mkdir": "ts-node src/nestedShareFolders/nsf_folder.ts", + "nsf:rmdir": "ts-node src/nestedShareFolders/nsf_folder.ts", "nsf:record-details": "ts-node src/nestedShareFolders/get_nsf_record_details.ts", "nsf:record-update": "ts-node src/nestedShareFolders/update_nsf_record.ts", + "nsf:record-add": "ts-node src/nestedShareFolders/add_nsf_record.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts new file mode 100644 index 00000000..c3193558 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts @@ -0,0 +1,51 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + parseNsfFieldStrings, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, withSuppressedLogs } from '../utils/format' + +async function addNsfRecord() { + const vault = await login() + + try { + const title = (await prompt('Record title: ')).trim() + const recordType = (await prompt('Record type (e.g. login, general): ')).trim() + const folder = (await prompt('Folder name or UID (optional, Enter for root): ')).trim() + const notes = (await prompt('Notes (optional): ')).trim() + const fieldsInput = (await prompt('Fields (field=value, space-separated, optional): ')).trim() + const force = isYes(await prompt('Ignore warnings (e.g. attachments)? [y/N]: ')) + + const parsed = fieldsInput ? parseNsfFieldStrings(fieldsInput.split(/\s+/)) : undefined + const result = await withSuppressedLogs(() => + vault.addNestedShareRecord({ + title, + recordType, + folder: folder || undefined, + notes: notes || undefined, + fields: parsed?.fields, + custom: parsed?.custom, + hasFileFields: parsed?.hasFileFields, + force, + }) + ) + + logger.info('') + logger.info(`Record UID: ${result.recordUid}`) + logger.info(`Status: ${result.status}`) + if (result.message) logger.info(`Message: ${result.message}`) + if (result.revision != null) logger.info(`Revision: ${result.revision}`) + logger.info('') + } catch (err) { + logger.error(`Record add failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(addNsfRecord) diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts index 61ccb2d2..57d275d7 100644 --- a/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts @@ -5,16 +5,15 @@ import { login, logger, prompt, - suppressLogs, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' async function getNsfRecordDetails() { const vault = await login() try { - const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() - const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + const records = splitCommaSeparated(await prompt('Record UID(s) or title(s), comma-separated: ')) if (records.length === 0) { logger.info('At least one record is required.') return @@ -24,13 +23,7 @@ async function getNsfRecordDetails() { const format = formatInput === 'json' ? GetNsfRecordDetailsFormat.JSON : GetNsfRecordDetailsFormat.Table - const restore = suppressLogs() - let result - try { - result = await vault.getNestedShareRecordDetails({ records, format }) - } finally { - restore() - } + const result = await withSuppressedLogs(() => vault.getNestedShareRecordDetails({ records, format })) logger.info('') logger.info(vault.formatNsfRecordDetailsOutput(result, format)) @@ -43,4 +36,4 @@ async function getNsfRecordDetails() { } } -runExample(getNsfRecordDetails) \ No newline at end of file +runExample(getNsfRecordDetails) diff --git a/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts deleted file mode 100644 index a4a82898..00000000 --- a/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - cleanup, - extractErrorMessage, - login, - logger, - NSF_FOLDER_COLORS, - prompt, - suppressLogs, - type MkdirNsfInput, -} from '@keeper-security/keeper-sdk-javascript' -import { runExample } from '../utils/runner' - -async function mkdirNsf() { - const vault = await login() - - try { - const folder = (await prompt('Folder name or path (e.g. Team/Projects/Q1): ')).trim() - if (!folder) { - logger.info('Folder name is required.') - return - } - - logger.info(`Colors: ${NSF_FOLDER_COLORS.join(', ')}`) - const colorInput = (await prompt('Color (optional, leaf only) [none]: ')).trim().toLowerCase() - const color = - colorInput && (NSF_FOLDER_COLORS as readonly string[]).includes(colorInput) - ? (colorInput as MkdirNsfInput['color']) - : undefined - const noInherit = (await prompt('Do not inherit parent permissions? [y/N]: ')).trim().toLowerCase() === 'y' - - const restore = suppressLogs() - let result - try { - result = await vault.mkdirNestedShareFolder({ - folder, - color, - noInheritPermissions: noInherit, - }) - } finally { - restore() - } - - logger.info('') - if (result.message) logger.info(result.message) - logger.info(`Folder UID: ${result.folderUid}`) - logger.info(`Created: ${result.created ? 'Yes' : 'No'}`) - logger.info('') - } catch (err) { - logger.error(`mkdir failed: ${extractErrorMessage(err)}`) - process.exitCode = 1 - } finally { - cleanup(vault) - } -} - -runExample(mkdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts b/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts new file mode 100644 index 00000000..30600390 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts @@ -0,0 +1,169 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_FOLDER_COLORS, + NsfRemoveFolderOperation, + prompt, + type MkdirNsfInput, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': 'mkdir', + '1': 'mkdir', + '2': 'rmdir', + mkdir: 'mkdir', + create: 'mkdir', + rmdir: 'rmdir', + remove: 'rmdir', +} + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveFolderOperation.FolderTrash, + '1': NsfRemoveFolderOperation.FolderTrash, + '2': NsfRemoveFolderOperation.DeletePermanent, + 'folder-trash': NsfRemoveFolderOperation.FolderTrash, + 'delete-permanent': NsfRemoveFolderOperation.DeletePermanent, +} + +const PERMANENT_DELETE_WARNING_LINES = [ + '*** WARNING ***', + 'delete-permanent is IRREVERSIBLE.', + 'All sub-folders and records inside will be permanently destroyed.', +] as const + +function parseAction(input: string): 'mkdir' | 'rmdir' { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? 'mkdir' +} + +function parseOperation(input: string): NsfRemoveFolderOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash +} + +function logPreview(vault: Vault, result: RemoveNsfFolderResult, quiet: boolean): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(vault.formatRemoveNsfFolderPreview(result.preview, result.operation, quiet)) + logger.info('') +} + +function logPreviewWarnings(result: RemoveNsfFolderResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function mkdirNsf(vault: Vault): Promise { + const folder = (await prompt('Folder name or path (e.g. Team/Projects/Q1): ')).trim() + if (!folder) { + logger.info('Folder name is required.') + return + } + + logger.info(`Colors: ${NSF_FOLDER_COLORS.join(', ')}`) + const colorInput = (await prompt('Color (optional, leaf only) [none]: ')).trim().toLowerCase() + const color = + colorInput && (NSF_FOLDER_COLORS as readonly string[]).includes(colorInput) + ? (colorInput as MkdirNsfInput['color']) + : undefined + const noInherit = (await prompt('Do not inherit parent permissions? [y/N]: ')).trim().toLowerCase() === 'y' + + const result = await withSuppressedLogs(() => + vault.mkdirNestedShareFolder({ + folder, + color, + noInheritPermissions: noInherit, + }) + ) + + logger.info('') + if (result.message) logger.info(result.message) + logger.info(`Folder UID: ${result.folderUid}`) + logger.info(`Created: ${result.created ? 'Yes' : 'No'}`) + logger.info('') +} + +async function rmdirNsf(vault: Vault): Promise { + const folders = splitCommaSeparated(await prompt('Folder UID(s) or name(s), comma-separated: ')) + if (folders.length === 0) { + logger.info('At least one folder is required.') + return + } + + logger.info('Operation: 1) folder-trash 2) delete-permanent') + const operation = parseOperation(await prompt('Choose [1]: ')) + const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) + const quiet = isYes(await prompt('Quiet (summary only)? [y/N]: ')) + const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + + if (operation === NsfRemoveFolderOperation.DeletePermanent && !force && !dryRun) { + logger.info('') + for (const line of PERMANENT_DELETE_WARNING_LINES) { + logger.info(line) + } + logger.info('') + } + + const baseInput: RemoveNsfFolderInput = { folders, operation, quiet } + + if (dryRun) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, dryRun: true })) + logPreview(vault, result, quiet) + logger.info('[Dry-run] No folders were deleted.') + return + } + + if (force) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + logPreview(vault, result, quiet) + if (result.confirmed && result.message) logger.info(result.message) + return + } + + const preview = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: false })) + logPreview(vault, preview, quiet) + logPreviewWarnings(preview) + + const confirmPrompt = + operation === NsfRemoveFolderOperation.DeletePermanent + ? 'Do you want to permanently delete the folder(s) and all their contents? [y/n]: ' + : 'Do you want to proceed with the folder deletion? [y/n]: ' + + if (!isYes(await prompt(confirmPrompt))) { + logger.info('Removal cancelled.') + return + } + + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + if (result.confirmed && result.message) logger.info(result.message) +} + +async function nsfFolder() { + const vault = await login() + + try { + logger.info('Action: 1) create folder 2) remove folder') + const action = parseAction(await prompt('Choose [1]: ')) + if (action === 'mkdir') { + await mkdirNsf(vault) + } else { + await rmdirNsf(vault) + } + } catch (err) { + logger.error(`Folder operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(nsfFolder) diff --git a/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts deleted file mode 100644 index 19b95260..00000000 --- a/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - cleanup, - extractErrorMessage, - login, - logger, - NsfRemoveFolderOperation, - prompt, - suppressLogs, - type RemoveNsfFolderInput, - type RemoveNsfFolderResult, -} from '@keeper-security/keeper-sdk-javascript' -import { runExample } from '../utils/runner' -import { isYes } from '../utils/format' - -const OPERATION_BY_INPUT: Record = { - '': NsfRemoveFolderOperation.FolderTrash, - '1': NsfRemoveFolderOperation.FolderTrash, - '2': NsfRemoveFolderOperation.DeletePermanent, - 'folder-trash': NsfRemoveFolderOperation.FolderTrash, - 'delete-permanent': NsfRemoveFolderOperation.DeletePermanent, -} - -function parseOperation(input: string): NsfRemoveFolderOperation { - return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash -} - -function printPreview( - vault: Awaited>, - result: RemoveNsfFolderResult, - quiet: boolean -): void { - if (result.preview.length === 0) return - logger.info('') - logger.info(vault.formatRemoveNsfFolderPreview(result.preview, result.operation, quiet)) - logger.info('') -} - -function printPreviewWarnings(result: RemoveNsfFolderResult): void { - for (const item of result.preview) { - for (const warning of item.impact?.warnings ?? []) { - logger.info(`Warning: ${warning}`) - } - } -} - -async function removeNestedShareFolders( - vault: Awaited>, - input: RemoveNsfFolderInput -): Promise { - const restore = suppressLogs() - try { - return await vault.removeNestedShareFolders(input) - } finally { - restore() - } -} - -async function rmdirNsf() { - const vault = await login() - - try { - const foldersInput = (await prompt('Folder UID(s) or name(s), comma-separated: ')).trim() - const folders = foldersInput.split(',').map((value) => value.trim()).filter(Boolean) - if (folders.length === 0) { - logger.info('At least one folder is required.') - return - } - - logger.info('Operation: 1) folder-trash 2) delete-permanent') - const operation = parseOperation(await prompt('Choose [1]: ')) - const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) - const quiet = isYes(await prompt('Quiet (summary only)? [y/N]: ')) - const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) - - if (operation === NsfRemoveFolderOperation.DeletePermanent && !force && !dryRun) { - logger.info('') - logger.info('*** WARNING ***') - logger.info('delete-permanent is IRREVERSIBLE.') - logger.info('All sub-folders and records inside will be permanently destroyed.') - logger.info('') - } - - const baseInput: RemoveNsfFolderInput = { - folders, - operation, - quiet, - } - - if (dryRun) { - const result = await removeNestedShareFolders(vault, { ...baseInput, dryRun: true }) - printPreview(vault, result, quiet) - logger.info('[Dry-run] No folders were deleted.') - return - } - - if (force) { - const result = await removeNestedShareFolders(vault, { ...baseInput, force: true }) - printPreview(vault, result, quiet) - if (result.confirmed && result.message) { - logger.info(result.message) - } - return - } - - const preview = await removeNestedShareFolders(vault, { ...baseInput, force: false }) - printPreview(vault, preview, quiet) - printPreviewWarnings(preview) - - const promptText = - operation === NsfRemoveFolderOperation.DeletePermanent - ? 'Do you want to permanently delete the folder(s) and all their contents? [y/n]: ' - : 'Do you want to proceed with the folder deletion? [y/n]: ' - - if (!isYes(await prompt(promptText))) { - logger.info('Removal cancelled.') - return - } - - const result = await removeNestedShareFolders(vault, { ...baseInput, force: true }) - if (result.confirmed && result.message) { - logger.info(result.message) - } - } catch (err) { - logger.error(`rmdir failed: ${extractErrorMessage(err)}`) - process.exitCode = 1 - } finally { - cleanup(vault) - } -} - -runExample(rmdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts index 5ea33197..b2a662a7 100644 --- a/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts +++ b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts @@ -3,30 +3,17 @@ import { extractErrorMessage, login, logger, + parseNsfFieldStrings, prompt, - suppressLogs, - type UpdateNsfRecordFieldMap, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' - -function parseFieldSpecs(input: string): UpdateNsfRecordFieldMap { - const fields: UpdateNsfRecordFieldMap = {} - for (const spec of input.split(',').map((value) => value.trim()).filter(Boolean)) { - const separator = spec.indexOf('=') - if (separator <= 0) continue - const type = spec.slice(0, separator).trim() - const value = spec.slice(separator + 1).trim() - if (type) fields[type] = value - } - return fields -} +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' async function updateNsfRecord() { const vault = await login() try { - const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() - const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + const records = splitCommaSeparated(await prompt('Record UID(s) or title(s), comma-separated: ')) if (records.length === 0) { logger.info('At least one record is required.') return @@ -36,21 +23,19 @@ async function updateNsfRecord() { const recordType = (await prompt('Record type (optional): ')).trim() const notes = (await prompt('Notes (optional): ')).trim() const fieldsInput = (await prompt('Fields (type=value, comma-separated, optional): ')).trim() - const fields = fieldsInput ? parseFieldSpecs(fieldsInput) : undefined + const fields = fieldsInput + ? parseNsfFieldStrings(splitCommaSeparated(fieldsInput)).fields + : undefined - const restore = suppressLogs() - let result - try { - result = await vault.updateNestedShareRecords({ + const result = await withSuppressedLogs(() => + vault.updateNestedShareRecords({ records, title: title || undefined, recordType: recordType || undefined, notes: notes || undefined, fields, }) - } finally { - restore() - } + ) logger.info('') for (const item of result.updated) { @@ -68,4 +53,4 @@ async function updateNsfRecord() { } } -runExample(updateNsfRecord) \ No newline at end of file +runExample(updateNsfRecord) diff --git a/examples/sdk_example/src/utils/format.ts b/examples/sdk_example/src/utils/format.ts index b9482be6..4f322c38 100644 --- a/examples/sdk_example/src/utils/format.ts +++ b/examples/sdk_example/src/utils/format.ts @@ -2,6 +2,7 @@ import { EMAIL_LIST_SEPARATOR_PATTERN, EMAIL_PATTERN, isValidEmail, + suppressLogs, } from '@keeper-security/keeper-sdk-javascript' export { EMAIL_PATTERN } @@ -40,6 +41,19 @@ export function isYes(answer: string): boolean { return normalized === 'y' || normalized === 'yes' } +export function splitCommaSeparated(input: string): string[] { + return input.split(',').map((value) => value.trim()).filter(Boolean) +} + +export async function withSuppressedLogs(fn: () => Promise): Promise { + const restore = suppressLogs() + try { + return await fn() + } finally { + restore() + } +} + export function parseEmails(raw: string): { emails: string[]; invalid: string[] } { const tokens = raw .split(EMAIL_LIST_SEPARATOR_PATTERN) diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index bfb8f9ec..0445ec56 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -1072,7 +1072,7 @@ export const pamGetOnlineControllersMessage = (): RestOutMessage => - createMessage(data, '/vault/records/v3/add', record.v3.RecordsAddRequest, Records.RecordsModifyResponse) + createMessage(data, 'vault/records/v3/add', record.v3.RecordsAddRequest, Records.RecordsModifyResponse) export const keeperDriveRecordsUpdate = ( data: Records.IRecordsUpdateRequest From ba6a94b47688aeb5dd6c319bea88b0999cb282c8 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 18 Jun 2026 16:30:20 +0530 Subject: [PATCH 3/4] Code format and optimisation --- .../src/nestedShareFolders/addNsfRecord.ts | 44 ++++------ .../nestedShareFolders/getNsfRecordDetails.ts | 38 ++++---- KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 79 ++++++++--------- .../src/nestedShareFolders/nsfHelpers.ts | 88 ++++++++++++++++--- .../src/nestedShareFolders/removeNsfFolder.ts | 6 +- .../src/nestedShareFolders/updateNsfRecord.ts | 44 +++------- keeperapi/src/restMessages.ts | 7 +- 7 files changed, 167 insertions(+), 139 deletions(-) diff --git a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts index d5e0f6bb..847cae4f 100644 --- a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts @@ -2,6 +2,7 @@ import type { Auth, record as RecordProto } from '@keeper-security/keeperapi' import { Folder, Records, + generateEncryptionKey, generateUid, keeperDriveRecordsAdd, normal64Bytes, @@ -18,11 +19,11 @@ import { import { ensureNestedShareFolder, nsfToNumber, + parseRecordModifyStatus, + requireAuthDataKey, resolveNsfFolderIdentifier, } from './nsfHelpers' -const RECORD_KEY_BYTE_LENGTH = 32 - export type { NsfRecordFieldMap, NsfRecordCustomField } from './nsfRecordData' export type AddNsfRecordInput = { @@ -57,7 +58,7 @@ function resolveFolderUid(storage: InMemoryStorage, folderInput?: string): strin return folderUid } -async function resolveFolderKey(storage: InMemoryStorage, folderUid: string): Promise { +async function requireFolderKey(storage: InMemoryStorage, folderUid: string): Promise { const folderKey = await storage.getKeyBytes(folderUid) if (folderKey) return folderKey throw new KeeperSdkError( @@ -73,7 +74,7 @@ async function buildRecordAdd( folderUid?: string ): Promise<{ recordUid: string; recordAdd: RecordProto.v3.IRecordAdd }> { const recordUid = generateUid() - const recordKey = platform.getRandomBytes(RECORD_KEY_BYTE_LENGTH) + const recordKey = generateEncryptionKey() await storage.saveKeyBytes(recordUid, recordKey) const recordAdd: RecordProto.v3.IRecordAdd = { @@ -83,16 +84,13 @@ async function buildRecordAdd( } if (folderUid) { - const folderKey = await resolveFolderKey(storage, folderUid) + const folderKey = await requireFolderKey(storage, folderUid) recordAdd.folderUid = normal64Bytes(folderUid) recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, folderKey) recordAdd.recordKeyEncryptedBy = Folder.FolderKeyEncryptionType.ENCRYPTED_BY_PARENT_KEY recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm } else { - if (!auth.dataKey) { - throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) - } - recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, auth.dataKey) + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, requireAuthDataKey(auth)) recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm } @@ -127,35 +125,29 @@ export async function addNestedShareRecord( custom: input.custom, }) - const folderUid = resolveFolderUid(storage, input.folder) - try { - const { recordUid, recordAdd } = await buildRecordAdd(storage, auth, recordData, folderUid) + const { recordUid, recordAdd } = await buildRecordAdd( + storage, + auth, + recordData, + resolveFolderUid(storage, input.folder) + ) const response = await auth.executeRest( keeperDriveRecordsAdd({ records: [recordAdd], clientTime: Date.now(), }) ) - - const result = response.records?.[0] - if (!result) { - throw new KeeperSdkError('No results from record creation.', ResultCodes.NSF_ADD_FAILED) - } - - const success = result.status === Records.RecordModifyResult.RS_SUCCESS || result.status == null - const statusName = - result.status != null ? Records.RecordModifyResult[result.status] ?? String(result.status) : 'RS_SUCCESS' - - if (!success) { - throw new KeeperSdkError(result.message || `Record creation failed (${statusName}).`, ResultCodes.NSF_ADD_FAILED) - } + const { statusName, message } = parseRecordModifyStatus( + response.records?.[0], + ResultCodes.NSF_ADD_FAILED + ) return { recordUid, success: true, status: statusName, - message: result.message || 'Record created successfully', + message, revision: nsfToNumber(response.revision), } } catch (err) { diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts index 25ef429f..c7235ec9 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -1,4 +1,4 @@ -import type { Auth } from '@keeper-security/keeperapi' +import type { Auth, Records } from '@keeper-security/keeperapi' import { normal64Bytes, recordDetailsDataMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' @@ -35,16 +35,32 @@ function resolveRecordUids(storage: InMemoryStorage, identifiers: string[]): str throw new KeeperSdkError('At least one record UID or title is required.', ResultCodes.NSF_DETAILS_FAILED) } - const recordUids: string[] = [] - for (const identifier of identifiers) { + return identifiers.map((identifier) => { const recordUid = resolveNsfRecordIdentifier(storage, identifier) if (!recordUid) { throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) } ensureNestedShareRecord(storage, recordUid, identifier) - recordUids.push(recordUid) + return recordUid + }) +} + +async function mapRecordDetailsItem( + storage: InMemoryStorage, + auth: Auth, + item: Records.IRecordData +): Promise { + const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' + if (!recordUid) return undefined + + const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) + return { + recordUid, + title, + type, + revision: nsfToNumber(item.revision, 0) ?? 0, + version: item.version ?? 0, } - return recordUids } export function formatNsfRecordDetailsTable(result: GetNsfRecordDetailsResult): string { @@ -95,16 +111,8 @@ export async function getNestedShareRecordDetails( const data: NsfRecordDetailsItem[] = [] for (const item of response.data ?? []) { - const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' - if (!recordUid) continue - const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) - data.push({ - recordUid, - title, - type, - revision: nsfToNumber(item.revision, 0) ?? 0, - version: item.version ?? 0, - }) + const mapped = await mapRecordDetailsItem(storage, auth, item) + if (mapped) data.push(mapped) } return { diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts index 08fb4d98..35066b18 100644 --- a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -2,25 +2,29 @@ import type { Auth } from '@keeper-security/keeperapi' import { Folder, folderAddMessage, + generateEncryptionKey, generateUid, normal64Bytes, platform, } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_FOLDER_COLORS, type NsfFolderColor } from './nsfConstants' import { - NSF_FOLDER_COLORS, - type NsfFolderColor, -} from './nsfConstants' -import { + buildFolderOwnerInfo, cacheNewNsfFolder, findExistingChildFolder, isNestedShareFolder, - isRootFolderUid, + parseFolderModifyStatus, parseNsfPath, + requireAuthDataKey, + resolveKeeperDriveParentUid, } from './nsfHelpers' -const FOLDER_KEY_BYTE_LENGTH = 32 +type NsfFolderMetadata = { + name: string + color?: string +} export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` @@ -39,14 +43,13 @@ export type MkdirNsfResult = { function normalizeColor(color?: NsfFolderColorInput): NsfFolderColor | undefined { if (!color) return undefined - const value = color as NsfFolderColor - if (!(NSF_FOLDER_COLORS as readonly string[]).includes(value)) { + if (!(NSF_FOLDER_COLORS as readonly string[]).includes(color)) { throw new KeeperSdkError( `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(', ')}.`, ResultCodes.NSF_MKDIR_FAILED ) } - return value + return color } function resolveBaseFolderUid( @@ -54,8 +57,7 @@ function resolveBaseFolderUid( baseFolderUid: string | null | undefined ): string | null { if (!baseFolderUid) return null - if (isNestedShareFolder(storage, baseFolderUid)) return baseFolderUid - return null + return isNestedShareFolder(storage, baseFolderUid) ? baseFolderUid : null } async function resolveFolderKeyEncryptionKey( @@ -63,15 +65,11 @@ async function resolveFolderKeyEncryptionKey( auth: Auth, parentUid: string | null ): Promise { - const normalizedParent = parentUid && !isRootFolderUid(parentUid) ? parentUid : null - if (normalizedParent) { - const parentKey = await storage.getKeyBytes(normalizedParent) + if (parentUid) { + const parentKey = await storage.getKeyBytes(parentUid) if (parentKey) return parentKey } - if (!auth.dataKey) { - throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) - } - return auth.dataKey + return requireAuthDataKey(auth) } async function prepareFolderData( @@ -83,33 +81,33 @@ async function prepareFolderData( inheritPermissions: boolean ): Promise<{ folderUid: string; folderData: Folder.IFolderData }> { const folderUid = generateUid() - const folderKey = platform.getRandomBytes(FOLDER_KEY_BYTE_LENGTH) + const folderKey = generateEncryptionKey() await storage.saveKeyBytes(folderUid, folderKey) - const metadata: { name: string; color?: string } = { name: folderName } + const metadata: NsfFolderMetadata = { name: folderName } if (color && color !== 'none') metadata.color = color + const resolvedParentUid = resolveKeeperDriveParentUid(storage, parentUid) const encryptedData = await platform.aesGcmEncrypt( platform.stringToBytes(JSON.stringify(metadata)), folderKey ) - - const normalizedParent = parentUid && !isRootFolderUid(parentUid) ? parentUid : null - const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, normalizedParent) + const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, resolvedParentUid) const encryptedFolderKey = await platform.aesGcmEncrypt(folderKey, encryptionKey) return { folderUid, - folderData: { + folderData: Folder.FolderData.create({ folderUid: normal64Bytes(folderUid), - parentUid: normalizedParent ? normal64Bytes(normalizedParent) : undefined, + parentUid: resolvedParentUid ? normal64Bytes(resolvedParentUid) : undefined, data: encryptedData, folderKey: encryptedFolderKey, type: Folder.FolderUsageType.UT_NORMAL, inheritUserPermissions: inheritPermissions ? Folder.SetBooleanValue.BOOLEAN_TRUE : Folder.SetBooleanValue.BOOLEAN_FALSE, - }, + ownerInfo: buildFolderOwnerInfo(auth), + }), } } @@ -131,21 +129,10 @@ async function createFolderV3( ) const response = await auth.executeRest(folderAddMessage({ folderData: [folderData] })) - const result = response.folderAddResults?.[0] - if (!result) { - throw new KeeperSdkError('No results from folder creation.', ResultCodes.NSF_MKDIR_FAILED) - } - - const statusName = Folder.FolderModifyStatus[result.status ?? Folder.FolderModifyStatus.SUCCESS] ?? 'UNKNOWN' - if (result.status !== Folder.FolderModifyStatus.SUCCESS) { - throw new KeeperSdkError(result.message || `Folder creation failed (${statusName}).`, ResultCodes.NSF_MKDIR_FAILED) - } + const message = parseFolderModifyStatus(response.folderAddResults?.[0], ResultCodes.NSF_MKDIR_FAILED) await cacheNewNsfFolder(storage, auth, folderUid, folderName, parentUid, inheritPermissions) - return { - folderUid, - message: result.message || 'Folder created successfully', - } + return { folderUid, message } } export async function mkdirNestedShareFolder( @@ -160,9 +147,8 @@ export async function mkdirNestedShareFolder( const color = normalizeColor(input.color) const inheritPermissions = !input.noInheritPermissions - const baseFolderUid = resolveBaseFolderUid(storage, input.baseFolderUid) const segments = parseNsfPath(folderPath) - let parentUid = baseFolderUid + let parentUid: string | null = resolveBaseFolderUid(storage, input.baseFolderUid) const lastIdx = segments.length - 1 let createdUid: string | undefined @@ -184,9 +170,14 @@ export async function mkdirNestedShareFolder( continue } - const segColor = isLeaf ? color : undefined - const segInherit = isLeaf ? inheritPermissions : true - const result = await createFolderV3(storage, auth, segment, parentUid, segColor, segInherit) + const result = await createFolderV3( + storage, + auth, + segment, + parentUid, + isLeaf ? color : undefined, + isLeaf ? inheritPermissions : true + ) createdUid = result.folderUid parentUid = createdUid } diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 2ffbf9d7..d09656e5 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,4 +1,5 @@ import type { + Auth, DRecord, DUser, DKdFolder, @@ -6,7 +7,7 @@ import type { DKdFolderRecord, DKdRecordAccess, } from '@keeper-security/keeperapi' -import { Folder, webSafe64FromBytes } from '@keeper-security/keeperapi' +import { Folder, Records, webSafe64FromBytes } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { VaultObjectKind } from '../folders/folderHelpers' import { KeeperSdkError, ResultCodes } from '../utils' @@ -45,6 +46,68 @@ export function nsfToNumber( return typeof value === 'number' ? value : value.toNumber() } +export function requireAuthAccountUid(auth: Auth): Uint8Array { + const accountUid = auth.accountUid + if (!accountUid?.length) { + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + } + return accountUid +} + +export function requireAuthDataKey(auth: Auth): Uint8Array { + if (!auth.dataKey?.length) { + throw new KeeperSdkError('Data key not available. Ensure you are logged in.', ResultCodes.NSF_MISSING_KEY) + } + return auth.dataKey +} + +export function buildFolderOwnerInfo(auth: Auth): Folder.IUserInfo | undefined { + if (!auth.accountUid?.length) return undefined + return { + accountUid: auth.accountUid, + username: auth.username, + } +} + +export function parseFolderModifyStatus( + result: Folder.IFolderModifyResult | null | undefined, + failureCode: string +): string { + if (!result) { + throw new KeeperSdkError('No results from folder operation.', failureCode) + } + const status = result.status ?? Folder.FolderModifyStatus.SUCCESS + const statusName = Folder.FolderModifyStatus[status] ?? String(status) + if (status !== Folder.FolderModifyStatus.SUCCESS) { + throw new KeeperSdkError( + result.message || `Folder operation failed (${statusName}).`, + failureCode + ) + } + return result.message || 'Folder operation succeeded' +} + +export function parseRecordModifyStatus( + result: Records.IRecordModifyStatus | null | undefined, + failureCode: string +): { statusName: string; message: string } { + if (!result) { + throw new KeeperSdkError('No results from record operation.', failureCode) + } + const status = result.status ?? Records.RecordModifyResult.RS_SUCCESS + const statusName = Records.RecordModifyResult[status] ?? String(status) + if (status !== Records.RecordModifyResult.RS_SUCCESS) { + throw new KeeperSdkError( + result.message || `Record operation failed (${statusName}).`, + failureCode + ) + } + return { + statusName, + message: result.message || 'Record operation succeeded', + } +} + export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string): boolean { return !!getKeeperDriveRecord(storage, recordUid) } @@ -238,6 +301,14 @@ function resolveKeeperDriveRootParentUid(storage: InMemoryStorage): string | und return undefined } +export function resolveKeeperDriveParentUid( + storage: InMemoryStorage, + parentUid: string | null | undefined +): string | null { + if (parentUid && !isRootFolderUid(parentUid)) return parentUid + return resolveKeeperDriveRootParentUid(storage) ?? null +} + export async function cacheNewNsfFolder( storage: InMemoryStorage, auth: { username?: string; accountUid?: Uint8Array }, @@ -270,27 +341,18 @@ export function checkFolderDeletePermission( storage: InMemoryStorage, folderUid: string, username: string, - accountUid?: Uint8Array + accountUid: Uint8Array ): void { if (isRootFolderUid(folderUid)) { throw new KeeperSdkError('The root folder cannot be removed.', ResultCodes.NSF_PERMISSION_DENIED) } + const accountUidStr = toRequiredAccountUidStr(accountUid) const entries = getFolderAccessEntries(storage, folderUid) if (entries.length === 0) return - const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' for (const entry of entries) { - const isCurrentUser = - (entry.accessType === Folder.AccessType.AT_USER && entry.accessTypeUid === accountUidStr) || - (entry.accessType === Folder.AccessType.AT_OWNER && entry.accessTypeUid === accountUidStr) || - (username && - storage.getAll('user').some( - (user) => - user.username === username && - webSafe64FromBytes(user.accountUid) === entry.accessTypeUid - )) - if (!isCurrentUser) continue + if (!isCurrentUserFolderAccess(storage, entry, username, accountUidStr)) continue if (entry.accessType === Folder.AccessType.AT_OWNER || entry.permission?.canDelete) return throw new KeeperSdkError( 'You do not have permission to delete this folder.', diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts index 19878b37..9515b673 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -7,6 +7,7 @@ import { checkFolderDeletePermission, ensureNestedShareFolder, getFolderDisplayName, + requireAuthAccountUid, resolveNsfFolderUidOrName, } from './nsfHelpers' @@ -90,10 +91,7 @@ function buildRemovals( ) } - const accountUid = auth.accountUid - if (!accountUid?.length) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } + const accountUid = requireAuthAccountUid(auth) const removals: RemovalSpec[] = [] for (const identifier of folderIdentifiers) { diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts index cdc0a5c0..680e513b 100644 --- a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -1,10 +1,5 @@ import type { Auth, DRecord } from '@keeper-security/keeperapi' -import { - Records, - keeperDriveRecordsUpdate, - normal64Bytes, - platform, -} from '@keeper-security/keeperapi' +import { keeperDriveRecordsUpdate, normal64Bytes, platform } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { VaultObjectKind } from '../folders/folderHelpers' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' @@ -14,6 +9,8 @@ import { checkRecordEditPermission, ensureNestedShareRecord, nsfToNumber, + parseRecordModifyStatus, + requireAuthAccountUid, resolveNsfRecordIdentifier, } from './nsfHelpers' @@ -47,14 +44,6 @@ function loadExistingRecordData(storage: InMemoryStorage, recordUid: string): Re return { fields: [] } } -function requireAccountUid(auth: Auth): Uint8Array { - const accountUid = auth.accountUid - if (!accountUid?.length) { - throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) - } - return accountUid -} - async function updateSingleRecord( storage: InMemoryStorage, auth: Auth, @@ -71,8 +60,6 @@ async function updateSingleRecord( } const merged = mergeNsfRecordData(loadExistingRecordData(storage, recordUid), input) - const encryptedData = await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey) - const response = await auth.executeRest( keeperDriveRecordsUpdate({ records: [ @@ -80,23 +67,19 @@ async function updateSingleRecord( recordUid: normal64Bytes(recordUid), clientModifiedTime: Date.now(), revision: record?.revision ?? 0, - data: encryptedData, + data: await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey), }, ], clientTime: Date.now(), }) ) - const result = response.records?.[0] - const success = result?.status === Records.RecordModifyResult.RS_SUCCESS || result?.status == null - const statusName = - result?.status != null ? Records.RecordModifyResult[result.status] ?? String(result.status) : 'RS_SUCCESS' - - if (!success) { - throw new KeeperSdkError(result?.message || `Record update failed (${statusName}).`, ResultCodes.NSF_UPDATE_FAILED) - } - + const { statusName, message } = parseRecordModifyStatus( + response.records?.[0], + ResultCodes.NSF_UPDATE_FAILED + ) const revision = nsfToNumber(response.revision) ?? record?.revision + if (record) { await storage.put({ ...record, @@ -110,7 +93,7 @@ async function updateSingleRecord( recordUid, success: true, status: statusName, - message: result?.message || 'Record updated successfully', + message, revision, } } @@ -120,16 +103,15 @@ export async function updateNestedShareRecords( auth: Auth, input: UpdateNsfRecordInput ): Promise { - const identifiers = input.records ?? [] - if (identifiers.length === 0) { + if (!input.records?.length) { throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) } - const accountUid = requireAccountUid(auth) + const accountUid = requireAuthAccountUid(auth) const updated: UpdateNsfRecordResultItem[] = [] try { - for (const identifier of identifiers) { + for (const identifier of input.records) { const recordUid = resolveNsfRecordIdentifier(storage, identifier) if (!recordUid) { throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 0445ec56..93a3e8f2 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -539,12 +539,7 @@ const RecordDetailsDataResponse = { export const recordDetailsDataMessage = ( data: IRecordDetailsDataRequest ): RestMessage => - createMessage( - data, - 'vault/records/v3/details/data', - RecordDetailsDataRequest, - RecordDetailsDataResponse - ) + createMessage(data, 'vault/records/v3/details/data', RecordDetailsDataRequest, RecordDetailsDataResponse) export const folderAddMessage = ( data: Folder.IFolderAddRequest From b3c1a353a1cca46eb68eebd21e3f937459a477ea Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 24 Jun 2026 14:02:56 +0530 Subject: [PATCH 4/4] Merge branch 'main' into feature-kd-records-v1 --- KeeperSdk/src/index.ts | 2 +- KeeperSdk/src/nestedShareFolders/getNsf.ts | 2 +- KeeperSdk/src/nestedShareFolders/index.ts | 2 +- KeeperSdk/src/nestedShareFolders/listNsf.ts | 2 +- .../src/nestedShareFolders/nsfConstants.ts | 2 - .../src/nestedShareFolders/nsfHelpers.ts | 106 ++++--- .../src/nestedShareFolders/removeNsfFolder.ts | 4 +- .../src/nestedShareFolders/removeNsfRecord.ts | 2 +- keeperapi/package-lock.json | 298 +----------------- keeperapi/package.json | 10 +- keeperapi/rollup.config.js | 8 +- keeperapi/src/restMessages.ts | 6 +- 12 files changed, 86 insertions(+), 358 deletions(-) diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index f4dd449e..7cdf205c 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -445,13 +445,13 @@ export { export { UserManager } from './users/UserManager' export { - ROOT_FOLDER_UID, KeeperDriveKind, NsfItemType, formatAccessRoleType, formatAccessType, normalizeParentUid, isRootFolderUid, + resolveKeeperDriveRootParentUid, getKeeperDriveFolders, getKeeperDriveRecords, findRecordFolderLocation, diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts index 9130ad20..25523668 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -323,7 +323,7 @@ function buildFolderView(storage: InMemoryStorage, folderUid: string): NsfFolder objectType: 'folder', folderUid, name: folder.data.name || 'Unnamed', - parentUid: normalizeParentUid(folder.parentUid), + parentUid: normalizeParentUid(storage, folder.parentUid), path: buildFolderPath(storage, folderUid), userPermissions, shareAdmins, diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index 48c9d683..c6e351ee 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -1,11 +1,11 @@ export { - ROOT_FOLDER_UID, KeeperDriveKind, NsfItemType, formatAccessRoleType, formatAccessType, normalizeParentUid, isRootFolderUid, + resolveKeeperDriveRootParentUid, getKeeperDriveFolders, getKeeperDriveRecords, findRecordFolderLocation, diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts index 4971753f..e4ff8d2c 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -55,7 +55,7 @@ function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { title: folder.data.name || 'Unnamed', type: '', description: '', - parentOrFolder: normalizeParentUid(folder.parentUid), + parentOrFolder: normalizeParentUid(storage, folder.parentUid), })) } diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index e687147a..68c67f1e 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -1,7 +1,5 @@ import { Folder } from '@keeper-security/keeperapi' -export const ROOT_FOLDER_UID = 'AAAAAAAAAAAAAAAAAPmtNA' - export const NSF_LEGACY_RECORD_MSG = "Record '{0}' is a legacy vault record. Nested Share Folder commands operate only on Nested Share Records." diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index d09656e5..d8449535 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -21,11 +21,8 @@ import { NSF_PATH_SENTINEL, NSF_RECORD_DESCRIPTION_MAX_LENGTH, NSF_SENSITIVE_FIELD_TYPES, - ROOT_FOLDER_UID, } from './nsfConstants' -export { ROOT_FOLDER_UID } from './nsfConstants' - export enum KeeperDriveKind { Folder = 'keeper_drive_folder', FolderAccess = 'keeper_drive_folder_access', @@ -112,9 +109,41 @@ export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string) return !!getKeeperDriveRecord(storage, recordUid) } +function getKnownKeeperDriveFolderUids(storage: InMemoryStorage): Set { + return new Set(getKeeperDriveFolders(storage).map((folder) => folder.uid)) +} + +/** Per-account drive root UID inferred from sync (parentUid of top-level folders). */ +export function resolveKeeperDriveRootParentUid(storage: InMemoryStorage): string | undefined { + const knownFolderUids = getKnownKeeperDriveFolderUids(storage) + for (const folder of getKeeperDriveFolders(storage)) { + const parentUid = folder.parentUid?.trim() + if (parentUid && !knownFolderUids.has(parentUid)) { + return parentUid + } + } + return undefined +} + +export function isRootFolderUid( + storage: InMemoryStorage, + folderUid: string | undefined | null +): boolean { + const value = (folderUid ?? '').trim() + if (!value) return true + const driveRoot = resolveKeeperDriveRootParentUid(storage) + return !!driveRoot && value === driveRoot +} + +export function normalizeParentUid( + storage: InMemoryStorage, + parentUid: string | undefined | null +): string { + return isRootFolderUid(storage, parentUid) ? 'root' : (parentUid ?? '').trim() +} + export function isNestedShareFolder(storage: InMemoryStorage, folderUid: string): boolean { - if (!folderUid) return false - if (isRootFolderUid(folderUid)) return true + if (isRootFolderUid(storage, folderUid)) return true return !!getKeeperDriveFolder(storage, folderUid) } @@ -172,7 +201,7 @@ function resolveRecordByTitleSearch(storage: InMemoryStorage, identifier: string function resolveFolderByPath(storage: InMemoryStorage, identifier: string): string | undefined { const trimmed = identifier.trim().replace(/^\/+/, '') - if (!trimmed) return ROOT_FOLDER_UID + if (!trimmed) return resolveKeeperDriveRootParentUid(storage) ?? '' const targetPath = `/${trimmed.toLowerCase()}` for (const folder of getKeeperDriveFolders(storage)) { @@ -200,7 +229,11 @@ export function resolveNsfFolderIdentifier(storage: InMemoryStorage, identifier: const trimmed = identifier.trim() if (!trimmed) return undefined - if (isRootFolderUid(trimmed) || trimmed.toLowerCase() === 'root') return ROOT_FOLDER_UID + if (trimmed.toLowerCase() === 'root' || trimmed === '/') { + return resolveKeeperDriveRootParentUid(storage) ?? '' + } + const driveRoot = resolveKeeperDriveRootParentUid(storage) + if (driveRoot && trimmed === driveRoot) return driveRoot const byUidOrName = resolveByUidOrName( getKeeperDriveFolders(storage), @@ -240,13 +273,9 @@ export function parseNsfPath(folderPath: string): string[] { return segments } -function getKnownKeeperDriveFolderUids(storage: InMemoryStorage): Set { - return new Set(getKeeperDriveFolders(storage).map((folder) => folder.uid)) -} - function isVirtualDriveRootParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { const trimmed = parentUid?.trim() - if (!trimmed || isRootFolderUid(trimmed)) return true + if (!trimmed || isRootFolderUid(storage, trimmed)) return true return !getKnownKeeperDriveFolderUids(storage).has(trimmed) } @@ -259,7 +288,9 @@ function folderParentsMatch( folderParentUid: string | undefined, searchParentUid: string | null | undefined ): boolean { - if (normalizeParentUid(folderParentUid) === normalizeParentUid(searchParentUid)) return true + if (normalizeParentUid(storage, folderParentUid) === normalizeParentUid(storage, searchParentUid)) { + return true + } return isRootLevelParent(storage, searchParentUid) && isRootLevelParent(storage, folderParentUid) } @@ -276,7 +307,7 @@ export function findExistingChildFolder( const name = folder.data.name || '' if (name.toLowerCase() !== lower) continue - if (normalizeParentUid(folder.parentUid) === normalizeParentUid(parentUid)) { + if (normalizeParentUid(storage, folder.parentUid) === normalizeParentUid(storage, parentUid)) { exactMatches.push(folder.uid) continue } @@ -290,22 +321,11 @@ export function findExistingChildFolder( return undefined } -function resolveKeeperDriveRootParentUid(storage: InMemoryStorage): string | undefined { - const knownFolderUids = getKnownKeeperDriveFolderUids(storage) - for (const folder of getKeeperDriveFolders(storage)) { - const parentUid = folder.parentUid?.trim() - if (parentUid && !knownFolderUids.has(parentUid) && !isRootFolderUid(parentUid)) { - return parentUid - } - } - return undefined -} - export function resolveKeeperDriveParentUid( storage: InMemoryStorage, parentUid: string | null | undefined ): string | null { - if (parentUid && !isRootFolderUid(parentUid)) return parentUid + if (parentUid && !isRootFolderUid(storage, parentUid)) return parentUid return resolveKeeperDriveRootParentUid(storage) ?? null } @@ -318,7 +338,7 @@ export async function cacheNewNsfFolder( inheritPermissions: boolean ): Promise { const normalizedParent = - parentUid && !isRootFolderUid(parentUid) + parentUid && !isRootFolderUid(storage, parentUid) ? parentUid.trim() : resolveKeeperDriveRootParentUid(storage) await storage.put({ @@ -343,7 +363,7 @@ export function checkFolderDeletePermission( username: string, accountUid: Uint8Array ): void { - if (isRootFolderUid(folderUid)) { + if (isRootFolderUid(storage, folderUid)) { throw new KeeperSdkError('The root folder cannot be removed.', ResultCodes.NSF_PERMISSION_DENIED) } @@ -420,7 +440,7 @@ function isCurrentUserFolderAccess( } function isFolderOwnerAccount(storage: InMemoryStorage, folderUid: string, accountUidStr: string): boolean { - if (isRootFolderUid(folderUid)) return true + if (isRootFolderUid(storage, folderUid)) return true const folder = getKeeperDriveFolder(storage, folderUid) return folder?.ownerInfo?.accountUid === accountUidStr } @@ -466,7 +486,7 @@ function canRecordBeDeleted( if (entry.owner || entry.canDelete) return true if ( folderUid && - !isRootFolderUid(folderUid) && + !isRootFolderUid(storage, folderUid) && hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') ) { return true @@ -476,7 +496,7 @@ function canRecordBeDeleted( return ( !!folderUid && - !isRootFolderUid(folderUid) && + !isRootFolderUid(storage, folderUid) && hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') ) } @@ -489,6 +509,9 @@ export function checkFolderRemovePermission( accountUid: Uint8Array ): void { if (hasFolderPermission(storage, folderUid, username, accountUid, 'canRemove')) return + // Folder-trash and unlink are less destructive than owner-trash. Allow when the user + // can permanently delete the record, or owns it without explicit record-access entries + // (common for records in a personal drive root with no folder-access sync data). if (canRecordBeDeleted(storage, recordUid, username, accountUid, folderUid)) return throw new KeeperSdkError( 'You do not have permission to remove records from this folder.', @@ -544,15 +567,6 @@ export function formatAccessType(type: Folder.AccessType | null | undefined): st return NSF_ACCESS_TYPE_LABELS[type] ?? `type-${type}` } -export function normalizeParentUid(parentUid: string | undefined | null): string { - const value = (parentUid ?? '').trim() - return !value || value === ROOT_FOLDER_UID ? ROOT_FOLDER_UID : value -} - -export function isRootFolderUid(folderUid: string | undefined | null): boolean { - return normalizeParentUid(folderUid) === ROOT_FOLDER_UID -} - export function getKeeperDriveFolders(storage: InMemoryStorage): DKdFolder[] { return storage.getAll(KeeperDriveKind.Folder) } @@ -577,7 +591,7 @@ export function getFolderAccessEntries(storage: InMemoryStorage, folderUid: stri } export function getFolderDisplayName(storage: InMemoryStorage, folderUid: string): string { - if (isRootFolderUid(folderUid)) return 'root' + if (isRootFolderUid(storage, folderUid)) return 'root' return getKeeperDriveFolder(storage, folderUid)?.data.name ?? folderUid } @@ -588,13 +602,13 @@ export function findRecordFolderLocation(storage: InMemoryStorage, recordUid: st } export function buildFolderPath(storage: InMemoryStorage, folderUid: string): string { - if (isRootFolderUid(folderUid)) return '/' + if (isRootFolderUid(storage, folderUid)) return '/' const segments: string[] = [] let currentUid: string | undefined = folderUid const seen = new Set() - while (currentUid && !isRootFolderUid(currentUid) && !seen.has(currentUid)) { + while (currentUid && !isRootFolderUid(storage, currentUid) && !seen.has(currentUid)) { seen.add(currentUid) const folder = getKeeperDriveFolder(storage, currentUid) if (!folder) break @@ -606,10 +620,11 @@ export function buildFolderPath(storage: InMemoryStorage, folderUid: string): st } export function collectRecordsInFolder(storage: InMemoryStorage, folderUid: string): DRecord[] { - const normalizedFolderUid = normalizeParentUid(folderUid) + const targetIsRoot = isRootFolderUid(storage, folderUid) const records: DRecord[] = [] for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { - if (normalizeParentUid(entry.folderUid) !== normalizedFolderUid) continue + const entryIsRoot = isRootFolderUid(storage, entry.folderUid) + if (targetIsRoot ? !entryIsRoot : entry.folderUid !== folderUid) continue const record = getKeeperDriveRecord(storage, entry.recordUid) if (record) records.push(record) } @@ -675,4 +690,3 @@ export function isFolderUserPermission(entry: DKdFolderAccess): boolean { entry.accessType === Folder.AccessType.AT_OWNER ) } - diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts index 9515b673..09735889 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -8,7 +8,7 @@ import { ensureNestedShareFolder, getFolderDisplayName, requireAuthAccountUid, - resolveNsfFolderUidOrName, + resolveNsfFolderIdentifier, } from './nsfHelpers' const { RemoveAction, FolderOperationType, RemoveStatus } = folder.v3.remove @@ -95,7 +95,7 @@ function buildRemovals( const removals: RemovalSpec[] = [] for (const identifier of folderIdentifiers) { - const folderUid = resolveNsfFolderUidOrName(storage, identifier) + const folderUid = resolveNsfFolderIdentifier(storage, identifier) if (!folderUid) { throw new KeeperSdkError(`Folder '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) } diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts index ae87725b..595aee66 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -154,7 +154,7 @@ function buildRemovals( if (operation === NsfRemoveOperation.OwnerTrash) { checkRecordDeletePermission(storage, recordUid, auth.username, accountUid, ctxFolder) } else { - if (!ctxFolder || isRootFolderUid(ctxFolder)) { + if (!ctxFolder || isRootFolderUid(storage, ctxFolder)) { throw new KeeperSdkError( `Folder context is required for '${operation}' operation.`, ResultCodes.NSF_FOLDER_REQUIRED diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index c653de19..d190d206 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1,12 +1,12 @@ { "name": "@keeper-security/keeperapi", - "version": "17.2.8", + "version": "18.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@keeper-security/keeperapi", - "version": "17.2.8", + "version": "18.0.1", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -32,6 +32,9 @@ "ts-jest": "^29.1.1", "ts-node": "^8.10.2", "typescript": "^4.0.1" + }, + "engines": { + "node": ">=24.13.1" } }, "node_modules/@ampproject/remapping": { @@ -1786,34 +1789,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2129,51 +2104,6 @@ "node": ">=8" } }, - "node_modules/@jest/core/node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2860,42 +2790,6 @@ "node": ">= 10" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -3710,15 +3604,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5116,51 +5001,6 @@ "node": ">=8" } }, - "node_modules/jest-cli/node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/jest-diff": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", @@ -8487,15 +8327,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -9998,31 +9829,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "dependencies": { - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - } - } - }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -10252,29 +10058,6 @@ "requires": { "has-flag": "^4.0.0" } - }, - "ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - } } } }, @@ -10807,38 +10590,6 @@ "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "dev": true }, - "@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "optional": true, - "peer": true - }, "@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -11485,14 +11236,6 @@ "browserslist": "^4.22.1" } }, - "create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "optional": true, - "peer": true - }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12497,29 +12240,6 @@ "requires": { "has-flag": "^4.0.0" } - }, - "ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - } } } }, @@ -14983,14 +14703,6 @@ "requires-port": "^1.0.0" } }, - "v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "optional": true, - "peer": true - }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", diff --git a/keeperapi/package.json b/keeperapi/package.json index 604b5acf..4013e5cd 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -1,12 +1,18 @@ { "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", - "version": "17.3.0", - "browser": "dist/index.es.js", + "version": "18.0.1", + "browser": "dist/browser/index.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", + "sideEffects": [ + "**/configureProtobuf*" + ], "repository": "https://github.com/Keeper-Security/keeper-sdk-javascript", "license": "ISC", + "engines": { + "node": ">=24.13.1" + }, "scripts": { "start": "rollup -cw", "build": "node ./scripts/cleanDistFolder.js && rollup -c && cp src/proto.d.ts dist", diff --git a/keeperapi/rollup.config.js b/keeperapi/rollup.config.js index b1e68944..41dda6c3 100644 --- a/keeperapi/rollup.config.js +++ b/keeperapi/rollup.config.js @@ -8,14 +8,12 @@ export default [ input: 'src/browser/index.ts', output: [ { - file: pkg.browser, + dir: 'dist', format: 'es', + preserveModules: true, + preserveModulesRoot: 'src', sourcemap: true, }, - // { - // file: pkg.browsertest, - // format: 'cjs', - // }, ], external: [...Object.keys(pkg.dependencies || {}), 'protobufjs/minimal', '@noble/post-quantum/ml-kem.js'], plugins: [ diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 93a3e8f2..72bc2bfa 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -952,17 +952,17 @@ export const ssoCloudValidationRequestMessage = ( ) export const switchAccountListAuthenticated = () => - createOutMessage('/authentication/switch_account_list_authenticated', Authentication.SwitchListResponse) + createOutMessage('authentication/switch_account_list_authenticated', Authentication.SwitchListResponse) export const switchAccountListRemoved = ( data: Authentication.LoginAsUserRequest ): RestInMessage => - createInMessage(data, '/authentication/switch_account_list_remove', Authentication.LoginAsUserRequest) + createInMessage(data, 'authentication/switch_account_list_remove', Authentication.LoginAsUserRequest) export const switchAccountFromAuthenticated = (data: Authentication.LoginAsUserRequest) => createMessage( data, - '/authentication/switch_account_from_authenticated', + 'authentication/switch_account_from_authenticated', Authentication.LoginAsUserRequest, Authentication.LoginResponse )