From 95e1f86054899c33287ab4e157dc0f652844894f Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 7 Jul 2026 20:50:35 +0530 Subject: [PATCH 1/2] NSF record and folder sdk functionality along with records bug fix --- KeeperSdk/src/index.ts | 59 +++ .../NestedShareFolderManager.ts | 110 ++--- .../src/nestedShareFolders/addNsfRecord.ts | 193 ++++++++ KeeperSdk/src/nestedShareFolders/getNsf.ts | 373 +++++++++------- .../nestedShareFolders/getNsfRecordDetails.ts | 111 +++++ KeeperSdk/src/nestedShareFolders/index.ts | 107 ++++- .../src/nestedShareFolders/linkNsfRecord.ts | 9 +- KeeperSdk/src/nestedShareFolders/listNsf.ts | 73 ++-- KeeperSdk/src/nestedShareFolders/mkdirNsf.ts | 224 ++++++++++ .../src/nestedShareFolders/nsfConstants.ts | 59 ++- .../src/nestedShareFolders/nsfHelpers.ts | 413 +++++++++++++++++- .../src/nestedShareFolders/nsfRecordCrypto.ts | 110 +++++ .../src/nestedShareFolders/nsfRecordData.ts | 385 ++++++++++++++++ .../src/nestedShareFolders/nsfRecordTypes.ts | 95 ++++ KeeperSdk/src/nestedShareFolders/nsfTypes.ts | 403 +++++++++++++++++ .../src/nestedShareFolders/removeNsfFolder.ts | 231 ++++++++++ .../src/nestedShareFolders/removeNsfRecord.ts | 67 +-- .../src/nestedShareFolders/updateNsfRecord.ts | 196 +++++++++ KeeperSdk/src/utils/constants.ts | 10 + KeeperSdk/src/vault/KeeperVault.ts | 98 ++++- 20 files changed, 2982 insertions(+), 344 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/addNsfRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts create mode 100644 KeeperSdk/src/nestedShareFolders/mkdirNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordData.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfTypes.ts create mode 100644 KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts create mode 100644 KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 8e812d8e..f6dc11de 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -518,16 +518,45 @@ export { formatListNsfJson, formatListNsfOutput, GetNsfFormat, + NsfAccessRoleLabel, + NsfObjectKind, + NSF_ACCESS_ROLE_LABELS, + NSF_MAX_RECORD_BATCH, + resolveRecordPermissionRole, + toNsfAccessRoleLabel, resolveNsfFolder, resolveNsfRecord, getNestedShareFolder, formatNsfFolderDetail, formatNsfRecordDetail, formatNsfDetail, + formatNsfJson, + formatNsfRecordJson, + toNsfRecordJsonView, linkNestedShareRecord, NsfRemoveOperation, removeNestedShareRecords, formatRemoveNsfPreview, + collectRemoveNsfWarnings, + mkdirNestedShareFolder, + NSF_FOLDER_COLORS, + NsfRemoveFolderOperation, + removeNestedShareFolders, + formatRemoveNsfFolderPreview, + GetNsfRecordDetailsFormat, + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, + updateNestedShareRecords, + updateNestedShareRecord, + addNestedShareRecord, + addNestedShareRecords, + buildNsfRecordData, + parseNsfFieldInput, + parseNsfFieldSpaceInput, + resolveNsfFieldValue, + clearNsfRecordTypeCache, + checkRecordEditPermission, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -540,6 +569,10 @@ export type { GetNsfResult, NsfFolderView, NsfRecordView, + NsfRecordFieldView, + NsfRecordFolderView, + NsfRecordJsonView, + NsfRecordJsonUserPermission, NsfFolderPermission, NsfFolderAccessRow, NsfRecordPermission, @@ -548,6 +581,32 @@ export type { RemoveNsfRecordInput, NsfRemovePreviewItem, RemoveNsfRecordResult, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, + NsfFolderColor, + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + RecordAddPayload, + RecordUpdatePayload, + FolderCreatePayload, + ParsedNsfFields, + RecordFieldEntry, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index 4a21c74f..c8e75fa3 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -1,33 +1,39 @@ import type { Auth } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, ResultCodes } from '../utils' -import { - formatListNsfOutput, - formatListNsfTable, - listNestedShareFolders, - renderListNsfAsciiTable, - type FormattedListNsfTable, - type ListNsfFormatInput, - type ListNsfOptions, - type ListNsfRow, -} from './listNsf' -import { - formatNsfDetail as renderNsfDetail, - formatNsfFolderDetail as renderNsfFolderDetail, - formatNsfRecordDetail as renderNsfRecordDetail, - getNestedShareFolder, - type GetNsfOptions, - type GetNsfResult, - type NsfFolderView, - type NsfRecordView, -} from './getNsf' -import { linkNestedShareRecord, type LinkNsfRecordResult } from './linkNsfRecord' -import { - formatRemoveNsfPreview, - removeNestedShareRecords, - type RemoveNsfRecordInput, - type RemoveNsfRecordResult, -} from './removeNsfRecord' +import { listNestedShareFolders } from './listNsf' +import { getNestedShareFolder } from './getNsf' +import { linkNestedShareRecord } from './linkNsfRecord' +import { removeNestedShareRecords } from './removeNsfRecord' +import { mkdirNestedShareFolder } from './mkdirNsf' +import { removeNestedShareFolders } from './removeNsfFolder' +import { getNestedShareRecordDetails } from './getNsfRecordDetails' +import { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' +import { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' +import type { + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + GetNsfOptions, + GetNsfResult, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + LinkNsfRecordResult, + ListNsfOptions, + ListNsfRow, + MkdirNsfInput, + MkdirNsfResult, + RemoveNsfFolderInput, + RemoveNsfFolderResult, + RemoveNsfRecordInput, + RemoveNsfRecordResult, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, +} from './nsfTypes' export type AuthProvider = () => Auth @@ -52,46 +58,50 @@ export class NestedShareFolderManager { return listNestedShareFolders(this.storage, options) } - public formatListNsfTable(rows: ListNsfRow[], options: { columnWidth?: number } = {}): FormattedListNsfTable { - return formatListNsfTable(rows, options) + public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { + return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) } - public renderListNsfAsciiTable(table: FormattedListNsfTable, options: { minColWidth?: number } = {}): string { - return renderListNsfAsciiTable(table, options) + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string + ): Promise { + return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) } - public formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = 'table'): string { - return formatListNsfOutput(rows, format) + public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { + return removeNestedShareRecords(this.storage, this.requireAuth(), input) } - public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { - return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) + public async mkdirNestedShareFolder(input: MkdirNsfInput): Promise { + return mkdirNestedShareFolder(this.storage, this.requireAuth(), input) } - public formatNsfDetail(result: GetNsfResult, verbose = false): string { - return renderNsfDetail(result, verbose) + public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { + return removeNestedShareFolders(this.storage, this.requireAuth(), input) } - public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { - return renderNsfFolderDetail(view, verbose) + public async getNestedShareRecordDetails( + input: GetNsfRecordDetailsInput + ): Promise { + return getNestedShareRecordDetails(this.storage, this.requireAuth(), input) } - public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { - return renderNsfRecordDetail(view, verbose) + public async updateNestedShareRecords( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput + ): Promise { + return updateNestedShareRecords(this.storage, this.requireAuth(), input) } - public async linkNestedShareRecord( - recordIdentifier: string, - folderIdentifier: string - ): Promise { - return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) + public async updateNestedShareRecord(input: UpdateNsfRecordItemInput): Promise { + return updateNestedShareRecord(this.storage, this.requireAuth(), input) } - public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { - return removeNestedShareRecords(this.storage, this.requireAuth(), input) + public async addNestedShareRecords(input: AddNsfRecordsInput): Promise { + return addNestedShareRecords(this.storage, this.requireAuth(), input) } - public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { - return formatRemoveNsfPreview(preview) + public async addNestedShareRecord(input: AddNsfRecordInput): Promise { + return addNestedShareRecord(this.storage, this.requireAuth(), input) } } diff --git a/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts new file mode 100644 index 00000000..e6e26009 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/addNsfRecord.ts @@ -0,0 +1,193 @@ +import type { Auth, record as RecordProto } from '@keeper-security/keeperapi' +import { + Folder, + generateEncryptionKey, + generateUid, + keeperDriveRecordsAdd, + normal64Bytes, + platform, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_MAX_RECORD_BATCH } from './nsfConstants' +import { + buildNsfRecordData, + getPaddedJsonBytes, +} from './nsfRecordData' +import { + ensureNestedShareFolder, + nsfToNumber, + parseRecordModifyStatus, + requireAuthDataKey, + resolveNsfFolderIdentifier, +} from './nsfHelpers' +import { validateNsfRecordType } from './nsfRecordTypes' +import type { + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + RecordAddPayload, +} from './nsfTypes' + +function resolveFolderUid(storage: InMemoryStorage, folderInput?: string): string | undefined { + const trimmed = folderInput?.trim() + if (!trimmed) return undefined + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed) + if (!folderUid) { + throw new KeeperSdkError(`No such folder: ${trimmed}`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, trimmed) + return folderUid +} + +async function requireFolderKey(storage: InMemoryStorage, folderUid: string): Promise { + const folderKey = await storage.getKeyBytes(folderUid) + if (folderKey) return folderKey + throw new KeeperSdkError( + `Folder key not found for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) +} + +async function buildRecordAddPayload( + storage: InMemoryStorage, + auth: Auth, + recordData: Record, + folderUid?: string +): Promise { + const recordUid = generateUid() + const recordKey = generateEncryptionKey() + await storage.saveKeyBytes(recordUid, recordKey) + + const recordAdd: RecordProto.v3.IRecordAdd = { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + data: await platform.aesGcmEncrypt(getPaddedJsonBytes(recordData), recordKey), + } + + if (folderUid) { + const folderKey = await requireFolderKey(storage, folderUid) + recordAdd.folderUid = normal64Bytes(folderUid) + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, folderKey) + recordAdd.recordKeyEncryptedBy = Folder.FolderKeyEncryptionType.ENCRYPTED_BY_PARENT_KEY + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm + } else { + recordAdd.recordKey = await platform.aesGcmEncrypt(recordKey, requireAuthDataKey(auth)) + recordAdd.recordKeyType = Folder.EncryptedKeyType.encrypted_by_data_key_gcm + } + + return { recordUid, recordAdd } +} + +function validateAddNsfRecordInput(input: AddNsfRecordInput): void { + if (!input.title?.trim()) { + throw new KeeperSdkError('Record title is required.', ResultCodes.NSF_ADD_FAILED) + } + if (!input.recordType?.trim() && !input.recordData) { + throw new KeeperSdkError('Record type is required.', ResultCodes.NSF_ADD_FAILED) + } + if (input.hasFileFields && !input.force) { + throw new KeeperSdkError( + 'File attachments are not supported in nested share record add.', + ResultCodes.NSF_ADD_FAILED + ) + } +} + +function buildRecordDataFromInput(input: AddNsfRecordInput): Record { + return ( + input.recordData ?? + buildNsfRecordData({ + title: input.title, + recordType: input.recordType, + notes: input.notes, + fieldEntries: input.fieldEntries, + customEntries: input.customEntries, + }) + ) +} + +export async function addNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: AddNsfRecordsInput +): Promise { + const recordInputs = input.records ?? [] + if (recordInputs.length === 0) { + throw new KeeperSdkError('At least one record is required.', ResultCodes.NSF_ADD_FAILED) + } + if (recordInputs.length > NSF_MAX_RECORD_BATCH) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, + ResultCodes.NSF_TOO_MANY_RECORDS + ) + } + + const recordTypes = new Set() + for (const recordInput of recordInputs) { + validateAddNsfRecordInput(recordInput) + if (!recordInput.recordData && recordInput.recordType?.trim()) { + recordTypes.add(recordInput.recordType.trim()) + } + } + for (const recordType of recordTypes) { + await validateNsfRecordType(auth, recordType, ResultCodes.NSF_ADD_FAILED) + } + + try { + const payloads = await Promise.all( + recordInputs.map(async (recordInput) => + buildRecordAddPayload( + storage, + auth, + buildRecordDataFromInput(recordInput), + resolveFolderUid(storage, recordInput.folder) + ) + ) + ) + + const response = await auth.executeRest( + keeperDriveRecordsAdd({ + records: payloads.map((entry) => entry.recordAdd), + clientTime: Date.now(), + }) + ) + const revision = nsfToNumber(response.revision) + + const added = payloads.map((payload, index) => { + const { statusName, message } = parseRecordModifyStatus( + response.records?.[index], + ResultCodes.NSF_ADD_FAILED + ) + return { + recordUid: payload.recordUid, + success: true, + status: statusName, + message, + revision, + } + }) + + return { added, revision } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to add nested share record(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_ADD_FAILED + ) + } +} + +export async function addNestedShareRecord( + storage: InMemoryStorage, + auth: Auth, + input: AddNsfRecordInput +): Promise { + const { added } = await addNestedShareRecords(storage, auth, { records: [input] }) + if (!added[0]) { + throw new KeeperSdkError('Failed to add nested share record.', ResultCodes.NSF_ADD_FAILED) + } + return added[0] +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts index 25523668..106dd2d6 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -16,20 +16,25 @@ import { getRecordType, getRecordUrl, } from '../records/RecordUtils' -import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { KeeperSdkError, ResultCodes } from '../utils' import { buildFolderPath, collectRecordsInFolder, + fetchLiveFolderAccessEntries, + fetchLiveRecordAccessEntries, + findNestedShareFoldersForRecord, findRecordFolderLocation, folderAccessDisplayRole, formatAccessType, - getFolderAccessEntries, getKeeperDriveFolder, getKeeperDriveRecord, + isFolderOwnerAccessor, isFolderShareAdministrator, isFolderUserPermission, isSensitiveFieldType, + loadShareUserMap, normalizeParentUid, + recordAccessDisplayRole, resolveAccessUsername, resolveNsfFolderIdentifier, resolveNsfRecordIdentifier, @@ -37,18 +42,31 @@ import { import { NSF_FOLDER_LABEL_WIDTH, NSF_FOLDER_SHARE_ADMINS_HEADING, - NSF_FOLDER_USER_PERMISSIONS_HEADING, + NSF_USER_PERMISSIONS_HEADING, NSF_MASKED_VALUE, NSF_RECORD_LABEL_WIDTH, - NSF_RECORD_USER_PERMISSIONS_HEADING, + NSF_SHARE_ADMINS_PREVIEW_LIMIT, NSF_TOP_LEVEL_FIELD_TYPES, NSF_UNKNOWN_RECORD_TITLES, } from './nsfConstants' - -function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { - if (value == null) return undefined - return typeof value === 'number' ? value : value.toNumber() -} +import { + GetNsfFormat, + NsfAccessRoleLabel, + NsfObjectKind, + resolveRecordPermissionRole, + type GetNsfFormatInput, + type GetNsfOptions, + type GetNsfResult, + type NsfFolderAccessRow, + type NsfFolderPermission, + type NsfFolderView, + type NsfRecordFieldView, + type NsfRecordFolderView, + type NsfRecordJsonUserPermission, + type NsfRecordJsonView, + type NsfRecordPermission, + type NsfRecordView, +} from './nsfTypes' function formatNsfFieldParts(values: unknown[]): string[] { return values @@ -70,82 +88,6 @@ function formatNsfFieldValue(value: unknown): string { return String(value) } -export enum GetNsfFormat { - Detail = 'detail', - JSON = 'json', -} - -export type GetNsfFormatInput = GetNsfFormat | `${GetNsfFormat}` - -export type GetNsfOptions = { - format?: GetNsfFormatInput - verbose?: boolean - unmask?: boolean -} - -export type NsfFolderAccessRow = { - username: string - role: string -} - -export type NsfFolderPermission = { - accessTypeUid: string - accessType: string - accessRoleType: string - inherited?: boolean - hidden?: boolean - canAdd?: boolean - canRemove?: boolean - canDelete?: boolean - canListAccess?: boolean - canUpdateAccess?: boolean - canEditRecords?: boolean - canViewRecords?: boolean - canListRecords?: boolean -} - -export type NsfRecordPermission = { - username: string - accountUid?: string - owner: boolean - shareAdmin: boolean - shareable: boolean - editable: boolean - awaitingApproval: boolean - expiration?: number -} - -export type NsfFolderView = { - objectType: 'folder' - folderUid: string - name: string - parentUid: string - path: string - userPermissions: NsfFolderAccessRow[] - shareAdmins: NsfFolderAccessRow[] - teamPermissions: NsfFolderPermission[] - records: { uid: string; title: string; type: string }[] -} - -export type NsfRecordView = { - objectType: 'record' - recordUid: string - title: string - type: string - revision: number - version: number - folderLocation: string - login?: string - password?: string - url?: string - notes?: string - fields: { type: string; label?: string; value: string }[] - userPermissions: NsfRecordPermission[] - shareAdmins: string[] -} - -export type GetNsfResult = { kind: 'folder'; view: NsfFolderView } | { kind: 'record'; view: NsfRecordView } - function folderDetailRow(label: string, value: string): string { return `${label.padStart(NSF_FOLDER_LABEL_WIDTH)}: ${value}` } @@ -162,10 +104,6 @@ function recordDetailsMessage(recordUid: string, include: Records.RecordDetailsI }) } -function bytesToUid(bytes: Uint8Array | null | undefined): string | undefined { - return bytes?.length ? webSafe64FromBytes(bytes) : undefined -} - function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { const permission = entry.permission return { @@ -188,36 +126,44 @@ function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { function buildFolderAccessRow( storage: InMemoryStorage, folder: DKdFolder, - entry: DKdFolderAccess + entry: DKdFolderAccess, + shareUsers: Map ): NsfFolderAccessRow { - return { - username: resolveAccessUsername(storage, entry.accessTypeUid, folder), - role: folderAccessDisplayRole(entry), - } + const username = resolveAccessUsername(storage, entry.accessTypeUid, folder, shareUsers) + const role = isFolderOwnerAccessor(folder, entry, username) + ? NsfAccessRoleLabel.Owner + : folderAccessDisplayRole(entry) + return { username, role } } -function splitFolderPermissions(storage: InMemoryStorage, folder: DKdFolder) { - const entries = getFolderAccessEntries(storage, folder.uid) +function splitFolderPermissions( + storage: InMemoryStorage, + folder: DKdFolder, + entries: DKdFolderAccess[], + shareUsers: Map +) { const userPermissions: NsfFolderAccessRow[] = [] const shareAdmins: NsfFolderAccessRow[] = [] const teamPermissions: NsfFolderPermission[] = [] + for (const entry of entries) { if (isFolderUserPermission(entry)) { - userPermissions.push(buildFolderAccessRow(storage, folder, entry)) + userPermissions.push(buildFolderAccessRow(storage, folder, entry, shareUsers)) } if (isFolderShareAdministrator(entry)) { - shareAdmins.push(buildFolderAccessRow(storage, folder, entry)) + shareAdmins.push(buildFolderAccessRow(storage, folder, entry, shareUsers)) } if (entry.accessType === Folder.AccessType.AT_TEAM) { teamPermissions.push(mapFolderPermission(entry)) } } + return { userPermissions, shareAdmins, teamPermissions } } function prependOwnerRow(rows: NsfFolderAccessRow[], ownerUsername: string): NsfFolderAccessRow[] { if (rows.some((entry) => entry.username === ownerUsername)) return rows - return [{ username: ownerUsername, role: 'owner' }, ...rows] + return [{ username: ownerUsername, role: NsfAccessRoleLabel.Owner }, ...rows] } function ensureFolderOwnerListed( @@ -233,20 +179,127 @@ function ensureFolderOwnerListed( } } -function buildRecordFields(record: DRecord, unmask: boolean): NsfRecordView['fields'] { - return getRecordFields(record) - .filter((field) => !NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) - .map((field) => { - const rawValues = Array.isArray(field.value) ? field.value : [field.value] - const displayValue = formatNsfFieldParts(rawValues).join(', ') - return { field, displayValue } - }) - .filter(({ displayValue }) => displayValue.length > 0) - .map(({ field, displayValue }) => ({ +function fieldValueToStrings(values: unknown[], fieldType?: string): string[] { + return values.map((value) => { + if (value == null || value === '') return '' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (typeof value === 'object') { + const obj = value as Record + if (fieldType === 'host' || fieldType === 'address') { + return JSON.stringify(obj) + } + if (typeof obj.url === 'string') return obj.url + if (typeof obj.value === 'string') return obj.value + return JSON.stringify(value) + } + return String(value) + }) +} + +function resolveRecordFolder( + storage: InMemoryStorage, + recordUid: string +): NsfRecordFolderView | undefined { + const folderUids = findNestedShareFoldersForRecord(storage, recordUid) + if (folderUids.length === 0) return undefined + + const uid = folderUids[0] + return { + uid, + path: buildFolderPath(storage, uid).replace(/^\//, ''), + } +} + +function buildRecordFields(record: DRecord, unmask: boolean): NsfRecordFieldView[] { + return getRecordFields(record).map((field) => { + const rawValues = Array.isArray(field.value) ? field.value : [field.value] + const stringValues = fieldValueToStrings(rawValues, field.type) + const masked = !unmask && isSensitiveFieldType(field.type) + return { type: field.type, label: field.label, - value: !unmask && isSensitiveFieldType(field.type) ? NSF_MASKED_VALUE : displayValue, - })) + value: + masked && stringValues.some((value) => value.length > 0) + ? stringValues.map((value) => (value.length > 0 ? NSF_MASKED_VALUE : value)) + : stringValues, + } + }) +} + +function formatRecordFieldDetailLines(field: NsfRecordFieldView): string[] { + if (NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) return [] + + if (field.type === 'host' || field.type === 'address') { + const lines: string[] = [] + for (const part of field.value) { + if (!part) continue + let obj: Record | undefined + if (typeof part === 'string' && part.startsWith('{')) { + try { + obj = JSON.parse(part) as Record + } catch { + obj = undefined + } + } + if (obj) { + if (obj.hostName != null && String(obj.hostName).length > 0) { + lines.push(recordDetailRow('HostName', String(obj.hostName))) + } + if (obj.port != null && String(obj.port).length > 0) { + lines.push(recordDetailRow('Port', String(obj.port))) + } + if (obj.street1 != null && String(obj.street1).length > 0) { + lines.push(recordDetailRow('Street', String(obj.street1))) + } + continue + } + } + if (lines.length > 0) return lines + } + + const displayValue = field.value.filter(Boolean).join(', ') + if (!displayValue) return [] + const label = field.label || field.type + return [recordDetailRow(label.charAt(0).toUpperCase() + label.slice(1), displayValue)] +} + +function jsonFieldValues(type: string, value: string[]): unknown[] { + return value.map((part) => { + if ((type === 'host' || type === 'address') && part.startsWith('{')) { + try { + return JSON.parse(part) as Record + } catch { + return part + } + } + return part + }) +} + +export function toNsfRecordJsonView(view: NsfRecordView): NsfRecordJsonView { + return { + record_uid: view.recordUid, + title: view.title, + type: view.type, + version: view.version, + revision: view.revision, + ...(view.folder ? { folder: view.folder } : {}), + fields: view.fields.map(({ type, value }) => ({ type, value: jsonFieldValues(type, value) })), + ...(view.notes ? { notes: view.notes } : {}), + user_permissions: view.userPermissions.map((entry) => ({ + username: entry.username, + owner: entry.owner, + shareable: entry.shareable, + editable: entry.editable, + role: resolveRecordPermissionRole(entry), + })), + share_admins: view.shareAdmins, + } +} + +export function formatNsfRecordJson(view: NsfRecordView): string { + return JSON.stringify(toNsfRecordJsonView(view), null, 2) } function formatRecordUserPermissionBlock(entry: NsfRecordPermission): string[] { @@ -254,32 +307,31 @@ function formatRecordUserPermissionBlock(entry: NsfRecordPermission): string[] { if (entry.username) lines.push(` User: ${entry.username}`) else if (entry.accountUid) lines.push(` User UID: ${entry.accountUid}`) if (entry.owner) lines.push(' Owner: Yes') + else if (entry.role) lines.push(` Role: ${entry.role}`) lines.push(` Shareable: ${entry.shareable ? 'Yes' : 'No'}`) lines.push(` Read-Only: ${entry.editable ? 'No' : 'Yes'}`) return lines } -async function fetchRecordPermissions(auth: Auth, recordUid: string): Promise { - try { - const response = await auth.executeRest( - recordDetailsMessage(recordUid, Records.RecordDetailsInclude.SHARE_ONLY) - ) - const detail = response.recordDataWithAccessInfo?.[0] - return (detail?.userPermission ?? []).map((entry) => ({ - username: entry.username || '', - accountUid: bytesToUid(entry.accountUid), - owner: !!entry.owner, - shareAdmin: !!entry.shareAdmin, - shareable: !!entry.sharable, - editable: !!entry.editable, - awaitingApproval: !!entry.awaitingApproval, - expiration: longToNumber(entry.expiration as number | null | undefined), - })) - } catch (err) { - throw new KeeperSdkError( - `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}` - ) - } +async function fetchRecordPermissions( + auth: Auth, + storage: InMemoryStorage, + recordUid: string +): Promise { + const entries = await fetchLiveRecordAccessEntries(auth, storage, recordUid) + return entries.map(({ username, accountUid, data }) => { + const owner = !!data.owner + return { + username, + accountUid, + owner, + shareAdmin: false, + shareable: !!(data.canApproveAccess || data.canUpdateAccess), + editable: !!data.canEdit, + awaitingApproval: false, + role: owner ? undefined : recordAccessDisplayRole(data), + } + }) } async function fetchRecordShareAdmins(auth: Auth, recordUid: string): Promise { @@ -306,13 +358,21 @@ async function fetchRecordDataFallback(auth: Auth, recordUid: string): Promise { const folder = getKeeperDriveFolder(storage, folderUid) if (!folder) { throw new KeeperSdkError(`Nested share folder not found: ${folderUid}`, ResultCodes.NSF_NOT_FOUND) } - const split = splitFolderPermissions(storage, folder) + const [entries, shareUsers] = await Promise.all([ + fetchLiveFolderAccessEntries(auth, folderUid), + loadShareUserMap(auth, storage), + ]) + const split = splitFolderPermissions(storage, folder, entries, shareUsers) const { userPermissions, shareAdmins } = ensureFolderOwnerListed( folder, split.userPermissions, @@ -320,7 +380,7 @@ function buildFolderView(storage: InMemoryStorage, folderUid: string): NsfFolder ) return { - objectType: 'folder', + objectType: NsfObjectKind.Folder, folderUid, name: folder.data.name || 'Unnamed', parentUid: normalizeParentUid(storage, folder.parentUid), @@ -358,19 +418,20 @@ async function buildRecordView( const password = getRecordPassword(record) const [userPermissions, shareAdmins] = await Promise.all([ - fetchRecordPermissions(auth, recordUid), + fetchRecordPermissions(auth, storage, recordUid), fetchRecordShareAdmins(auth, recordUid), ]) const notes = typeof record.data?.notes === 'string' && record.data.notes.trim() ? record.data.notes.trim() : undefined return { - objectType: 'record', + objectType: NsfObjectKind.Record, recordUid, title, type: getRecordType(record), revision: record.revision, version: record.version, + folder: resolveRecordFolder(storage, recordUid), folderLocation: findRecordFolderLocation(storage, recordUid) || 'root', login: getRecordLogin(record) || undefined, password: password ? (unmask ? password : NSF_MASKED_VALUE) : undefined, @@ -405,13 +466,13 @@ export async function getNestedShareFolder( const folderUid = resolveNsfFolder(storage, trimmed) if (folderUid) { - return { kind: 'folder', view: buildFolderView(storage, folderUid) } + return { kind: NsfObjectKind.Folder, view: await buildFolderView(auth, storage, folderUid) } } const recordUid = resolveNsfRecord(storage, trimmed) if (recordUid) { const view = await buildRecordView(auth, storage, recordUid, options.unmask ?? false) - return { kind: 'record', view } + return { kind: NsfObjectKind.Record, view } } throw new KeeperSdkError( @@ -425,7 +486,7 @@ export function formatNsfFolderDetail(view: NsfFolderView, verbose = false): str folderDetailRow('Nested Share Folder UID', view.folderUid), folderDetailRow('Name', view.name), '', - NSF_FOLDER_USER_PERMISSIONS_HEADING, + NSF_USER_PERMISSIONS_HEADING, ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), '', NSF_FOLDER_SHARE_ADMINS_HEADING, @@ -463,38 +524,56 @@ export function formatNsfRecordDetail(view: NsfRecordView, verbose = false): str if (view.notes) lines.push(recordDetailRow('Notes', view.notes)) for (const field of view.fields) { - const label = field.label || field.type - lines.push(recordDetailRow(label.charAt(0).toUpperCase() + label.slice(1), field.value)) + lines.push(...formatRecordFieldDetailLines(field)) } if (view.userPermissions.length > 0) { - lines.push('', NSF_RECORD_USER_PERMISSIONS_HEADING) + lines.push('', NSF_USER_PERMISSIONS_HEADING) for (const entry of view.userPermissions) { lines.push('', ...formatRecordUserPermissionBlock(entry)) } } if (view.shareAdmins.length > 0) { - lines.push('', `Share Admins (${view.shareAdmins.length}):`) - for (const admin of view.shareAdmins) { + const total = view.shareAdmins.length + const preview = view.shareAdmins.slice(0, NSF_SHARE_ADMINS_PREVIEW_LIMIT) + const headingSuffix = + total > NSF_SHARE_ADMINS_PREVIEW_LIMIT + ? `, showing first ${NSF_SHARE_ADMINS_PREVIEW_LIMIT}` + : '' + lines.push('', `Share Admins (${total}${headingSuffix}):`) + for (const admin of preview) { lines.push(` ${admin}`) } + if (total > NSF_SHARE_ADMINS_PREVIEW_LIMIT) { + lines.push(` ... and ${total - NSF_SHARE_ADMINS_PREVIEW_LIMIT} more`) + } } if (verbose) { lines.push( '', - recordDetailRow('Folder', view.folderLocation), + recordDetailRow('Folder', view.folder?.path ?? view.folderLocation), recordDetailRow('Revision', String(view.revision)), recordDetailRow('Version', String(view.version)) ) + if (view.folder?.uid) { + lines.push(recordDetailRow('Folder UID', view.folder.uid)) + } } return lines.join('\n') } export function formatNsfDetail(result: GetNsfResult, verbose = false): string { - return result.kind === 'folder' + return result.kind === NsfObjectKind.Folder ? formatNsfFolderDetail(result.view, verbose) : formatNsfRecordDetail(result.view, verbose) } + +export function formatNsfJson(result: GetNsfResult): string { + if (result.kind === NsfObjectKind.Record) { + return formatNsfRecordJson(result.view) + } + return JSON.stringify(result.view, null, 2) +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts new file mode 100644 index 00000000..a5b5c46f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsfRecordDetails.ts @@ -0,0 +1,111 @@ +import type { Auth, Records } from '@keeper-security/keeperapi' +import { normal64Bytes, recordDetailsDataMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { decryptRecordTitleAndType } from './nsfRecordCrypto' +import { ensureNestedShareRecord, nsfToNumber, resolveNsfRecordIdentifier } from './nsfHelpers' +import { + GetNsfRecordDetailsFormat, + type GetNsfRecordDetailsFormatInput, + type GetNsfRecordDetailsInput, + type GetNsfRecordDetailsResult, + type NsfRecordDetailsItem, +} from './nsfTypes' + +function resolveRecordUids(storage: InMemoryStorage, identifiers: string[]): string[] { + if (identifiers.length === 0) { + throw new KeeperSdkError('At least one record UID or title is required.', ResultCodes.NSF_DETAILS_FAILED) + } + + return identifiers.map((identifier) => { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + return recordUid + }) +} + +async function mapRecordDetailsItem( + storage: InMemoryStorage, + auth: Auth, + item: Records.IRecordData +): Promise { + const recordUid = item.recordUid?.length ? webSafe64FromBytes(item.recordUid) : '' + if (!recordUid) return undefined + + const { title, type } = await decryptRecordTitleAndType(storage, auth, recordUid, item) + return { + recordUid, + title, + type, + revision: nsfToNumber(item.revision, 0) ?? 0, + version: item.version ?? 0, + } +} + +export function formatNsfRecordDetailsTable(result: GetNsfRecordDetailsResult): string { + const lines: string[] = [] + for (const record of result.data) { + lines.push(`Record UID: ${record.recordUid}`) + lines.push(` Title: ${record.title}`) + lines.push(` Type: ${record.type}`) + lines.push(` Version: ${record.version}`) + lines.push(` Revision: ${record.revision}`) + lines.push('') + } + if (result.forbiddenRecords.length > 0) { + lines.push(`Forbidden records: ${result.forbiddenRecords.length}`) + for (const uid of result.forbiddenRecords) { + lines.push(` ${uid}`) + } + lines.push('') + } + lines.push(`Total records retrieved: ${result.data.length}`) + return lines.join('\n').trimEnd() +} + +export function formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format: GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat.Table +): string { + if (String(format).toLowerCase() === GetNsfRecordDetailsFormat.JSON) { + return JSON.stringify(result, null, 2) + } + return formatNsfRecordDetailsTable(result) +} + +export async function getNestedShareRecordDetails( + storage: InMemoryStorage, + auth: Auth, + input: GetNsfRecordDetailsInput +): Promise { + const recordUids = resolveRecordUids(storage, input.records) + + try { + const response = await auth.executeRest( + recordDetailsDataMessage({ + recordUids: recordUids.map((uid) => normal64Bytes(uid)), + clientTime: Date.now(), + }) + ) + + const data: NsfRecordDetailsItem[] = [] + for (const item of response.data ?? []) { + const mapped = await mapRecordDetailsItem(storage, auth, item) + if (mapped) data.push(mapped) + } + + return { + data, + forbiddenRecords: (response.forbiddenRecords ?? []).map((uid) => webSafe64FromBytes(uid)), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to get nested share record details: ${extractErrorMessage(err)}`, + ResultCodes.NSF_DETAILS_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index 2edc264a..e4d87594 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -19,13 +19,17 @@ export { ensureNestedShareFolder, resolveNsfRecordIdentifier, resolveNsfFolderIdentifier, + resolveNsfFolderUidOrName, findNestedShareFoldersForRecord, checkFolderRemovePermission, checkRecordDeletePermission, + checkRecordEditPermission, + checkFolderDeletePermission, + parseNsfPath, + findExistingChildFolder, } from './nsfHelpers' export { - ListNsfFormat, listNestedShareFolders, formatListNsfTable, renderListNsfAsciiTable, @@ -33,46 +37,115 @@ export { formatListNsfJson, formatListNsfOutput, } from './listNsf' -export type { - ListNsfFormatInput, - ListNsfOptions, - ListNsfRow, - FormattedListNsfTable, -} from './listNsf' export { - GetNsfFormat, resolveNsfFolder, resolveNsfRecord, getNestedShareFolder, formatNsfFolderDetail, formatNsfRecordDetail, formatNsfDetail, + formatNsfRecordJson, + formatNsfJson, + toNsfRecordJsonView, } from './getNsf' + +export { + ListNsfFormat, + GetNsfFormat, + NsfAccessRoleLabel, + NsfObjectKind, + NsfRemoveOperation, + NsfRemoveFolderOperation, + GetNsfRecordDetailsFormat, + NSF_ACCESS_ROLE_LABELS, + resolveRecordPermissionRole, + toNsfAccessRoleLabel, +} from './nsfTypes' + export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, GetNsfFormatInput, GetNsfOptions, GetNsfResult, NsfFolderView, NsfRecordView, + NsfRecordFieldView, + NsfRecordFolderView, + NsfRecordJsonView, + NsfRecordJsonUserPermission, NsfFolderPermission, NsfFolderAccessRow, NsfRecordPermission, -} from './getNsf' + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, + RecordAddPayload, + RecordUpdatePayload, + FolderCreatePayload, + LinkNsfRecordResult, + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, + NsfRemoveFolderOperationInput, + RemoveNsfFolderInput, + NsfRemoveFolderPreviewItem, + RemoveNsfFolderResult, + GetNsfRecordDetailsFormatInput, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + NsfRecordDetailsItem, +} from './nsfTypes' export { linkNestedShareRecord } from './linkNsfRecord' -export type { LinkNsfRecordResult } from './linkNsfRecord' export { - NsfRemoveOperation, removeNestedShareRecords, formatRemoveNsfPreview, + collectRemoveNsfWarnings, } from './removeNsfRecord' -export type { - NsfRemoveOperationInput, - RemoveNsfRecordInput, - NsfRemovePreviewItem, - RemoveNsfRecordResult, -} from './removeNsfRecord' + +export { mkdirNestedShareFolder } from './mkdirNsf' +export { NSF_FOLDER_COLORS, NSF_MAX_RECORD_BATCH } from './nsfConstants' +export type { NsfFolderColor } from './nsfConstants' + +export { + removeNestedShareFolders, + formatRemoveNsfFolderPreview, +} from './removeNsfFolder' + +export { + getNestedShareRecordDetails, + formatNsfRecordDetailsTable, + formatNsfRecordDetailsOutput, +} from './getNsfRecordDetails' + +export { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' + +export { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' + +export { clearNsfRecordTypeCache } from './nsfRecordTypes' + +export { + buildNsfRecordData, + parseNsfFieldInput, + parseNsfFieldSpaceInput, + resolveNsfFieldValue, + type ParsedNsfFields, + type RecordFieldEntry, +} from './nsfRecordData' export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts index e277ffad..d00aea38 100644 --- a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -16,6 +16,7 @@ import { resolveNsfFolderIdentifier, resolveNsfRecordIdentifier, } from './nsfHelpers' +import type { LinkNsfRecordResult } from './nsfTypes' function resolveRecordKeyType( storage: InMemoryStorage, @@ -34,14 +35,6 @@ function resolveRecordKeyType( } } -export type LinkNsfRecordResult = { - success: boolean - recordUid: string - folderUid: string - status: string - message: string -} - async function buildRecordMetadata( storage: InMemoryStorage, folderUid: string, diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts index e4ff8d2c..0e21e178 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -6,6 +6,7 @@ import { getKeeperDriveRecords, getRecordDescription, normalizeParentUid, + resolveKeeperDriveRootParentUid, } from './nsfHelpers' import { getRecordTitle, getRecordType } from '../records/RecordUtils' import { @@ -14,40 +15,24 @@ import { NSF_LIST_MIN_TRUNCATE_PREFIX, NSF_LIST_TABLE_HEADERS, } from './nsfConstants' - -export enum ListNsfFormat { - Table = 'table', - CSV = 'csv', - JSON = 'json', -} - -export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}` - -export type ListNsfOptions = { - folders?: boolean - records?: boolean - format?: ListNsfFormatInput -} - -export type ListNsfRow = { - itemType: NsfItemType - uid: string - title: string - type: string - description: string - parentOrFolder: string -} - -export type FormattedListNsfTable = { - headers: string[] - rows: string[][] -} +import { + ListNsfFormat, + type FormattedListNsfTable, + type ListNsfFormatInput, + type ListNsfOptions, + type ListNsfRow, +} from './nsfTypes' function compareRows(a: ListNsfRow, b: ListNsfRow): number { const typeCompare = a.itemType.localeCompare(b.itemType) return typeCompare !== 0 ? typeCompare : a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) } +function resolveListParentOrFolder(storage: InMemoryStorage, value: string): string { + if (value !== 'root') return value + return resolveKeeperDriveRootParentUid(storage) ?? value +} + function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { return getKeeperDriveFolders(storage).map((folder) => ({ itemType: NsfItemType.Folder, @@ -55,7 +40,7 @@ function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { title: folder.data.name || 'Unnamed', type: '', description: '', - parentOrFolder: normalizeParentUid(storage, folder.parentUid), + parentOrFolder: resolveListParentOrFolder(storage, normalizeParentUid(storage, folder.parentUid)), })) } @@ -66,7 +51,10 @@ function collectRecordRows(storage: InMemoryStorage): ListNsfRow[] { title: getRecordTitle(record), type: getRecordType(record), description: getRecordDescription(record), - parentOrFolder: findRecordFolderLocation(storage, record.uid) || 'root', + parentOrFolder: resolveListParentOrFolder( + storage, + findRecordFolderLocation(storage, record.uid) || 'root' + ), })) } @@ -146,19 +134,20 @@ export function formatListNsfCsv(rows: ListNsfRow[]): string { return lines.join('\n') } +function toListNsfJsonRow(row: ListNsfRow): Record { + const out: Record = { + item_type: row.itemType, + uid: row.uid, + title: row.title, + parent_or_folder: row.parentOrFolder, + } + if (row.type) out.type = row.type + if (row.description) out.description = row.description + return out +} + export function formatListNsfJson(rows: ListNsfRow[]): string { - return JSON.stringify( - rows.map((row) => ({ - item_type: row.itemType, - uid: row.uid, - title: row.title, - type: row.type, - description: row.description, - parent_or_folder: row.parentOrFolder, - })), - null, - 2 - ) + return JSON.stringify(rows.map(toListNsfJsonRow), null, 2) } export function formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = ListNsfFormat.Table): string { diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts new file mode 100644 index 00000000..b8350d7b --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -0,0 +1,224 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + folderAddMessage, + generateEncryptionKey, + generateUid, + normal64Bytes, + platform, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_FOLDER_COLORS, type NsfFolderColor } from './nsfConstants' +import { + buildFolderOwnerInfo, + cacheNewNsfFolder, + findExistingChildFolder, + isNestedShareFolder, + parseFolderModifyStatus, + parseNsfPath, + requireAuthDataKey, + resolveKeeperDriveParentUid, +} from './nsfHelpers' +import type { + FolderCreatePayload, + FolderSegmentCreateSpec, + MkdirNsfInput, + MkdirNsfResult, + NsfFolderColorInput, +} from './nsfTypes' + +type NsfFolderMetadata = { + name: string + color?: string +} + +function normalizeColor(color?: NsfFolderColorInput): NsfFolderColor | undefined { + if (!color) return undefined + if (!NSF_FOLDER_COLORS.some((candidate) => candidate === color)) { + throw new KeeperSdkError( + `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(', ')}.`, + ResultCodes.NSF_MKDIR_FAILED + ) + } + return color +} + +function resolveBaseFolderUid( + storage: InMemoryStorage, + baseFolderUid: string | null | undefined +): string | null { + if (!baseFolderUid) return null + return isNestedShareFolder(storage, baseFolderUid) ? baseFolderUid : null +} + +async function resolveFolderKeyEncryptionKey( + storage: InMemoryStorage, + auth: Auth, + parentUid: string | null +): Promise { + if (parentUid) { + const parentKey = await storage.getKeyBytes(parentUid) + if (parentKey) return parentKey + } + return requireAuthDataKey(auth) +} + +async function buildFolderCreatePayload( + storage: InMemoryStorage, + auth: Auth, + folderName: string, + parentUid: string | null, + color: NsfFolderColor | undefined, + inheritPermissions: boolean +): Promise { + const folderUid = generateUid() + const folderKey = generateEncryptionKey() + await storage.saveKeyBytes(folderUid, folderKey) + + const metadata: NsfFolderMetadata = { name: folderName } + if (color && color !== 'none') metadata.color = color + + const resolvedParentUid = resolveKeeperDriveParentUid(storage, parentUid) + const encryptedData = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(metadata)), + folderKey + ) + const encryptionKey = await resolveFolderKeyEncryptionKey(storage, auth, resolvedParentUid) + const encryptedFolderKey = await platform.aesGcmEncrypt(folderKey, encryptionKey) + + return { + folderUid, + folderName, + parentUid, + inheritPermissions, + folderData: Folder.FolderData.create({ + folderUid: normal64Bytes(folderUid), + parentUid: resolvedParentUid ? normal64Bytes(resolvedParentUid) : undefined, + data: encryptedData, + folderKey: encryptedFolderKey, + type: Folder.FolderUsageType.UT_NORMAL, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + ownerInfo: buildFolderOwnerInfo(auth), + }), + } +} + +async function createFolderSegmentsBatch( + storage: InMemoryStorage, + auth: Auth, + segments: FolderSegmentCreateSpec[], + parentUid: string | null +): Promise<{ folderUid: string }> { + let currentParentUid = parentUid + const payloads: FolderCreatePayload[] = [] + + for (const segment of segments) { + const payload = await buildFolderCreatePayload( + storage, + auth, + segment.segmentName, + currentParentUid, + segment.color, + segment.inheritPermissions + ) + payloads.push(payload) + currentParentUid = payload.folderUid + } + + const response = await auth.executeRest( + folderAddMessage({ folderData: payloads.map((entry) => entry.folderData) }) + ) + + for (let index = 0; index < payloads.length; index++) { + const payload = payloads[index] + parseFolderModifyStatus(response.folderAddResults?.[index], ResultCodes.NSF_MKDIR_FAILED) + await cacheNewNsfFolder( + storage, + auth, + payload.folderUid, + payload.folderName, + payload.parentUid, + payload.inheritPermissions + ) + } + + return { folderUid: payloads[payloads.length - 1].folderUid } +} + +export async function mkdirNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: MkdirNsfInput +): Promise { + const folderPath = (input.folder ?? '').trim() + if (!folderPath) { + throw new KeeperSdkError('Folder name is required.', ResultCodes.NSF_MKDIR_FAILED) + } + + const color = normalizeColor(input.color) + const inheritPermissions = !input.noInheritPermissions + const segments = parseNsfPath(folderPath) + let parentUid: string | null = resolveBaseFolderUid(storage, input.baseFolderUid) + const lastIdx = segments.length - 1 + let createdUid: string | undefined + const pendingSegments: FolderSegmentCreateSpec[] = [] + + const flushPendingSegments = async (): Promise => { + if (pendingSegments.length === 0) return + const result = await createFolderSegmentsBatch(storage, auth, pendingSegments, parentUid) + createdUid = result.folderUid + parentUid = result.folderUid + pendingSegments.length = 0 + } + + try { + for (let idx = 0; idx < segments.length; idx++) { + const segment = segments[idx] + const isLeaf = idx === lastIdx + const existingUid = findExistingChildFolder(storage, segment, parentUid) + + if (existingUid) { + await flushPendingSegments() + if (isLeaf) { + return { + folderUid: existingUid, + created: false, + message: `Folder "${segment}" already exists.`, + } + } + parentUid = existingUid + continue + } + + pendingSegments.push({ + segmentName: segment, + color: isLeaf ? color : undefined, + inheritPermissions: isLeaf ? inheritPermissions : true, + }) + } + + await flushPendingSegments() + + if (!createdUid) { + throw new KeeperSdkError('Folder creation did not return a UID.', ResultCodes.NSF_MKDIR_FAILED) + } + + return { + folderUid: createdUid, + created: true, + message: + segments.length > 1 + ? `Created folder path "${folderPath}".` + : `Created folder "${segments[lastIdx]}".`, + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to create nested share folder: ${extractErrorMessage(err)}`, + ResultCodes.NSF_MKDIR_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index ea667a20..b8d09c45 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -6,16 +6,7 @@ export const NSF_LEGACY_RECORD_MSG = export const NSF_LEGACY_FOLDER_MSG = "Folder '{0}' is a legacy folder. Nested Share Folder commands operate only on Nested Share Folders." -export const NSF_ACCESS_ROLE_LABELS: Record = { - [Folder.AccessRoleType.NAVIGATOR]: 'navigator', - [Folder.AccessRoleType.REQUESTOR]: 'requestor', - [Folder.AccessRoleType.VIEWER]: 'viewer', - [Folder.AccessRoleType.SHARED_MANAGER]: 'shared-manager', - [Folder.AccessRoleType.CONTENT_MANAGER]: 'content-manager', - [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: 'content-share-manager', - [Folder.AccessRoleType.MANAGER]: 'manager', - [Folder.AccessRoleType.UNRESOLVED]: 'unresolved', -} +export const NSF_LEGACY_RECORD_TYPES = new Set(['legacy', 'general']) export const NSF_ACCESS_TYPE_LABELS: Record = { [Folder.AccessType.AT_USER]: 'user', @@ -35,9 +26,9 @@ export const NSF_RECORD_DESCRIPTION_MAX_LENGTH = 120 export const NSF_MASKED_VALUE = '********' export const NSF_FOLDER_LABEL_WIDTH = 22 export const NSF_RECORD_LABEL_WIDTH = 17 -export const NSF_FOLDER_USER_PERMISSIONS_HEADING = 'User Permissions:' +export const NSF_USER_PERMISSIONS_HEADING = 'User Permissions:' export const NSF_FOLDER_SHARE_ADMINS_HEADING = 'Share Administrators:' -export const NSF_RECORD_USER_PERMISSIONS_HEADING = 'User Permissions:' +export const NSF_SHARE_ADMINS_PREVIEW_LIMIT = 10 export const NSF_LIST_TABLE_HEADERS = ['#', 'Item Type', 'UID', 'Title', 'Type', 'Description'] as const export const NSF_LIST_FULL_HEADERS = [ @@ -51,4 +42,46 @@ export const NSF_LIST_FULL_HEADERS = [ export const NSF_LIST_DEFAULT_COLUMN_WIDTH = 40 export const NSF_LIST_MIN_TRUNCATE_PREFIX = 3 -export const NSF_MAX_REMOVALS = 500 +export const NSF_MAX_RECORD_BATCH = 500 +export const NSF_MAX_FOLDER_REMOVALS = 100 + +export const NSF_PATH_SENTINEL = '\x00' + +export const NSF_FOLDER_COLORS = ['none', 'red', 'orange', 'yellow', 'green', 'blue', 'gray'] as const +export type NsfFolderColor = (typeof NSF_FOLDER_COLORS)[number] + + +export const NSF_KNOWN_FIELD_TYPES = new Set([ + 'login', + 'password', + 'url', + 'email', + 'text', + 'multiline', + 'secret', + 'note', + 'onetimecode', + 'date', + 'phone', + 'name', + 'address', + 'paymentcard', + 'bankaccount', + 'securityquestion', + 'host', + 'keypair', + 'fileref', + 'passkey', + 'pincode', +]) + + +export const NSF_STRUCTURED_SUBKEYS: Record> = { + name: new Set(['first', 'middle', 'last']), + host: new Set(['hostName', 'port', 'host']), + address: new Set(['street1', 'street2', 'city', 'state', 'zip', 'country', 'address']), + paymentcard: new Set(['cardNumber', 'cardExpirationDate', 'cardSecurityCode', 'cardPin']), + bankaccount: new Set(['accountNumber', 'routingNumber', 'accountType']), + securityquestion: new Set(['question', 'answer']), + phone: new Set(['number', 'region', 'type', 'ext']), +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 67b5a171..36ca1de2 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,4 +1,5 @@ import type { + Auth, DRecord, DUser, DKdFolder, @@ -6,20 +7,29 @@ import type { DKdFolderRecord, DKdRecordAccess, } from '@keeper-security/keeperapi' -import { Folder, webSafe64FromBytes } from '@keeper-security/keeperapi' +import { + Folder, + Records, + getFolderAccessMessage, + getRecordAccessMessage, + getShareObjectsMessage, + normal64Bytes, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { VaultObjectKind } from '../folders/folderHelpers' -import { KeeperSdkError, ResultCodes } from '../utils' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' import { getRecordTitle } from '../records/RecordUtils' import { - NSF_ACCESS_ROLE_LABELS, NSF_ACCESS_TYPE_LABELS, NSF_LEGACY_FOLDER_MSG, NSF_LEGACY_RECORD_MSG, NSF_NOTE_FIELD_TYPES, + NSF_PATH_SENTINEL, NSF_RECORD_DESCRIPTION_MAX_LENGTH, NSF_SENSITIVE_FIELD_TYPES, } from './nsfConstants' +import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS } from './nsfTypes' export enum KeeperDriveKind { Folder = 'keeper_drive_folder', @@ -33,6 +43,79 @@ 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 = + status === Records.RecordModifyResult.RS_SUCCESS + ? 'SUCCESS' + : (Records.RecordModifyResult[status] ?? String(status)) + if (status !== Records.RecordModifyResult.RS_SUCCESS) { + throw new KeeperSdkError( + result.message || `Record operation failed (${statusName}).`, + failureCode + ) + } + return { + statusName, + message: result.message || 'Record operation succeeded', + } +} + export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string): boolean { return !!getKeeperDriveRecord(storage, recordUid) } @@ -41,7 +124,6 @@ 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)) { @@ -174,6 +256,156 @@ 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 byUid = getKeeperDriveFolder(storage, segment) + if (byUid) { + if (parentUid == null || parentUid === '') { + return segment + } + if (normalizeParentUid(storage, byUid.parentUid) === normalizeParentUid(storage, parentUid)) { + return segment + } + return undefined + } + + const lower = segment.toLowerCase() + const exactMatches: string[] = [] + const rootMatches: string[] = [] + + 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) @@ -298,9 +530,6 @@ export function checkFolderRemovePermission( accountUid: Uint8Array ): void { if (hasFolderPermission(storage, folderUid, username, accountUid, 'canRemove')) return - // Folder-trash and unlink are less destructive than owner-trash. Allow when the user - // can permanently delete the record, or owns it without explicit record-access entries - // (common for records in a personal drive root with no folder-access sync data). if (canRecordBeDeleted(storage, recordUid, username, accountUid, folderUid)) return throw new KeeperSdkError( 'You do not have permission to remove records from this folder.', @@ -322,9 +551,33 @@ export function checkRecordDeletePermission( ) } -export function formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): string { - if (role == null) return 'unknown' - return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}` +export function checkRecordEditPermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array +): void { + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getRecordAccessEntries(storage, recordUid) + if (entries.length === 0) return + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue + if (entry.owner || entry.canEdit) return + throw new KeeperSdkError( + 'You do not have permission to edit this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to edit this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): NsfAccessRoleLabel { + if (role == null) return NsfAccessRoleLabel.Unknown + return NSF_ACCESS_ROLE_LABELS[role] ?? NsfAccessRoleLabel.Unknown } export function formatAccessType(type: Folder.AccessType | null | undefined): string { @@ -422,8 +675,12 @@ export function isSensitiveFieldType(fieldType: string): boolean { export function resolveAccessUsername( storage: InMemoryStorage, accessTypeUid: string, - folder?: DKdFolder + folder?: DKdFolder, + shareUsers?: Map ): string { + const fromShare = shareUsers?.get(accessTypeUid) + if (fromShare) return fromShare + for (const user of storage.getAll('user')) { if (webSafe64FromBytes(user.accountUid) === accessTypeUid) { return user.username @@ -435,8 +692,137 @@ export function resolveAccessUsername( return accessTypeUid } -export function folderAccessDisplayRole(entry: DKdFolderAccess): string { - if (entry.accessType === Folder.AccessType.AT_OWNER) return 'owner' +function toFolderAccessEntry(folderUid: string, accessor: Folder.IFolderAccessData): DKdFolderAccess { + return { + kind: 'keeper_drive_folder_access', + accessUid: `${folderUid}:${webSafe64FromBytes(accessor.accessTypeUid!)}`, + folderUid, + accessTypeUid: webSafe64FromBytes(accessor.accessTypeUid!), + accessType: accessor.accessType!, + accessRoleType: accessor.accessRoleType!, + permission: accessor.permissions ?? {}, + inherited: accessor.inherited ?? undefined, + hidden: accessor.hidden ?? undefined, + } +} + +export async function loadShareUserMap(auth: Auth, storage: InMemoryStorage): Promise> { + const map = new Map() + + for (const user of storage.getAll('user')) { + if (user.accountUid?.length && user.username) { + map.set(webSafe64FromBytes(user.accountUid), user.username) + } + } + + try { + const response = await auth.executeRest(getShareObjectsMessage()) + for (const list of [ + response.shareEnterpriseUsers, + response.shareFamilyUsers, + response.shareRelationships, + response.shareMCEnterpriseUsers, + ]) { + for (const entry of list ?? []) { + if (entry.userAccountUid?.length && entry.username) { + map.set(webSafe64FromBytes(entry.userAccountUid), entry.username) + } + } + } + } catch { + } + + return map +} + +export async function fetchLiveFolderAccessEntries(auth: Auth, folderUid: string): Promise { + try { + const response = await auth.executeRest( + getFolderAccessMessage({ folderUid: [normal64Bytes(folderUid)] }) + ) + const result = response.folderAccessResults?.find( + (entry) => entry.folderUid?.length && webSafe64FromBytes(entry.folderUid) === folderUid + ) + return (result?.accessors ?? []) + .filter( + (accessor) => + accessor.accessTypeUid?.length && + accessor.accessType != null && + accessor.accessRoleType != null + ) + .map((accessor) => toFolderAccessEntry(folderUid, accessor)) + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch folder permissions for ${folderUid}: ${extractErrorMessage(err)}`, + ResultCodes.NSF_DETAILS_FAILED + ) + } +} + +export function isFolderOwnerAccessor( + folder: DKdFolder, + entry: DKdFolderAccess, + username: string +): boolean { + const ownerUsername = folder.ownerInfo?.username?.trim().toLowerCase() + if (ownerUsername && username.toLowerCase() === ownerUsername) return true + if (folder.ownerInfo?.accountUid && folder.ownerInfo.accountUid === entry.accessTypeUid) return true + return entry.accessType === Folder.AccessType.AT_OWNER +} + +export function recordAccessDisplayRole(data: Folder.IRecordAccessData): NsfAccessRoleLabel { + return formatAccessRoleType(data.accessRoleType) +} + +export async function fetchLiveRecordAccessEntries( + auth: Auth, + storage: InMemoryStorage, + recordUid: string +): Promise< + { + username: string + accountUid?: string + data: Folder.IRecordAccessData + }[] +> { + try { + const [response, shareUsers] = await Promise.all([ + auth.executeRest(getRecordAccessMessage({ recordUids: [normal64Bytes(recordUid)] })), + loadShareUserMap(auth, storage), + ]) + + return (response.recordAccesses ?? []) + .filter((entry) => { + const accessType = entry.data?.accessType + return ( + accessType === Folder.AccessType.AT_USER || + accessType === Folder.AccessType.AT_OWNER || + !!entry.data?.owner + ) + }) + .map((entry) => { + const data = entry.data + if (!data?.accessTypeUid?.length || data.accessType == null) return undefined + + const accountUid = webSafe64FromBytes(data.accessTypeUid) + const username = + entry.accessorInfo?.name?.trim() || + shareUsers.get(accountUid) || + resolveAccessUsername(storage, accountUid) + + return { username, accountUid, data } + }) + .filter((entry): entry is NonNullable => !!entry && entry.username.length > 0) + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch record permissions for ${recordUid}: ${extractErrorMessage(err)}`, + ResultCodes.NSF_DETAILS_FAILED + ) + } +} + +export function folderAccessDisplayRole(entry: DKdFolderAccess): NsfAccessRoleLabel { + if (entry.accessType === Folder.AccessType.AT_OWNER) return NsfAccessRoleLabel.Owner return formatAccessRoleType(entry.accessRoleType) } @@ -455,4 +841,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..ffb33c22 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordCrypto.ts @@ -0,0 +1,110 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Records, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { extractErrorMessage, logger } from '../utils' +import { findNestedShareFoldersForRecord } from './nsfHelpers' + +const UNKNOWN_RECORD_LABEL = 'Unknown' + +function decodePayload(value: string | Uint8Array | null | undefined): Uint8Array { + if (!value) return new Uint8Array(0) + if (value instanceof Uint8Array) return value + return normal64Bytes(value) +} + +async function decryptWithFolderKeys( + storage: InMemoryStorage, + recordUid: string, + encryptedKey: Uint8Array +): Promise { + for (const folderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + const folderKey = await storage.getKeyBytes(folderUid) + if (!folderKey) continue + try { + return await platform.aesGcmDecrypt(encryptedKey, folderKey) + } catch (gcmErr) { + try { + const recordKey = await platform.aesCbcDecrypt(encryptedKey, folderKey, true) + logger.debug( + `NSF record key for ${recordUid} decrypted with CBC via folder ${folderUid} after GCM failed: ${extractErrorMessage(gcmErr)}` + ) + return recordKey + } catch (cbcErr) { + logger.debug( + `NSF record key decrypt failed for record ${recordUid} with folder ${folderUid}: ${extractErrorMessage(cbcErr)} (GCM: ${extractErrorMessage(gcmErr)})` + ) + continue + } + } + } + logger.debug(`NSF record key decrypt failed for ${recordUid}: no folder key succeeded`) + return undefined +} + +export async function resolveRecordKeyBytes( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + encryptedKey?: Uint8Array | null, + keyType?: Records.RecordKeyType | null +): Promise { + const cached = await storage.getKeyBytes(recordUid) + if (cached) return cached + + if (!encryptedKey?.length || !auth.dataKey) { + return decryptWithFolderKeys(storage, recordUid, encryptedKey ?? new Uint8Array(0)) + } + + try { + if (keyType === Records.RecordKeyType.ENCRYPTED_BY_DATA_KEY) { + return await platform.aesCbcDecrypt(encryptedKey, auth.dataKey, true) + } + return await platform.aesGcmDecrypt(encryptedKey, auth.dataKey) + } catch (err) { + logger.debug( + `NSF record key decrypt with data key failed for ${recordUid}, trying folder keys: ${extractErrorMessage(err)}` + ) + return decryptWithFolderKeys(storage, recordUid, encryptedKey) + } +} + +export async function decryptRecordTitleAndType( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + recordData: Records.IRecordData +): Promise<{ title: string; type: string }> { + const uid = recordData.recordUid?.length ? webSafe64FromBytes(recordData.recordUid) : recordUid + const recordKey = await resolveRecordKeyBytes( + storage, + auth, + uid, + recordData.recordKey, + recordData.recordKeyType + ) + if (!recordKey) { + logger.debug(`NSF record key unavailable for ${uid}`) + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } + } + + const encryptedData = decodePayload(recordData.encryptedRecordData) + if (!encryptedData.length) { + logger.debug(`NSF record data empty for ${uid}`) + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } + } + + try { + const decrypted = await platform.aesGcmDecrypt(encryptedData, recordKey) + const parsed = JSON.parse(platform.bytesToString(decrypted).replace(/\s+$/, '')) as { + title?: string + type?: string + } + return { + title: parsed.title?.trim() || UNKNOWN_RECORD_LABEL, + type: parsed.type?.trim() || UNKNOWN_RECORD_LABEL, + } + } catch (err) { + logger.debug(`NSF record title/type decrypt failed for ${uid}: ${extractErrorMessage(err)}`) + return { title: UNKNOWN_RECORD_LABEL, type: UNKNOWN_RECORD_LABEL } + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts new file mode 100644 index 00000000..b664d279 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordData.ts @@ -0,0 +1,385 @@ +import { platform } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_KNOWN_FIELD_TYPES, NSF_LEGACY_RECORD_TYPES, NSF_STRUCTURED_SUBKEYS } from './nsfConstants' + +const MIN_RECORD_PAD_BYTES = 384 +const PAD_BLOCK_SIZE = 16 + +export type RecordFieldEntry = { + type: string + label?: string + value: unknown[] +} + +export type BuildNsfRecordDataInput = { + title: string + recordType: string + notes?: string + fieldEntries?: RecordFieldEntry[] + customEntries?: RecordFieldEntry[] +} + +export type ParsedNsfFields = { + fieldEntries: RecordFieldEntry[] + customEntries: RecordFieldEntry[] + hasFileFields: boolean +} + +type NsfFieldValue = string | string[] | Record | Record[] + +type FieldAssignment = { + section: 'fields' | 'custom' + fieldType: string + fieldLabel: string + value: NsfFieldValue +} + +type ParsedFieldKey = { + section: 'fields' | 'custom' + fieldType: string + fieldLabel: string +} + +function stripSurroundingQuotes(value: string): string { + const trimmed = value.trim() + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1) + } + return trimmed +} + +export function resolveNsfFieldValue(raw: string): NsfFieldValue { + const trimmed = stripSurroundingQuotes(raw) + if (trimmed.startsWith('$JSON')) { + let jsonStr = trimmed.slice(5) + if (jsonStr.startsWith(':')) jsonStr = jsonStr.slice(1) + try { + return JSON.parse(jsonStr) as Record + } catch (err) { + throw new KeeperSdkError( + `Invalid $JSON value: ${extractErrorMessage(err)}`, + ResultCodes.NSF_ADD_FAILED + ) + } + } + return trimmed +} + +function toFieldValueArray(value: NsfFieldValue): unknown[] { + if (Array.isArray(value)) return value + if (typeof value === 'object' && value !== null) return [value] + return [value] +} + +function cloneFieldEntries(entries: unknown): RecordFieldEntry[] { + if (!Array.isArray(entries)) return [] + return (entries as RecordFieldEntry[]).map((field) => ({ + type: field.type ?? '', + label: field.label, + value: Array.isArray(field.value) ? [...field.value] : [], + })) +} + +function fieldEntryKey(field: RecordFieldEntry): string { + return `${field.type}\0${field.label ?? ''}` +} + +function isStructuredSubkey(fieldType: string, fieldLabel: string): boolean { + return !!NSF_STRUCTURED_SUBKEYS[fieldType]?.has(fieldLabel) +} + +function parseFieldKey(rawKey: string): ParsedFieldKey { + let key = rawKey.trim() + let section: 'fields' | 'custom' = 'fields' + + if (key.startsWith('c.')) { + section = 'custom' + key = key.slice(2) + } else if (key.startsWith('f.')) { + key = key.slice(2) + } + + const quoted = key.match(/^"(.+)"$/) + if (quoted) { + return { section: 'custom', fieldType: 'text', fieldLabel: quoted[1] } + } + + const dotIdx = key.indexOf('.') + if (dotIdx > 0) { + const fieldType = key.slice(0, dotIdx).toLowerCase() + const fieldLabel = key.slice(dotIdx + 1) + if (NSF_KNOWN_FIELD_TYPES.has(fieldType)) { + return { section, fieldType, fieldLabel } + } + return { section: 'custom', fieldType: 'text', fieldLabel: key } + } + + const fieldType = key.toLowerCase() + if (NSF_KNOWN_FIELD_TYPES.has(fieldType)) { + return { section: 'fields', fieldType, fieldLabel: '' } + } + + return { section: 'custom', fieldType: 'text', fieldLabel: key } +} + +function splitFieldTokens(input: string, delimiter: 'comma' | 'space'): string[] { + const result: string[] = [] + let current = '' + let inSingleQuote = false + let inDoubleQuote = false + let braceDepth = 0 + + for (let i = 0; i < input.length; i++) { + const ch = input[i] + const prev = i > 0 ? input[i - 1] : '' + + if (ch === "'" && !inDoubleQuote && prev !== '\\') { + inSingleQuote = !inSingleQuote + current += ch + continue + } + if (ch === '"' && !inSingleQuote && prev !== '\\') { + inDoubleQuote = !inDoubleQuote + current += ch + continue + } + if (!inSingleQuote && !inDoubleQuote) { + if (ch === '{') braceDepth++ + else if (ch === '}') braceDepth = Math.max(0, braceDepth - 1) + else if (braceDepth === 0) { + const isDelimiter = delimiter === 'comma' ? ch === ',' : /\s/.test(ch) + if (isDelimiter) { + const token = current.trim() + if (token) result.push(token) + current = '' + if (delimiter === 'space') { + while (i + 1 < input.length && /\s/.test(input[i + 1])) i++ + } + continue + } + } + } + current += ch + } + + const token = current.trim() + if (token) result.push(token) + + if (inSingleQuote) { + throw new KeeperSdkError('Unclosed single quote in field input.', ResultCodes.NSF_ADD_FAILED) + } + if (inDoubleQuote) { + throw new KeeperSdkError('Unclosed double quote in field input.', ResultCodes.NSF_ADD_FAILED) + } + if (braceDepth > 0) { + throw new KeeperSdkError('Unclosed "{" in field input.', ResultCodes.NSF_ADD_FAILED) + } + + return result +} + +function parseFieldToken(raw: string, position: number): FieldAssignment | 'file' | undefined { + const field = raw.trim() + if (!field) return undefined + + const separator = field.indexOf('=') + if (separator <= 0) { + throw new KeeperSdkError( + `Invalid field at position ${position}: "${field}" (expected key=value format).`, + ResultCodes.NSF_ADD_FAILED + ) + } + + const key = field.slice(0, separator).trim() + const value = field.slice(separator + 1).trim() + if (!key) { + throw new KeeperSdkError( + `Invalid field at position ${position}: "${field}" (missing field key).`, + ResultCodes.NSF_ADD_FAILED + ) + } + if (key.toLowerCase() === 'file') return 'file' + + const parsedKey = parseFieldKey(key) + try { + return { + section: parsedKey.section, + fieldType: parsedKey.fieldType, + fieldLabel: parsedKey.fieldLabel, + value: resolveNsfFieldValue(value), + } + } catch (err) { + if (err instanceof KeeperSdkError) { + throw new KeeperSdkError( + `Invalid field at position ${position} for key "${key}": ${err.message}`, + err.resultCode + ) + } + throw err + } +} + +function wrapPlainStructuredValue(fieldType: string, value: string): Record { + if (fieldType === 'address') return { street1: value } + if (fieldType === 'phone') return { number: value, type: 'Mobile' } + if (fieldType === 'name') return { first: value } + return { value } +} + +function buildRecordFieldEntries(assignments: FieldAssignment[]): { + fields: RecordFieldEntry[] + custom: RecordFieldEntry[] +} { + const custom: RecordFieldEntry[] = [] + const labeledFields: RecordFieldEntry[] = [] + const structuredByType = new Map>() + const simpleByType = new Map() + + for (const { section, fieldType, fieldLabel, value } of assignments) { + if (section === 'custom') { + custom.push({ + type: fieldType, + label: fieldLabel, + value: toFieldValueArray(value), + }) + continue + } + + if (fieldLabel && isStructuredSubkey(fieldType, fieldLabel)) { + const existing = structuredByType.get(fieldType) ?? {} + existing[fieldLabel] = typeof value === 'string' ? value : value + structuredByType.set(fieldType, existing) + continue + } + + if (fieldLabel) { + labeledFields.push({ + type: fieldType, + label: fieldLabel, + value: toFieldValueArray(value), + }) + continue + } + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + structuredByType.set(fieldType, value as Record) + continue + } + + simpleByType.set(fieldType, value) + } + + const fields: RecordFieldEntry[] = [] + for (const [fieldType, value] of simpleByType) { + if (typeof value === 'string' && NSF_STRUCTURED_SUBKEYS[fieldType]) { + fields.push({ type: fieldType, value: [wrapPlainStructuredValue(fieldType, value)] }) + continue + } + fields.push({ type: fieldType, value: toFieldValueArray(value) }) + } + for (const [fieldType, objectValue] of structuredByType) { + fields.push({ type: fieldType, value: [objectValue] }) + } + fields.push(...labeledFields) + + return { fields, custom } +} + +function mergeFieldEntries( + existing: RecordFieldEntry[], + incoming: RecordFieldEntry[] +): RecordFieldEntry[] { + const merged = new Map() + for (const field of existing) { + merged.set(fieldEntryKey(field), { + type: field.type ?? '', + label: field.label, + value: Array.isArray(field.value) ? [...field.value] : [], + }) + } + for (const field of incoming) { + merged.set(fieldEntryKey(field), { + type: field.type, + label: field.label, + value: [...field.value], + }) + } + return [...merged.values()] +} + +function parseNsfFields(rawFields: string[]): ParsedNsfFields { + let hasFileFields = false + const assignments: FieldAssignment[] = [] + + for (let i = 0; i < rawFields.length; i++) { + const parsed = parseFieldToken(rawFields[i], i + 1) + if (!parsed) continue + if (parsed === 'file') { + hasFileFields = true + continue + } + assignments.push(parsed) + } + + const { fields, custom } = buildRecordFieldEntries(assignments) + return { fieldEntries: fields, customEntries: custom, hasFileFields } +} + +export function getPaddedJsonBytes(data: Record): Uint8Array { + const json = JSON.stringify(data) + const paddedLength = Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * PAD_BLOCK_SIZE + return platform.stringToBytes(json.padEnd(paddedLength, ' ')) +} + +export function buildNsfRecordData(input: BuildNsfRecordDataInput): Record { + const title = input.title.trim() + const recordType = input.recordType.trim() + const data: Record = { + type: NSF_LEGACY_RECORD_TYPES.has(recordType) ? 'login' : recordType, + title, + fields: input.fieldEntries ?? [], + custom: input.customEntries ?? [], + } + + const notes = input.notes?.trim() + if (notes) data.notes = notes + return data +} + +export function mergeNsfRecordData( + existing: Record, + input: Partial +): Record { + const data: Record = { + ...existing, + fields: cloneFieldEntries(existing.fields), + custom: cloneFieldEntries(existing.custom), + } + + if (input.title !== undefined) data.title = input.title.trim() + if (input.recordType !== undefined) { + const recordType = input.recordType.trim() + data.type = NSF_LEGACY_RECORD_TYPES.has(recordType) ? 'login' : recordType + } + if (input.notes !== undefined) data.notes = input.notes + + if (input.fieldEntries?.length) { + data.fields = mergeFieldEntries(data.fields as RecordFieldEntry[], input.fieldEntries) + } + if (input.customEntries?.length) { + data.custom = mergeFieldEntries(data.custom as RecordFieldEntry[], input.customEntries) + } + + return data +} + +export function parseNsfFieldInput(input: string): ParsedNsfFields { + return parseNsfFields(splitFieldTokens(input, 'comma')) +} + +export function parseNsfFieldSpaceInput(input: string): ParsedNsfFields { + return parseNsfFields(splitFieldTokens(input, 'space')) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts new file mode 100644 index 00000000..2d95a7cf --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordTypes.ts @@ -0,0 +1,95 @@ +import type { Auth, Records } from '@keeper-security/keeperapi' +import { recordTypesGetMessage } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_LEGACY_RECORD_TYPES } from './nsfConstants' + +type RecordTypeSchema = { + id?: string + fields?: unknown[] +} + +const RECORD_TYPE_CACHE_TTL_MS = 5 * 60 * 1000 + +let cachedAuth: Auth | undefined +let cachedRecordTypes: Records.IRecordType[] | undefined +let cachedAt = 0 + +export function clearNsfRecordTypeCache(): void { + cachedAuth = undefined + cachedRecordTypes = undefined + cachedAt = 0 +} + +function parseRecordTypeSchema(content: string): RecordTypeSchema | undefined { + try { + const parsed = JSON.parse(content) as Record + if (typeof parsed !== 'object' || parsed === null) return undefined + const id = typeof parsed.$id === 'string' ? parsed.$id : undefined + const fields = Array.isArray(parsed.fields) ? parsed.fields : undefined + return { id, fields } + } catch { + return undefined + } +} + +async function loadRecordTypes(auth: Auth): Promise { + const now = Date.now() + if (cachedAuth === auth && cachedRecordTypes && now - cachedAt < RECORD_TYPE_CACHE_TTL_MS) { + return cachedRecordTypes + } + + try { + const response = await auth.executeRest( + recordTypesGetMessage({ + standard: true, + user: true, + enterprise: true, + pam: true, + }) + ) + cachedRecordTypes = response.recordTypes ?? [] + cachedAuth = auth + cachedAt = now + return cachedRecordTypes + } catch (err) { + throw new KeeperSdkError( + `Failed to load record types: ${extractErrorMessage(err)}`, + ResultCodes.NSF_ADD_FAILED + ) + } +} + +export async function getNsfRecordTypeFields( + auth: Auth, + recordType: string +): Promise { + const normalized = recordType.trim() + if (!normalized || NSF_LEGACY_RECORD_TYPES.has(normalized)) return undefined + + const types = await loadRecordTypes(auth) + for (const entry of types) { + if (!entry.content) continue + const schema = parseRecordTypeSchema(entry.content) + if (schema?.id === normalized && schema.fields?.length) { + return schema.fields + } + } + return undefined +} + +export async function validateNsfRecordType( + auth: Auth, + recordType: string, + resultCode: string = ResultCodes.NSF_ADD_FAILED +): Promise { + const normalized = recordType.trim() + if (!normalized) { + throw new KeeperSdkError('Record type is required.', resultCode) + } + if (NSF_LEGACY_RECORD_TYPES.has(normalized)) return + + const fields = await getNsfRecordTypeFields(auth, normalized) + if (!fields?.length) { + throw new KeeperSdkError(`Record type "${normalized}" cannot be found.`, resultCode) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts new file mode 100644 index 00000000..a8bad745 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -0,0 +1,403 @@ +import type { DRecord, Records, record as RecordProto } from '@keeper-security/keeperapi' +import { Folder } from '@keeper-security/keeperapi' +import type { NsfFolderColor } from './nsfConstants' +import type { NsfItemType } from './nsfHelpers' +import type { RecordFieldEntry } from './nsfRecordData' + +export enum NsfAccessRoleLabel { + Owner = 'owner', + Navigator = 'navigator', + Requestor = 'requestor', + Viewer = 'viewer', + SharedManager = 'shared-manager', + ContentManager = 'content-manager', + ContentShareManager = 'content-share-manager', + FullManager = 'full-manager', + Unresolved = 'unresolved', + Unknown = 'unknown', +} + +export const NSF_ACCESS_ROLE_LABELS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: NsfAccessRoleLabel.Navigator, + [Folder.AccessRoleType.REQUESTOR]: NsfAccessRoleLabel.Requestor, + [Folder.AccessRoleType.VIEWER]: NsfAccessRoleLabel.Viewer, + [Folder.AccessRoleType.SHARED_MANAGER]: NsfAccessRoleLabel.SharedManager, + [Folder.AccessRoleType.CONTENT_MANAGER]: NsfAccessRoleLabel.ContentManager, + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: NsfAccessRoleLabel.ContentShareManager, + [Folder.AccessRoleType.MANAGER]: NsfAccessRoleLabel.FullManager, + [Folder.AccessRoleType.UNRESOLVED]: NsfAccessRoleLabel.Unresolved, +} + +export enum NsfObjectKind { + Folder = 'folder', + Record = 'record', +} + +export enum GetNsfFormat { + Detail = 'detail', + JSON = 'json', +} + +export type GetNsfFormatInput = GetNsfFormat | `${GetNsfFormat}` + +export type GetNsfOptions = { + format?: GetNsfFormatInput + verbose?: boolean + unmask?: boolean +} + +export type NsfFolderAccessRow = { + username: string + role: NsfAccessRoleLabel +} + +export type NsfFolderPermission = { + accessTypeUid: string + accessType: string + accessRoleType: string + inherited?: boolean + hidden?: boolean + canAdd?: boolean + canRemove?: boolean + canDelete?: boolean + canListAccess?: boolean + canUpdateAccess?: boolean + canEditRecords?: boolean + canViewRecords?: boolean + canListRecords?: boolean +} + +export type NsfRecordPermission = { + username: string + accountUid?: string + owner: boolean + shareAdmin: boolean + shareable: boolean + editable: boolean + awaitingApproval: boolean + expiration?: number + role?: NsfAccessRoleLabel +} + +export type NsfFolderSummary = { + uid: string + title: string + type: string +} + +export type NsfFolderView = { + objectType: NsfObjectKind.Folder + folderUid: string + name: string + parentUid: string + path: string + userPermissions: NsfFolderAccessRow[] + shareAdmins: NsfFolderAccessRow[] + teamPermissions: NsfFolderPermission[] + records: NsfFolderSummary[] +} + +export type NsfRecordFieldView = { + type: string + label?: string + value: string[] +} + +export type NsfRecordFolderView = { + uid: string + path: string +} + +export type NsfRecordJsonUserPermission = { + username: string + owner: boolean + shareable: boolean + editable: boolean + role: NsfAccessRoleLabel +} + +export type NsfRecordJsonView = { + record_uid: string + title: string + type: string + version: number + revision: number + folder?: NsfRecordFolderView + fields: { type: string; value: unknown[] }[] + notes?: string + user_permissions: NsfRecordJsonUserPermission[] + share_admins: string[] +} + +export type NsfRecordView = { + objectType: NsfObjectKind.Record + recordUid: string + title: string + type: string + revision: number + version: number + folder?: NsfRecordFolderView + folderLocation: string + login?: string + password?: string + url?: string + notes?: string + fields: NsfRecordFieldView[] + userPermissions: NsfRecordPermission[] + shareAdmins: string[] +} + +export type GetNsfResult = + | { kind: NsfObjectKind.Folder; view: NsfFolderView } + | { kind: NsfObjectKind.Record; view: NsfRecordView } + +export type NsfFolderColorInput = NsfFolderColor | `${NsfFolderColor}` + +export type MkdirNsfInput = { + folder: string + color?: NsfFolderColorInput + noInheritPermissions?: boolean + baseFolderUid?: string | null +} + +export type MkdirNsfResult = { + folderUid: string + created: boolean + message?: string +} + +export type FolderSegmentCreateSpec = { + segmentName: string + color?: NsfFolderColor + inheritPermissions: boolean +} + +export type FolderCreatePayload = { + folderUid: string + folderName: string + parentUid: string | null + inheritPermissions: boolean + folderData: Folder.IFolderData +} + +export type AddNsfRecordInput = { + title: string + recordType: string + folder?: string + notes?: string + fieldEntries?: RecordFieldEntry[] + customEntries?: RecordFieldEntry[] + recordData?: Record + force?: boolean + hasFileFields?: boolean +} + +export type AddNsfRecordsInput = { + records: AddNsfRecordInput[] +} + +export type NsfRecordOperationResult = { + recordUid: string + success: boolean + status: string + message?: string + revision?: number +} + +export type AddNsfRecordResult = NsfRecordOperationResult + +export type AddNsfRecordsResult = { + added: AddNsfRecordResult[] + revision?: number +} + +export type RecordAddPayload = { + recordUid: string + recordAdd: RecordProto.v3.IRecordAdd +} + +export type UpdateNsfRecordInput = { + records: string[] + title?: string + recordType?: string + notes?: string + fieldEntries?: RecordFieldEntry[] + customEntries?: RecordFieldEntry[] +} + +export type UpdateNsfRecordItemInput = { + record: string + title?: string + recordType?: string + notes?: string + fieldEntries?: RecordFieldEntry[] + customEntries?: RecordFieldEntry[] +} + +export type UpdateNsfRecordsInput = { + records: UpdateNsfRecordItemInput[] +} + +export type UpdateNsfRecordResultItem = NsfRecordOperationResult + +export type UpdateNsfRecordResult = { + updated: UpdateNsfRecordResultItem[] +} + +export type RecordUpdatePayload = { + recordUid: string + storedRecord: DRecord | undefined + mergedRecordData: Record + recordUpdate: Records.IRecordUpdate +} + +export enum ListNsfFormat { + Table = 'table', + CSV = 'csv', + JSON = 'json', +} + +export type ListNsfFormatInput = ListNsfFormat | `${ListNsfFormat}` + +export type ListNsfOptions = { + folders?: boolean + records?: boolean + format?: ListNsfFormatInput +} + +export type ListNsfRow = { + itemType: NsfItemType + uid: string + title: string + type: string + description: string + parentOrFolder: string +} + +export type FormattedListNsfTable = { + headers: string[] + rows: string[][] +} + +export enum NsfRemoveOperation { + OwnerTrash = 'owner-trash', + FolderTrash = 'folder-trash', + Unlink = 'unlink', +} + +export type NsfRemoveOperationInput = NsfRemoveOperation | `${NsfRemoveOperation}` + +export type RemoveNsfRecordInput = { + records: string[] + folder?: string + operation?: NsfRemoveOperationInput + force?: boolean + dryRun?: boolean +} + +export type NsfRemovePreviewItem = { + recordUid: string + folderUid: string + status: string + impact?: { + foldersCount: number + recordsCount: number + affectedUsersCount: number + affectedTeamsCount: number + warnings: string[] + } + error?: { code: number; message: string } +} + +export type RemoveNsfRecordResult = { + confirmed: boolean + dryRun: boolean + preview: NsfRemovePreviewItem[] + message?: string +} + +export enum NsfRemoveFolderOperation { + FolderTrash = 'folder-trash', + DeletePermanent = 'delete-permanent', +} + +export type NsfRemoveFolderOperationInput = NsfRemoveFolderOperation | `${NsfRemoveFolderOperation}` + +export type RemoveNsfFolderInput = { + folders: string[] + operation?: NsfRemoveFolderOperationInput + force?: boolean + dryRun?: boolean + quiet?: boolean +} + +export type NsfRemoveFolderPreviewItem = { + folderUid: string + name: string + status: string + impact?: { + foldersCount: number + recordsCount: number + affectedUsersCount: number + affectedTeamsCount: number + warnings: string[] + } + error?: { code: number; message: string } +} + +export type RemoveNsfFolderResult = { + confirmed: boolean + dryRun: boolean + operation: NsfRemoveFolderOperation + preview: NsfRemoveFolderPreviewItem[] + message?: string +} + +export enum GetNsfRecordDetailsFormat { + Table = 'table', + JSON = 'json', +} + +export type GetNsfRecordDetailsFormatInput = GetNsfRecordDetailsFormat | `${GetNsfRecordDetailsFormat}` + +export type NsfRecordDetailsItem = { + recordUid: string + title: string + type: string + revision: number + version: number +} + +export type GetNsfRecordDetailsResult = { + data: NsfRecordDetailsItem[] + forbiddenRecords: string[] +} + +export type GetNsfRecordDetailsInput = { + records: string[] + format?: GetNsfRecordDetailsFormatInput +} + +export type LinkNsfRecordResult = { + success: boolean + recordUid: string + folderUid: string + status: string + message: string +} + +const ACCESS_ROLE_LABEL_VALUES = new Set(Object.values(NsfAccessRoleLabel)) + +export function toNsfAccessRoleLabel(role: string): NsfAccessRoleLabel { + if (ACCESS_ROLE_LABEL_VALUES.has(role)) { + return role as NsfAccessRoleLabel + } + return NsfAccessRoleLabel.Unknown +} + +export function resolveRecordPermissionRole(entry: NsfRecordPermission): NsfAccessRoleLabel { + if (entry.owner) return NsfAccessRoleLabel.Owner + if (entry.shareAdmin) return NsfAccessRoleLabel.SharedManager + if (entry.role) return entry.role + return NsfAccessRoleLabel.ContentManager +} diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts new file mode 100644 index 00000000..6e34567d --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/removeNsfFolder.ts @@ -0,0 +1,231 @@ +import type { Auth, folder as FolderProto } from '@keeper-security/keeperapi' +import { folder, normal64Bytes, removeFolderMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_MAX_FOLDER_REMOVALS } from './nsfConstants' +import { + checkFolderDeletePermission, + ensureNestedShareFolder, + getFolderDisplayName, + requireAuthAccountUid, + resolveNsfFolderIdentifier, +} from './nsfHelpers' + +import { + NsfRemoveFolderOperation, + type NsfRemoveFolderOperationInput, + type NsfRemoveFolderPreviewItem, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from './nsfTypes' + +const { RemoveAction, FolderOperationType, RemoveStatus } = folder.v3.remove +const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] +const TRASH_ACTION_LABEL = 'moved to trash' +const PERMANENT_DELETE_ACTION_LABEL = 'permanently deleted' + +const OPERATION_MAP: Record = { + [NsfRemoveFolderOperation.FolderTrash]: FolderOperationType.FOLDER_MOVE_TO_FOLDER_TRASH, + [NsfRemoveFolderOperation.DeletePermanent]: FolderOperationType.FOLDER_DELETE_PERMANENT, +} + +function normalizeOperation( + operation: NsfRemoveFolderOperationInput = NsfRemoveFolderOperation.FolderTrash +): NsfRemoveFolderOperation { + const value = operation as NsfRemoveFolderOperation + if (value in OPERATION_MAP) return value + throw new KeeperSdkError( + `Invalid operation '${operation}'. Use: folder-trash, delete-permanent.`, + ResultCodes.NSF_REMOVE_FAILED + ) +} + +type RemovalSpec = { + folderUid: string + name: string + operation: FolderProto.v3.remove.FolderOperationType +} + +function buildRemovals( + storage: InMemoryStorage, + auth: Auth, + folderIdentifiers: string[], + operation: NsfRemoveFolderOperation +): RemovalSpec[] { + if (folderIdentifiers.length === 0) { + throw new KeeperSdkError('Enter the name or UID of at least one folder.', ResultCodes.NSF_NOT_FOUND) + } + if (folderIdentifiers.length > NSF_MAX_FOLDER_REMOVALS) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_FOLDER_REMOVALS} folders per request.`, + ResultCodes.NSF_TOO_MANY_FOLDERS + ) + } + + const accountUid = requireAuthAccountUid(auth) + + const removals: RemovalSpec[] = [] + for (const identifier of folderIdentifiers) { + const folderUid = resolveNsfFolderIdentifier(storage, identifier) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, identifier) + checkFolderDeletePermission(storage, folderUid, auth.username, accountUid) + removals.push({ + folderUid, + name: getFolderDisplayName(storage, folderUid), + operation: OPERATION_MAP[operation], + }) + } + return removals +} + +function toRemovalInput(spec: RemovalSpec): FolderProto.v3.remove.IFolderRemoval { + return { + folderUid: normal64Bytes(spec.folderUid), + operationType: spec.operation, + } +} + +async function executeRemove( + auth: Auth, + removals: RemovalSpec[], + action: FolderProto.v3.remove.RemoveAction, + confirmationToken?: Uint8Array +): Promise { + return auth.executeRest( + removeFolderMessage({ + action, + folders: removals.map(toRemovalInput), + confirmationToken, + }) + ) +} + +function mapPreviewItem(spec: RemovalSpec, item: FolderProto.v3.remove.IRemoveResult): NsfRemoveFolderPreviewItem { + return { + folderUid: item.itemUid?.length ? webSafe64FromBytes(item.itemUid) : spec.folderUid, + name: spec.name, + status: item.status == null ? 'REMOVE_STATUS_UNKNOWN' : (RemoveStatus[item.status] ?? String(item.status)), + impact: item.impact + ? { + foldersCount: item.impact.foldersCount ?? 0, + recordsCount: item.impact.recordsCount ?? 0, + affectedUsersCount: item.impact.affectedUsersCount ?? 0, + affectedTeamsCount: item.impact.affectedTeamsCount ?? 0, + warnings: [...(item.impact.warnings ?? [])], + } + : undefined, + error: item.error + ? { + code: item.error.code ?? 0, + message: item.error.message ?? '', + } + : undefined, + } +} + +function mapPreview( + removals: RemovalSpec[], + response: FolderProto.v3.remove.IRemoveResponse +): NsfRemoveFolderPreviewItem[] { + return (response.results ?? []).map((item, index) => { + const spec = removals[index] + if (!spec) { + throw new KeeperSdkError('Folder removal preview mismatch.', ResultCodes.NSF_REMOVE_FAILED) + } + return mapPreviewItem(spec, item) + }) +} + +function hasPreviewErrors(preview: NsfRemoveFolderPreviewItem[]): boolean { + return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) +} + +export function formatRemoveNsfFolderPreview( + preview: NsfRemoveFolderPreviewItem[], + operation: NsfRemoveFolderOperation, + quiet = false +): string { + const action = + operation === NsfRemoveFolderOperation.DeletePermanent + ? PERMANENT_DELETE_ACTION_LABEL + : TRASH_ACTION_LABEL + const lines: string[] = [] + + for (const item of preview) { + lines.push(`\nThe following folder will be ${action}:`) + lines.push(` ${item.name} [${item.folderUid}]`) + if (item.impact && !quiet) { + const parts = [ + `sub-folders=${item.impact.foldersCount}`, + `records=${item.impact.recordsCount}`, + `users=${item.impact.affectedUsersCount}`, + `teams=${item.impact.affectedTeamsCount}`, + ] + lines.push(` Impact: ${parts.join(', ')}`) + for (const warning of item.impact.warnings) { + lines.push(` Warning: ${warning}`) + } + } + if (item.error?.message) { + lines.push(` Error: ${item.error.message}`) + } + } + + return lines.join('\n').trimEnd() +} + +export async function removeNestedShareFolders( + storage: InMemoryStorage, + auth: Auth, + input: RemoveNsfFolderInput +): Promise { + const operation = normalizeOperation(input.operation) + const dryRun = input.dryRun ?? false + const removals = buildRemovals(storage, auth, input.folders, operation) + + try { + const previewResponse = await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_PREVIEW) + const preview = mapPreview(removals, previewResponse) + + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError( + formatRemoveNsfFolderPreview(preview, operation, input.quiet) || 'Folder removal preview failed.', + ResultCodes.NSF_REMOVE_FAILED + ) + } + + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, operation, preview } + } + + if (!input.force) { + return { + confirmed: false, + dryRun: false, + operation, + preview, + message: 'Confirmation required. Set force=true to proceed.', + } + } + + await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_CONFIRM, previewResponse.confirmationToken) + const actionLabel = + operation === NsfRemoveFolderOperation.DeletePermanent ? 'Permanently deleted' : 'Moved to trash' + return { + confirmed: true, + dryRun: false, + operation, + preview, + message: `${actionLabel} ${removals.length} folder(s).`, + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to remove nested share folder(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_REMOVE_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts index 595aee66..2afae04e 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -11,48 +11,18 @@ import { resolveNsfFolderIdentifier, resolveNsfRecordIdentifier, } from './nsfHelpers' -import { NSF_MAX_REMOVALS } from './nsfConstants' +import { NSF_MAX_RECORD_BATCH } from './nsfConstants' +import { + NsfRemoveOperation, + type NsfRemoveOperationInput, + type NsfRemovePreviewItem, + type RemoveNsfRecordInput, + type RemoveNsfRecordResult, +} from './nsfTypes' const { RemoveAction, RecordOperationType, RemoveStatus } = folder.v3.remove const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] -export enum NsfRemoveOperation { - OwnerTrash = 'owner-trash', - FolderTrash = 'folder-trash', - Unlink = 'unlink', -} - -export type NsfRemoveOperationInput = NsfRemoveOperation | `${NsfRemoveOperation}` - -export type RemoveNsfRecordInput = { - records: string[] - folder?: string - operation?: NsfRemoveOperationInput - force?: boolean - dryRun?: boolean -} - -export type NsfRemovePreviewItem = { - recordUid: string - folderUid: string - status: string - impact?: { - foldersCount: number - recordsCount: number - affectedUsersCount: number - affectedTeamsCount: number - warnings: string[] - } - error?: { code: number; message: string } -} - -export type RemoveNsfRecordResult = { - confirmed: boolean - dryRun: boolean - preview: NsfRemovePreviewItem[] - message?: string -} - const OPERATION_MAP: Record = { [NsfRemoveOperation.Unlink]: RecordOperationType.UNLINK_FROM_FOLDER, [NsfRemoveOperation.FolderTrash]: RecordOperationType.MOVE_TO_FOLDER_TRASH, @@ -111,8 +81,8 @@ function buildRemovals( if (recordIdentifiers.length === 0) { throw new KeeperSdkError('At least one record UID or title is required.', ResultCodes.NSF_NOT_FOUND) } - if (recordIdentifiers.length > NSF_MAX_REMOVALS) { - throw new KeeperSdkError(`Maximum ${NSF_MAX_REMOVALS} records per request.`, ResultCodes.NSF_TOO_MANY_RECORDS) + if (recordIdentifiers.length > NSF_MAX_RECORD_BATCH) { + throw new KeeperSdkError(`Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, ResultCodes.NSF_TOO_MANY_RECORDS) } if (operation === NsfRemoveOperation.Unlink && !folderIdentifier?.trim()) { throw new KeeperSdkError( @@ -191,19 +161,28 @@ async function executeRemove( ) } +export function collectRemoveNsfWarnings(preview: NsfRemovePreviewItem[]): string[] { + const warnings = new Set() + for (const item of preview) { + for (const warning of item.impact?.warnings ?? []) { + if (warning) warnings.add(warning) + } + } + return [...warnings] +} + export function formatRemoveNsfPreview(preview: NsfRemovePreviewItem[]): string { const lines: string[] = [] for (const item of preview) { lines.push(`Record: ${item.recordUid}`) if (item.folderUid) lines.push(` Folder: ${item.folderUid}`) - lines.push(` Status: ${item.status}`) + if (item.status !== REMOVE_SUCCESS_STATUS) { + lines.push(` Status: ${item.status}`) + } if (item.impact) { lines.push( ` Impact: folders=${item.impact.foldersCount}, records=${item.impact.recordsCount}, users=${item.impact.affectedUsersCount}, teams=${item.impact.affectedTeamsCount}` ) - for (const warning of item.impact.warnings) { - lines.push(` Warning: ${warning}`) - } } if (item.error?.message) { lines.push(` Error: ${item.error.message}`) diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts new file mode 100644 index 00000000..8829ed38 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfRecord.ts @@ -0,0 +1,196 @@ +import type { Auth, DRecord } from '@keeper-security/keeperapi' +import { keeperDriveRecordsUpdate, normal64Bytes, platform } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { VaultObjectKind } from '../folders/folderHelpers' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_MAX_RECORD_BATCH } from './nsfConstants' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import { getPaddedJsonBytes, mergeNsfRecordData } from './nsfRecordData' +import { + checkRecordEditPermission, + ensureNestedShareRecord, + nsfToNumber, + parseRecordModifyStatus, + requireAuthAccountUid, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { validateNsfRecordType } from './nsfRecordTypes' +import type { + RecordUpdatePayload, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, +} from './nsfTypes' + +type UpdateNsfRecordChanges = Omit + +function loadStoredRecordData(storage: InMemoryStorage, recordUid: string): Record { + const record = storage.getByUid(VaultObjectKind.Record, recordUid) + if (record?.data && typeof record.data === 'object') { + return structuredClone(record.data) as Record + } + return { fields: [] } +} + +function isPerRecordUpdateInput( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput +): input is UpdateNsfRecordsInput { + const first = input.records[0] + return first != null && typeof first === 'object' && 'record' in first +} + +function toUpdateItems(input: UpdateNsfRecordInput | UpdateNsfRecordsInput): UpdateNsfRecordItemInput[] { + if (isPerRecordUpdateInput(input)) { + return input.records + } + const { records, ...changes } = input + return records.map((record) => ({ record, ...changes })) +} + +function hasUpdateChanges(changes: UpdateNsfRecordChanges): boolean { + return !!( + changes.title?.trim() || + changes.recordType?.trim() || + changes.notes?.trim() || + changes.fieldEntries?.length || + changes.customEntries?.length + ) +} + +async function buildRecordUpdatePayload( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + changes: UpdateNsfRecordChanges +): Promise { + const storedRecord = storage.getByUid(VaultObjectKind.Record, recordUid) + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + + const mergedRecordData = mergeNsfRecordData(loadStoredRecordData(storage, recordUid), changes) + return { + recordUid, + storedRecord, + mergedRecordData, + recordUpdate: { + recordUid: normal64Bytes(recordUid), + clientModifiedTime: Date.now(), + revision: storedRecord?.revision ?? 0, + data: await platform.aesGcmEncrypt(getPaddedJsonBytes(mergedRecordData), recordKey), + }, + } +} + +export async function updateNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordInput | UpdateNsfRecordsInput +): Promise { + const items = toUpdateItems(input) + if (items.length === 0) { + throw new KeeperSdkError('Record UID is required.', ResultCodes.NSF_UPDATE_FAILED) + } + if (items.length > NSF_MAX_RECORD_BATCH) { + throw new KeeperSdkError( + `Maximum ${NSF_MAX_RECORD_BATCH} records per request.`, + ResultCodes.NSF_TOO_MANY_RECORDS + ) + } + + for (const item of items) { + if (!hasUpdateChanges(item)) { + throw new KeeperSdkError( + `At least one field to update is required for record '${item.record}'.`, + ResultCodes.NSF_UPDATE_FAILED + ) + } + } + + const recordTypes = new Set() + for (const item of items) { + if (item.recordType?.trim()) { + recordTypes.add(item.recordType.trim()) + } + } + for (const recordType of recordTypes) { + await validateNsfRecordType(auth, recordType, ResultCodes.NSF_UPDATE_FAILED) + } + + const accountUid = requireAuthAccountUid(auth) + + try { + const payloads: RecordUpdatePayload[] = [] + for (const item of items) { + const { record: identifier, ...changes } = item + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + checkRecordEditPermission(storage, recordUid, auth.username, accountUid) + payloads.push(await buildRecordUpdatePayload(storage, auth, recordUid, changes)) + } + + const response = await auth.executeRest( + keeperDriveRecordsUpdate({ + records: payloads.map((entry) => entry.recordUpdate), + clientTime: Date.now(), + }) + ) + const revision = nsfToNumber(response.revision) + + const updated: UpdateNsfRecordResultItem[] = [] + for (let index = 0; index < payloads.length; index++) { + const payload = payloads[index] + const { statusName, message } = parseRecordModifyStatus( + response.records?.[index], + ResultCodes.NSF_UPDATE_FAILED + ) + const itemRevision = revision ?? payload.storedRecord?.revision + + if (payload.storedRecord) { + await storage.put({ + ...payload.storedRecord, + data: payload.mergedRecordData, + revision: itemRevision ?? payload.storedRecord.revision, + clientModifiedTime: Date.now(), + }) + } + + updated.push({ + recordUid: payload.recordUid, + success: true, + status: statusName, + message, + revision: itemRevision, + }) + } + + return { updated } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to update nested share record(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_UPDATE_FAILED + ) + } +} + +export async function updateNestedShareRecord( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordItemInput +): Promise { + const { updated } = await updateNestedShareRecords(storage, auth, { records: [input] }) + if (!updated[0]) { + throw new KeeperSdkError('Failed to update nested share record.', ResultCodes.NSF_UPDATE_FAILED) + } + return updated[0] +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 6fc04d69..6146d3ac 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 { @@ -169,7 +174,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 24e3f8c2..815ecd3d 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -86,10 +86,40 @@ import { } from '../enterpriseReport' import { UserManager } from '../users/UserManager' import { NestedShareFolderManager } from '../nestedShareFolders/NestedShareFolderManager' -import type { ListNsfOptions, ListNsfRow, ListNsfFormatInput, FormattedListNsfTable } from '../nestedShareFolders/listNsf' -import type { GetNsfOptions, GetNsfResult } from '../nestedShareFolders/getNsf' -import type { LinkNsfRecordResult } from '../nestedShareFolders/linkNsfRecord' -import type { RemoveNsfRecordInput, RemoveNsfRecordResult } from '../nestedShareFolders/removeNsfRecord' +import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' +import { + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfOutput, +} from '../nestedShareFolders/listNsf' +import { formatNsfDetail } from '../nestedShareFolders/getNsf' +import { formatRemoveNsfPreview } from '../nestedShareFolders/removeNsfRecord' +import type { + AddNsfRecordInput, + AddNsfRecordResult, + AddNsfRecordsInput, + AddNsfRecordsResult, + GetNsfOptions, + GetNsfResult, + GetNsfRecordDetailsInput, + GetNsfRecordDetailsResult, + LinkNsfRecordResult, + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, + MkdirNsfInput, + MkdirNsfResult, + RemoveNsfFolderInput, + RemoveNsfFolderResult, + RemoveNsfRecordInput, + RemoveNsfRecordResult, + UpdateNsfRecordInput, + UpdateNsfRecordItemInput, + UpdateNsfRecordsInput, + UpdateNsfRecordResult, + UpdateNsfRecordResultItem, +} from '../nestedShareFolders/nsfTypes' import type { ListUserRow, ListUsersOptions, @@ -751,15 +781,15 @@ export class KeeperVault { } public formatListNsfTable(rows: ListNsfRow[], options?: { columnWidth?: number }): FormattedListNsfTable { - return this.nestedShareFolderManager.formatListNsfTable(rows, options ?? {}) + return formatListNsfTable(rows, options ?? {}) } public renderListNsfAsciiTable(table: FormattedListNsfTable, options?: { minColWidth?: number }): string { - return this.nestedShareFolderManager.renderListNsfAsciiTable(table, options ?? {}) + return renderListNsfAsciiTable(table, options ?? {}) } public formatListNsfOutput(rows: ListNsfRow[], format?: ListNsfFormatInput): string { - return this.nestedShareFolderManager.formatListNsfOutput(rows, format) + return formatListNsfOutput(rows, format) } public async getNestedShareFolder(identifier: string, options?: GetNsfOptions): Promise { @@ -767,7 +797,7 @@ export class KeeperVault { } public formatNsfDetail(result: GetNsfResult, verbose?: boolean): string { - return this.nestedShareFolderManager.formatNsfDetail(result, verbose ?? false) + return formatNsfDetail(result, verbose ?? false) } public async linkNestedShareRecord( @@ -786,7 +816,57 @@ export class KeeperVault { } public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { - return this.nestedShareFolderManager.formatRemoveNsfPreview(preview) + return formatRemoveNsfPreview(preview) + } + + public async mkdirNestedShareFolder(input: Omit): Promise { + const current = this.folderSession.currentFolderUid + const baseFolderUid = + current && isNestedShareFolder(this.storage, current) ? current : null + const result = await this.nestedShareFolderManager.mkdirNestedShareFolder({ + ...input, + baseFolderUid, + }) + if (result.created) await this.syncIfNeeded() + return result + } + + public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { + const result = await this.nestedShareFolderManager.removeNestedShareFolders(input) + if (result.confirmed) await this.syncIfNeeded() + return result + } + + public async getNestedShareRecordDetails( + input: GetNsfRecordDetailsInput + ): Promise { + return this.nestedShareFolderManager.getNestedShareRecordDetails(input) + } + + public async updateNestedShareRecords( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput + ): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareRecords(input) + if (result.updated.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public async updateNestedShareRecord(input: UpdateNsfRecordItemInput): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareRecord(input) + if (result.success) await this.syncIfNeeded() + return result + } + + public async addNestedShareRecords(input: AddNsfRecordsInput): Promise { + const result = await this.nestedShareFolderManager.addNestedShareRecords(input) + if (result.added.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public async addNestedShareRecord(input: AddNsfRecordInput): Promise { + const result = await this.nestedShareFolderManager.addNestedShareRecord(input) + if (result.success) await this.syncIfNeeded() + return result } public async shareFolder(input: ShareFolderInput): Promise { From 96324d5120b2489288da7668edd82f579ca8a176 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 9 Jul 2026 22:52:28 +0530 Subject: [PATCH 2/2] Use sync-down folder access instead of live call --- KeeperSdk/src/nestedShareFolders/getNsf.ts | 17 ++------ .../src/nestedShareFolders/nsfHelpers.ts | 39 ------------------- KeeperSdk/src/nestedShareFolders/nsfTypes.ts | 8 ---- 3 files changed, 3 insertions(+), 61 deletions(-) diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts index 106dd2d6..91ce48d7 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -20,8 +20,8 @@ import { KeeperSdkError, ResultCodes } from '../utils' import { buildFolderPath, collectRecordsInFolder, - fetchLiveFolderAccessEntries, fetchLiveRecordAccessEntries, + getFolderAccessEntries, findNestedShareFoldersForRecord, findRecordFolderLocation, folderAccessDisplayRole, @@ -105,21 +105,12 @@ function recordDetailsMessage(recordUid: string, include: Records.RecordDetailsI } function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { - const permission = entry.permission return { accessTypeUid: entry.accessTypeUid, accessType: formatAccessType(entry.accessType), accessRoleType: folderAccessDisplayRole(entry), inherited: entry.inherited, hidden: entry.hidden, - canAdd: permission?.canAdd ?? undefined, - canRemove: permission?.canRemove ?? undefined, - canDelete: permission?.canDelete ?? undefined, - canListAccess: permission?.canListAccess ?? undefined, - canUpdateAccess: permission?.canUpdateAccess ?? undefined, - canEditRecords: permission?.canEditRecords ?? undefined, - canViewRecords: permission?.canViewRecords ?? undefined, - canListRecords: permission?.canListRecords ?? undefined, } } @@ -368,10 +359,8 @@ async function buildFolderView( throw new KeeperSdkError(`Nested share folder not found: ${folderUid}`, ResultCodes.NSF_NOT_FOUND) } - const [entries, shareUsers] = await Promise.all([ - fetchLiveFolderAccessEntries(auth, folderUid), - loadShareUserMap(auth, storage), - ]) + const entries = getFolderAccessEntries(storage, folderUid) + const shareUsers = await loadShareUserMap(auth, storage) const split = splitFolderPermissions(storage, folder, entries, shareUsers) const { userPermissions, shareAdmins } = ensureFolderOwnerListed( folder, diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 36ca1de2..7e42605d 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -10,7 +10,6 @@ import type { import { Folder, Records, - getFolderAccessMessage, getRecordAccessMessage, getShareObjectsMessage, normal64Bytes, @@ -692,20 +691,6 @@ export function resolveAccessUsername( return accessTypeUid } -function toFolderAccessEntry(folderUid: string, accessor: Folder.IFolderAccessData): DKdFolderAccess { - return { - kind: 'keeper_drive_folder_access', - accessUid: `${folderUid}:${webSafe64FromBytes(accessor.accessTypeUid!)}`, - folderUid, - accessTypeUid: webSafe64FromBytes(accessor.accessTypeUid!), - accessType: accessor.accessType!, - accessRoleType: accessor.accessRoleType!, - permission: accessor.permissions ?? {}, - inherited: accessor.inherited ?? undefined, - hidden: accessor.hidden ?? undefined, - } -} - export async function loadShareUserMap(auth: Auth, storage: InMemoryStorage): Promise> { const map = new Map() @@ -735,30 +720,6 @@ export async function loadShareUserMap(auth: Auth, storage: InMemoryStorage): Pr return map } -export async function fetchLiveFolderAccessEntries(auth: Auth, folderUid: string): Promise { - try { - const response = await auth.executeRest( - getFolderAccessMessage({ folderUid: [normal64Bytes(folderUid)] }) - ) - const result = response.folderAccessResults?.find( - (entry) => entry.folderUid?.length && webSafe64FromBytes(entry.folderUid) === folderUid - ) - return (result?.accessors ?? []) - .filter( - (accessor) => - accessor.accessTypeUid?.length && - accessor.accessType != null && - accessor.accessRoleType != null - ) - .map((accessor) => toFolderAccessEntry(folderUid, accessor)) - } catch (err) { - throw new KeeperSdkError( - `Failed to fetch folder permissions for ${folderUid}: ${extractErrorMessage(err)}`, - ResultCodes.NSF_DETAILS_FAILED - ) - } -} - export function isFolderOwnerAccessor( folder: DKdFolder, entry: DKdFolderAccess, diff --git a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts index a8bad745..9a6383d4 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -57,14 +57,6 @@ export type NsfFolderPermission = { accessRoleType: string inherited?: boolean hidden?: boolean - canAdd?: boolean - canRemove?: boolean - canDelete?: boolean - canListAccess?: boolean - canUpdateAccess?: boolean - canEditRecords?: boolean - canViewRecords?: boolean - canListRecords?: boolean } export type NsfRecordPermission = {