diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 317c7da8..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, @@ -475,6 +475,20 @@ export { NsfRemoveOperation, removeNestedShareRecords, formatRemoveNsfPreview, + mkdirNestedShareFolder, + NSF_FOLDER_COLORS, + NsfRemoveFolderOperation, + removeNestedShareFolders, + formatRemoveNsfFolderPreview, + GetNsfRecordDetailsFormat, + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, + updateNestedShareRecords, + addNestedShareRecord, + buildNsfRecordData, + parseNsfFieldStrings, + checkRecordEditPermission, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -495,6 +509,27 @@ export type { RemoveNsfRecordInput, NsfRemovePreviewItem, RemoveNsfRecordResult, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, + NsfFolderColor, + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, + UpdateNsfRecordInput, + 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 4a21c74f..d98b5c54 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -28,6 +28,25 @@ 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' +import { addNestedShareRecord, type AddNsfRecordInput, type AddNsfRecordResult } from './addNsfRecord' export type AuthProvider = () => Auth @@ -94,4 +113,41 @@ 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) + } + + 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..847cae4f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts @@ -0,0 +1,160 @@ +import type { Auth, record as RecordProto } from '@keeper-security/keeperapi' +import { + Folder, + Records, + generateEncryptionKey, + 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, + parseRecordModifyStatus, + requireAuthDataKey, + resolveNsfFolderIdentifier, +} from './nsfHelpers' + +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 requireFolderKey(storage: InMemoryStorage, folderUid: string): Promise { + const folderKey = await storage.getKeyBytes(folderUid) + if (folderKey) return folderKey + throw new KeeperSdkError( + `Folder key not found for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) +} + +async function buildRecordAdd( + storage: InMemoryStorage, + auth: Auth, + recordData: Record, + folderUid?: string +): Promise<{ recordUid: string; recordAdd: RecordProto.v3.IRecordAdd }> { + const recordUid = generateUid() + const recordKey = generateEncryptionKey() + await storage.saveKeyBytes(recordUid, recordKey) + + const recordAdd: RecordProto.v3.IRecordAdd = { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + data: await platform.aesGcmEncrypt(getPaddedJsonBytes(recordData), recordKey), + } + + if (folderUid) { + const folderKey = await requireFolderKey(storage, folderUid) + recordAdd.folderUid = normal64Bytes(folderUid) + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, folderKey) + recordAdd.recordKeyEncryptedBy = Folder.FolderKeyEncryptionType.ENCRYPTED_BY_PARENT_KEY + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm + } else { + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, requireAuthDataKey(auth)) + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm + } + + return { recordUid, recordAdd } +} + +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, + }) + + try { + const { recordUid, recordAdd } = await buildRecordAdd( + storage, + auth, + recordData, + resolveFolderUid(storage, input.folder) + ) + const response = await auth.executeRest( + keeperDriveRecordsAdd({ + records: [recordAdd], + clientTime: Date.now(), + }) + ) + const { statusName, message } = parseRecordModifyStatus( + response.records?.[0], + ResultCodes.NSF_ADD_FAILED + ) + + return { + recordUid, + success: true, + status: statusName, + message, + 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/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/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts new file mode 100644 index 00000000..c7235ec9 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -0,0 +1,129 @@ +import type { Auth, Records } from '@keeper-security/keeperapi' +import { normal64Bytes, recordDetailsDataMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { decryptRecordTitleAndType } from './nsfRecordCrypto' +import { ensureNestedShareRecord, nsfToNumber, resolveNsfRecordIdentifier } from './nsfHelpers' + +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) + } + + return identifiers.map((identifier) => { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + return recordUid + }) +} + +async function mapRecordDetailsItem( + storage: InMemoryStorage, + auth: Auth, + item: Records.IRecordData +): Promise { + const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' + if (!recordUid) return undefined + + const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) + return { + recordUid, + title, + type, + revision: nsfToNumber(item.revision, 0) ?? 0, + version: item.version ?? 0, + } +} + +export function formatNsfRecordDetailsTable(result: GetNsfRecordDetailsResult): string { + const lines: string[] = [] + for (const record of result.data) { + lines.push(`Record UID: ${record.recordUid}`) + lines.push(` Title: ${record.title}`) + lines.push(` Type: ${record.type}`) + lines.push(` Version: ${record.version}`) + lines.push(` Revision: ${record.revision}`) + lines.push('') + } + if (result.forbiddenRecords.length > 0) { + lines.push(`Forbidden records: ${result.forbiddenRecords.length}`) + for (const uid of result.forbiddenRecords) { + lines.push(` ${uid}`) + } + lines.push('') + } + lines.push(`Total records retrieved: ${result.data.length}`) + return lines.join('\n').trimEnd() +} + +export function formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table +): string { + if (String(format).toLowerCase() === GetNsfRecordDetailsFormat.JSON) { + return JSON.stringify(result, null, 2) + } + return formatNsfRecordDetailsTable(result) +} + +export async function getNestedShareRecordDetails( + storage: InMemoryStorage, + auth: Auth, + input: GetNsfRecordDetailsInput +): Promise { + const recordUids = resolveRecordUids(storage, input.records) + + try { + const response = await auth.executeRest( + recordDetailsDataMessage({ + recordUids: recordUids.map((uid) => normal64Bytes(uid)), + clientTime: Date.now(), + }) + ) + + const data: NsfRecordDetailsItem[] = [] + for (const item of response.data ?? []) { + const mapped = await mapRecordDetailsItem(storage, auth, item) + if (mapped) data.push(mapped) + } + + return { + data, + forbiddenRecords: (response.forbiddenRecords ?? []).map((uid) => webSafe64FromBytes(uid)), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to get nested share record details: ${extractErrorMessage(err)}`, + ResultCodes.NSF_DETAILS_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index e024b165..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, @@ -19,9 +19,14 @@ export { ensureNestedShareFolder, resolveNsfRecordIdentifier, resolveNsfFolderIdentifier, + resolveNsfFolderUidOrName, findNestedShareFoldersForRecord, checkFolderRemovePermission, checkRecordDeletePermission, + checkRecordEditPermission, + checkFolderDeletePermission, + parseNsfPath, + findExistingChildFolder, } from './nsfHelpers' export { @@ -75,4 +80,53 @@ 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 { 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/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/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts new file mode 100644 index 00000000..35066b18 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -0,0 +1,204 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + folderAddMessage, + generateEncryptionKey, + generateUid, + normal64Bytes, + platform, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_FOLDER_COLORS, type NsfFolderColor } from './nsfConstants' +import { + buildFolderOwnerInfo, + cacheNewNsfFolder, + findExistingChildFolder, + isNestedShareFolder, + parseFolderModifyStatus, + parseNsfPath, + requireAuthDataKey, + resolveKeeperDriveParentUid, +} from './nsfHelpers' + +type NsfFolderMetadata = { + name: string + color?: string +} + +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 + 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 color +} + +function resolveBaseFolderUid( + storage: InMemoryStorage, + baseFolderUid: string | null | undefined +): string | null { + if (!baseFolderUid) return null + return isNestedShareFolder(storage, baseFolderUid) ? baseFolderUid : null +} + +async function resolveFolderKeyEncryptionKey( + storage: InMemoryStorage, + auth: Auth, + parentUid: string | null +): Promise { + if (parentUid) { + const parentKey = await storage.getKeyBytes(parentUid) + if (parentKey) return parentKey + } + return requireAuthDataKey(auth) +} + +async function 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 = generateEncryptionKey() + await storage.saveKeyBytes(folderUid, folderKey) + + const metadata: NsfFolderMetadata = { name: folderName } + if (color && color !== 'none') metadata.color = color + + const resolvedParentUid = resolveKeeperDriveParentUid(storage, parentUid) + const encryptedData = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(metadata)), + folderKey + ) + const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, resolvedParentUid) + const encryptedFolderKey = await platform.aesGcmEncrypt(folderKey, encryptionKey) + + return { + folderUid, + folderData: Folder.FolderData.create({ + folderUid: normal64Bytes(folderUid), + parentUid: resolvedParentUid ? normal64Bytes(resolvedParentUid) : undefined, + data: encryptedData, + folderKey: encryptedFolderKey, + type: Folder.FolderUsageType.UT_NORMAL, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + ownerInfo: buildFolderOwnerInfo(auth), + }), + } +} + +async function createFolderV3( + storage: InMemoryStorage, + auth: Auth, + folderName: string, + parentUid: string | null, + color: NsfFolderColor | undefined, + inheritPermissions: boolean +): Promise<{ folderUid: string; message: string }> { + const { folderUid, folderData } = await prepareFolderData( + storage, + auth, + folderName, + parentUid, + color, + inheritPermissions + ) + + const response = await auth.executeRest(folderAddMessage({ folderData: [folderData] })) + const message = parseFolderModifyStatus(response.folderAddResults?.[0], ResultCodes.NSF_MKDIR_FAILED) + + await cacheNewNsfFolder(storage, auth, folderUid, folderName, parentUid, inheritPermissions) + return { folderUid, message } +} + +export async function mkdirNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: MkdirNsfInput +): Promise { + const folderPath = (input.folder ?? '').trim() + if (!folderPath) { + throw new KeeperSdkError('Folder name is required.', ResultCodes.NSF_MKDIR_FAILED) + } + + const color = normalizeColor(input.color) + const inheritPermissions = !input.noInheritPermissions + const segments = parseNsfPath(folderPath) + let parentUid: string | null = resolveBaseFolderUid(storage, input.baseFolderUid) + const lastIdx = segments.length - 1 + let createdUid: string | undefined + + 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 result = await createFolderV3( + storage, + auth, + segment, + parentUid, + isLeaf ? color : undefined, + isLeaf ? inheritPermissions : true + ) + 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..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." @@ -54,3 +52,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 f2f2b524..d8449535 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' @@ -17,13 +18,11 @@ 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, } from './nsfConstants' -export { ROOT_FOLDER_UID } from './nsfConstants' - export enum KeeperDriveKind { Folder = 'keeper_drive_folder', FolderAccess = 'keeper_drive_folder_access', @@ -36,13 +35,115 @@ 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 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) } +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) } @@ -100,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)) { @@ -128,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), @@ -141,6 +246,145 @@ 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 +} + +function isVirtualDriveRootParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { + const trimmed = parentUid?.trim() + if (!trimmed || isRootFolderUid(storage, trimmed)) return true + return !getKnownKeeperDriveFolderUids(storage).has(trimmed) +} + +function isRootLevelParent(storage: InMemoryStorage, parentUid: string | undefined | null): boolean { + return isVirtualDriveRootParent(storage, parentUid) +} + +function folderParentsMatch( + storage: InMemoryStorage, + folderParentUid: string | undefined, + searchParentUid: string | null | undefined +): boolean { + if (normalizeParentUid(storage, folderParentUid) === normalizeParentUid(storage, searchParentUid)) { + return true + } + return isRootLevelParent(storage, searchParentUid) && isRootLevelParent(storage, folderParentUid) +} + +export function findExistingChildFolder( + storage: InMemoryStorage, + segment: string, + parentUid: string | null | undefined +): string | undefined { + const lower = segment.toLowerCase() + const exactMatches: string[] = [] + const rootMatches: string[] = [] + + for (const folder of getKeeperDriveFolders(storage)) { + const name = folder.data.name || '' + if (name.toLowerCase() !== lower) continue + + if (normalizeParentUid(storage, folder.parentUid) === normalizeParentUid(storage, parentUid)) { + exactMatches.push(folder.uid) + continue + } + if (folderParentsMatch(storage, folder.parentUid, parentUid)) { + rootMatches.push(folder.uid) + } + } + + if (exactMatches.length > 0) return exactMatches[0] + if (rootMatches.length > 0) return rootMatches[0] + return undefined +} + +export function resolveKeeperDriveParentUid( + storage: InMemoryStorage, + parentUid: string | null | undefined +): string | null { + if (parentUid && !isRootFolderUid(storage, parentUid)) return parentUid + return resolveKeeperDriveRootParentUid(storage) ?? null +} + +export async function cacheNewNsfFolder( + storage: InMemoryStorage, + auth: { username?: string; accountUid?: Uint8Array }, + folderUid: string, + name: string, + parentUid: string | null | undefined, + inheritPermissions: boolean +): Promise { + const normalizedParent = + parentUid && !isRootFolderUid(storage, parentUid) + ? parentUid.trim() + : resolveKeeperDriveRootParentUid(storage) + await storage.put({ + kind: 'keeper_drive_folder', + uid: folderUid, + data: { name }, + parentUid: normalizedParent, + ownerInfo: { + accountUid: auth.accountUid?.length ? webSafe64FromBytes(auth.accountUid) : undefined, + username: auth.username, + }, + type: Folder.FolderUsageType.UT_NORMAL, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + }) +} + +export function checkFolderDeletePermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array +): void { + if (isRootFolderUid(storage, folderUid)) { + throw new KeeperSdkError('The root folder cannot be removed.', ResultCodes.NSF_PERMISSION_DENIED) + } + + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getFolderAccessEntries(storage, folderUid) + if (entries.length === 0) return + + for (const entry of entries) { + if (!isCurrentUserFolderAccess(storage, entry, username, accountUidStr)) continue + if (entry.accessType === Folder.AccessType.AT_OWNER || entry.permission?.canDelete) return + throw new KeeperSdkError( + 'You do not have permission to delete this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to delete this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + export function findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { return storage .getAll(KeeperDriveKind.FolderRecord) @@ -196,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 } @@ -242,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 @@ -252,7 +496,7 @@ function canRecordBeDeleted( return ( !!folderUid && - !isRootFolderUid(folderUid) && + !isRootFolderUid(storage, folderUid) && hasFolderPermission(storage, folderUid, username, accountUid, 'canDelete') ) } @@ -289,6 +533,30 @@ export function checkRecordDeletePermission( ) } +export function checkRecordEditPermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array +): void { + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getRecordAccessEntries(storage, recordUid) + if (entries.length === 0) return + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue + if (entry.owner || entry.canEdit) return + throw new KeeperSdkError( + 'You do not have permission to edit this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to edit this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + export function formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): string { if (role == null) return 'unknown' return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}` @@ -299,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) } @@ -332,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 } @@ -343,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 @@ -361,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) } @@ -430,4 +690,3 @@ export function isFolderUserPermission(entry: DKdFolderAccess): boolean { entry.accessType === Folder.AccessType.AT_OWNER ) } - diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts new file mode 100644 index 00000000..1a76c12d --- /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' + +const UNKNOWN_RECORD_LABEL = 'Unknown' + +function decodePayload(value: string | Uint8Array | null | undefined): Uint8Array { + if (!value) return new Uint8Array(0) + if (value instanceof Uint8Array) return value + return normal64Bytes(value) +} + +async function decryptWithFolderKeys( + storage: InMemoryStorage, + recordUid: string, + encryptedKey: Uint8Array +): Promise { + for (const folderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + const folderKey = await storage.getKeyBytes(folderUid) + if (!folderKey) continue + try { + return await platform.aesGcmDecrypt(encryptedKey, folderKey) + } catch { + try { + return await platform.aesCbcDecrypt(encryptedKey, folderKey, true) + } catch { + continue + } + } + } + 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 { + 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_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } + } + + const encryptedData = decodePayload(recordData.encryptedRecordData) + if (!encryptedData.length) { + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } + } + + try { + const decrypted = await platform.aesGcmDecrypt(encryptedData, recordKey) + const parsed = JSON.parse(platform.bytesToString(decrypted).replace(/\s+$/, '')) as { + title?: string + type?: string + } + return { + title: parsed.title?.trim() || UNKNOWN_RECORD_LABEL, + type: parsed.type?.trim() || UNKNOWN_RECORD_LABEL, + } + } catch { + 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 new file mode 100644 index 00000000..09735889 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -0,0 +1,260 @@ +import type { Auth, folder as FolderProto } from '@keeper-security/keeperapi' +import { folder, normal64Bytes, removeFolderMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_MAX_FOLDER_REMOVALS } from './nsfConstants' +import { + checkFolderDeletePermission, + ensureNestedShareFolder, + getFolderDisplayName, + requireAuthAccountUid, + resolveNsfFolderIdentifier, +} from './nsfHelpers' + +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', + 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 accountUid = requireAuthAccountUid(auth) + + const removals: RemovalSpec[] = [] + for (const identifier of folderIdentifiers) { + const folderUid = resolveNsfFolderIdentifier(storage, identifier) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, identifier) + checkFolderDeletePermission(storage, folderUid, auth.username, accountUid) + removals.push({ + folderUid, + name: getFolderDisplayName(storage, folderUid), + operation: OPERATION_MAP[operation], + }) + } + return removals +} + +function toRemovalInput(spec: RemovalSpec): FolderProto.v3.remove.IFolderRemoval { + return { + folderUid: normal64Bytes(spec.folderUid), + operationType: spec.operation, + } +} + +async function executeRemove( + auth: Auth, + removals: RemovalSpec[], + action: FolderProto.v3.remove.RemoveAction, + confirmationToken?: Uint8Array +): Promise { + return auth.executeRest( + removeFolderMessage({ + action, + folders: removals.map(toRemovalInput), + confirmationToken, + }) + ) +} + +function mapPreviewItem(spec: RemovalSpec, item: FolderProto.v3.remove.IRemoveResult): NsfRemoveFolderPreviewItem { + return { + folderUid: item.itemUid?.length ? webSafe64FromBytes(item.itemUid) : spec.folderUid, + name: spec.name, + status: item.status == null ? 'REMOVE_STATUS_UNKNOWN' : (RemoveStatus[item.status] ?? String(item.status)), + impact: item.impact + ? { + foldersCount: item.impact.foldersCount ?? 0, + recordsCount: item.impact.recordsCount ?? 0, + affectedUsersCount: item.impact.affectedUsersCount ?? 0, + affectedTeamsCount: item.impact.affectedTeamsCount ?? 0, + warnings: [...(item.impact.warnings ?? [])], + } + : undefined, + error: item.error + ? { + code: item.error.code ?? 0, + message: item.error.message ?? '', + } + : undefined, + } +} + +function mapPreview( + removals: RemovalSpec[], + response: FolderProto.v3.remove.IRemoveResponse +): NsfRemoveFolderPreviewItem[] { + return (response.results ?? []).map((item, index) => { + const spec = removals[index] + if (!spec) { + throw new KeeperSdkError('Folder removal preview mismatch.', ResultCodes.NSF_REMOVE_FAILED) + } + return mapPreviewItem(spec, item) + }) +} + +function hasPreviewErrors(preview: NsfRemoveFolderPreviewItem[]): boolean { + return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) +} + +export function formatRemoveNsfFolderPreview( + preview: NsfRemoveFolderPreviewItem[], + operation: NsfRemoveFolderOperation, + quiet = false +): string { + const action = + operation === NsfRemoveFolderOperation.DeletePermanent + ? PERMANENT_DELETE_ACTION_LABEL + : TRASH_ACTION_LABEL + const lines: string[] = [] + + for (const item of preview) { + lines.push(`\nThe following folder will be ${action}:`) + lines.push(` ${item.name} [${item.folderUid}]`) + if (item.impact && !quiet) { + const parts = [ + `sub-folders=${item.impact.foldersCount}`, + `records=${item.impact.recordsCount}`, + `users=${item.impact.affectedUsersCount}`, + `teams=${item.impact.affectedTeamsCount}`, + ] + lines.push(` Impact: ${parts.join(', ')}`) + for (const warning of item.impact.warnings) { + lines.push(` Warning: ${warning}`) + } + } + if (item.error?.message) { + lines.push(` Error: ${item.error.message}`) + } + } + + return lines.join('\n').trimEnd() +} + +export async function removeNestedShareFolders( + storage: InMemoryStorage, + auth: Auth, + input: RemoveNsfFolderInput +): Promise { + const operation = normalizeOperation(input.operation) + const dryRun = input.dryRun ?? false + const removals = buildRemovals(storage, auth, input.folders, operation) + + try { + const previewResponse = await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_PREVIEW) + const preview = mapPreview(removals, previewResponse) + + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError( + formatRemoveNsfFolderPreview(preview, operation, input.quiet) || 'Folder removal preview failed.', + ResultCodes.NSF_REMOVE_FAILED + ) + } + + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, operation, preview } + } + + if (!input.force) { + return { + confirmed: false, + dryRun: false, + operation, + preview, + message: 'Confirmation required. Set force=true to proceed.', + } + } + + await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_CONFIRM, previewResponse.confirmationToken) + const actionLabel = + operation === NsfRemoveFolderOperation.DeletePermanent ? 'Permanently deleted' : 'Moved to trash' + return { + confirmed: true, + dryRun: false, + operation, + preview, + message: `${actionLabel} ${removals.length} folder(s).`, + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to remove nested share folder(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_REMOVE_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts index 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/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts new file mode 100644 index 00000000..680e513b --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -0,0 +1,131 @@ +import type { Auth, DRecord } from '@keeper-security/keeperapi' +import { keeperDriveRecordsUpdate, normal64Bytes, platform } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { VaultObjectKind } from '../folders/folderHelpers' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import { getPaddedJsonBytes, mergeNsfRecordData, type NsfRecordFieldMap } from './nsfRecordData' +import { + checkRecordEditPermission, + ensureNestedShareRecord, + nsfToNumber, + parseRecordModifyStatus, + requireAuthAccountUid, + resolveNsfRecordIdentifier, +} from './nsfHelpers' + +export type { NsfRecordFieldMap as UpdateNsfRecordFieldMap } from './nsfRecordData' + +export type UpdateNsfRecordInput = { + records: string[] + title?: string + recordType?: string + notes?: string + fields?: NsfRecordFieldMap +} + +export type UpdateNsfRecordResultItem = { + recordUid: string + success: boolean + status: string + message?: string + revision?: number +} + +export type UpdateNsfRecordResult = { + updated: UpdateNsfRecordResultItem[] +} + +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 = mergeNsfRecordData(loadExistingRecordData(storage, recordUid), input) + const response = await auth.executeRest( + keeperDriveRecordsUpdate({ + records: [ + { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + revision: record?.revision ?? 0, + data: await platform.aesGcmEncrypt(getPaddedJsonBytes(merged), recordKey), + }, + ], + clientTime: Date.now(), + }) + ) + + const { statusName, message } = parseRecordModifyStatus( + response.records?.[0], + ResultCodes.NSF_UPDATE_FAILED + ) + const revision = nsfToNumber(response.revision) ?? record?.revision + + if (record) { + await storage.put({ + ...record, + data: merged, + revision: revision ?? record.revision, + clientModifiedTime: Date.now(), + }) + } + + return { + recordUid, + success: true, + status: statusName, + message, + revision, + } +} + +export async function updateNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordInput +): Promise { + if (!input.records?.length) { + throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) + } + + const accountUid = requireAuthAccountUid(auth) + const updated: UpdateNsfRecordResultItem[] = [] + + try { + for (const identifier of input.records) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + checkRecordEditPermission(storage, recordUid, auth.username, accountUid) + 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..f67696b5 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -64,7 +64,12 @@ export enum NsfErrorCode { RemoveFailed = 'nsf_remove_failed', FolderRequired = 'nsf_folder_required', TooManyRecords = 'nsf_too_many_records', + TooManyFolders = 'nsf_too_many_folders', MissingKey = 'nsf_missing_key', + MkdirFailed = 'nsf_mkdir_failed', + UpdateFailed = 'nsf_update_failed', + DetailsFailed = 'nsf_details_failed', + AddFailed = 'nsf_add_failed', } export enum TeamErrorCode { @@ -143,7 +148,12 @@ export const ResultCodes = { NSF_REMOVE_FAILED: NsfErrorCode.RemoveFailed, NSF_FOLDER_REQUIRED: NsfErrorCode.FolderRequired, NSF_TOO_MANY_RECORDS: NsfErrorCode.TooManyRecords, + NSF_TOO_MANY_FOLDERS: NsfErrorCode.TooManyFolders, NSF_MISSING_KEY: NsfErrorCode.MissingKey, + NSF_MKDIR_FAILED: NsfErrorCode.MkdirFailed, + NSF_UPDATE_FAILED: NsfErrorCode.UpdateFailed, + NSF_DETAILS_FAILED: NsfErrorCode.DetailsFailed, + NSF_ADD_FAILED: NsfErrorCode.AddFailed, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 80f6e5c0..363236ea 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -80,6 +80,12 @@ 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 type { AddNsfRecordInput, AddNsfRecordResult } from '../nestedShareFolders/addNsfRecord' +import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' import type { ListUserRow, ListUsersOptions, @@ -751,6 +757,57 @@ 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 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 165e6c7b..a84d3b35 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -33,6 +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: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 new file mode 100644 index 00000000..57d275d7 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts @@ -0,0 +1,39 @@ +import { + cleanup, + extractErrorMessage, + GetNsfRecordDetailsFormat, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +async function getNsfRecordDetails() { + const vault = await login() + + try { + const records = splitCommaSeparated(await prompt('Record UID(s) or title(s), comma-separated: ')) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const formatInput = (await prompt('Output format (table/json) [table]: ')).trim().toLowerCase() + const format = + formatInput === 'json' ? GetNsfRecordDetailsFormat.JSON : GetNsfRecordDetailsFormat.Table + + const result = await withSuppressedLogs(() => vault.getNestedShareRecordDetails({ records, format })) + + logger.info('') + logger.info(vault.formatNsfRecordDetailsOutput(result, format)) + logger.info('') + } catch (err) { + logger.error(`Record details failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(getNsfRecordDetails) diff --git a/examples/sdk_example/src/nestedShareFolders/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/update_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts new file mode 100644 index 00000000..b2a662a7 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts @@ -0,0 +1,56 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + parseNsfFieldStrings, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +async function updateNsfRecord() { + const vault = await login() + + try { + const records = splitCommaSeparated(await prompt('Record UID(s) or title(s), comma-separated: ')) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const 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 + ? parseNsfFieldStrings(splitCommaSeparated(fieldsInput)).fields + : undefined + + const result = await withSuppressedLogs(() => + vault.updateNestedShareRecords({ + records, + title: title || undefined, + recordType: recordType || undefined, + notes: notes || undefined, + fields, + }) + ) + + 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) 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/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 6d9ab14f..72bc2bfa 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,80 @@ 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 => @@ -878,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 ) @@ -993,12 +1067,12 @@ export const pamGetOnlineControllersMessage = (): RestOutMessage => - createMessage(data, '/vault/records/v3/add', record.v3.RecordsAddRequest, Records.RecordsModifyResponse) + createMessage(data, 'vault/records/v3/add', record.v3.RecordsAddRequest, Records.RecordsModifyResponse) export const keeperDriveRecordsUpdate = ( data: Records.IRecordsUpdateRequest ): RestMessage => - createMessage(data, '/vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) + createMessage(data, 'vault/records/v3/update', Records.RecordsUpdateRequest, Records.RecordsModifyResponse) export const getSharingAdminsMessage = ( data: Enterprise.IGetSharingAdminsRequest