From 95e1f86054899c33287ab4e157dc0f652844894f Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 7 Jul 2026 20:50:35 +0530 Subject: [PATCH 1/3] 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 7291209a0c613c2aea885b469ad10c49727d4815 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Fri, 10 Jul 2026 14:14:26 +0530 Subject: [PATCH 2/3] nsf permssion and sharing commands --- KeeperSdk/src/index.ts | 66 ++ .../NestedShareFolderManager.ts | 58 ++ KeeperSdk/src/nestedShareFolders/index.ts | 92 +- .../src/nestedShareFolders/nsfConstants.ts | 155 ++++ .../src/nestedShareFolders/nsfHelpers.ts | 502 ++++++++++- .../nestedShareFolders/nsfRecordPermission.ts | 708 ++++++++++++++++ KeeperSdk/src/nestedShareFolders/nsfShare.ts | 799 ++++++++++++++++++ .../src/nestedShareFolders/nsfShareKeys.ts | 242 ++++++ .../src/nestedShareFolders/nsfShortcut.ts | 354 ++++++++ .../src/nestedShareFolders/nsfTeamShare.ts | 212 +++++ .../nestedShareFolders/nsfTransferRecord.ts | 128 +++ KeeperSdk/src/nestedShareFolders/nsfTypes.ts | 225 +++++ .../src/nestedShareFolders/updateNsfFolder.ts | 160 ++++ KeeperSdk/src/utils/constants.ts | 8 + KeeperSdk/src/vault/KeeperVault.ts | 124 +++ keeperapi/src/commands.ts | 18 + 16 files changed, 3846 insertions(+), 5 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfShare.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfShortcut.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index f6dc11de..c1269035 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -557,6 +557,42 @@ export { resolveNsfFieldValue, clearNsfRecordTypeCache, checkRecordEditPermission, + updateNestedShareFolder, + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, + parseShareExpiration, + parseShareExpirationValue, + validateShareExpirationTimestamp, + isShareExpirationNoop, + fetchNsfTeamPublicKeys, + encryptNsfFolderKeyForTeam, + resolveNsfShareRecipient, + MIN_SHARE_EXPIRATION_MS, + TeamGetKeysResponseKeyType, + getNsfRecordShortcuts, + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, + transferNestedShareRecords, + formatTransferNestedShareRecordResults, + NSF_RECORD_PERMISSION_ROLES, + NsfFolderShareAction, + NsfRecordShareAction, + NsfRecordPermissionAction, + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + getFolderPermissionsForRole, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -607,6 +643,36 @@ export type { FolderCreatePayload, ParsedNsfFields, RecordFieldEntry, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + NsfFolderShareActionInput, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, + NsfRecordPermissionRole, + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, + ParseShareExpirationInput, + NsfTeamPublicKeys, + NsfResolvedShareRecipient, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index c8e75fa3..f3c9d4b8 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -10,6 +10,11 @@ import { removeNestedShareFolders } from './removeNsfFolder' import { getNestedShareRecordDetails } from './getNsfRecordDetails' import { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' import { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' +import { shareNestedShareFolder, shareNestedShareRecord } from './nsfShare' +import { listNsfShortcuts, keepNsfShortcut } from './nsfShortcut' +import { transferNestedShareRecords } from './nsfTransferRecord' +import { updateNestedShareRecordPermissions } from './nsfRecordPermission' +import { updateNestedShareFolder } from './updateNsfFolder' import type { AddNsfRecordInput, AddNsfRecordResult, @@ -19,18 +24,32 @@ import type { GetNsfResult, GetNsfRecordDetailsInput, GetNsfRecordDetailsResult, + KeepNsfShortcutInput, + KeepNsfShortcutResult, LinkNsfRecordResult, ListNsfOptions, ListNsfRow, + ListNsfShortcutsOptions, MkdirNsfInput, MkdirNsfResult, + NsfShortcutRow, RemoveNsfFolderInput, RemoveNsfFolderResult, RemoveNsfRecordInput, RemoveNsfRecordResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + UpdateNsfFolderInput, + UpdateNsfFolderResult, UpdateNsfRecordInput, UpdateNsfRecordItemInput, UpdateNsfRecordsInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from './nsfTypes' @@ -104,4 +123,43 @@ export class NestedShareFolderManager { public async addNestedShareRecord(input: AddNsfRecordInput): Promise { return addNestedShareRecord(this.storage, this.requireAuth(), input) } + + public async updateNestedShareFolder(input: UpdateNsfFolderInput): Promise { + return updateNestedShareFolder(this.storage, this.requireAuth(), input) + } + + public async shareNestedShareFolder( + input: ShareNestedShareFolderInput + ): Promise { + return shareNestedShareFolder(this.storage, this.requireAuth(), input) + } + + public async shareNestedShareRecord( + input: ShareNestedShareRecordInput + ): Promise { + return shareNestedShareRecord(this.storage, this.requireAuth(), input) + } + + public listNsfShortcuts(options: ListNsfShortcutsOptions = {}): NsfShortcutRow[] { + return listNsfShortcuts(this.storage, options) + } + + public async keepNsfShortcut( + input: KeepNsfShortcutInput, + defaultFolderUid?: string + ): Promise { + return keepNsfShortcut(this.storage, this.requireAuth(), input, defaultFolderUid) + } + + public async transferNestedShareRecords( + input: TransferNestedShareRecordInput + ): Promise { + return transferNestedShareRecords(this.storage, this.requireAuth(), input) + } + + public async updateNestedShareRecordPermissions( + input: UpdateNsfRecordPermissionInput + ): Promise { + return updateNestedShareRecordPermissions(this.storage, this.requireAuth(), input) + } } diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index e4d87594..d1ccd632 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -27,6 +27,13 @@ export { checkFolderDeletePermission, parseNsfPath, findExistingChildFolder, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + parseShareExpiration, + parseShareExpirationValue, + validateShareExpirationTimestamp, + isShareExpirationNoop, } from './nsfHelpers' export { @@ -58,6 +65,9 @@ export { NsfRemoveOperation, NsfRemoveFolderOperation, GetNsfRecordDetailsFormat, + NsfFolderShareAction, + NsfRecordShareAction, + NsfRecordPermissionAction, NSF_ACCESS_ROLE_LABELS, resolveRecordPermissionRole, toNsfAccessRoleLabel, @@ -108,8 +118,45 @@ export type { GetNsfRecordDetailsInput, GetNsfRecordDetailsResult, NsfRecordDetailsItem, + NsfFolderShareActionInput, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + ParseShareExpirationInput, + NsfTeamPublicKeys, + NsfResolvedShareRecipient, } from './nsfTypes' +export { + fetchNsfTeamPublicKeys, + encryptNsfFolderKeyForTeam, + resolveNsfShareRecipient, +} from './nsfTeamShare' + +export type { UserShareKeys } from './nsfShareKeys' + export { linkNestedShareRecord } from './linkNsfRecord' export { @@ -119,7 +166,12 @@ export { } from './removeNsfRecord' export { mkdirNestedShareFolder } from './mkdirNsf' -export { NSF_FOLDER_COLORS, NSF_MAX_RECORD_BATCH } from './nsfConstants' +export { + NSF_FOLDER_COLORS, + NSF_MAX_RECORD_BATCH, + MIN_SHARE_EXPIRATION_MS, + TeamGetKeysResponseKeyType, +} from './nsfConstants' export type { NsfFolderColor } from './nsfConstants' export { @@ -149,3 +201,41 @@ export { } from './nsfRecordData' export { NestedShareFolderManager } from './NestedShareFolderManager' + +export { + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, +} from './nsfShare' + +export { + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, +} from './nsfRecordPermission' + +export { + getNsfRecordShortcuts, + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, +} from './nsfShortcut' + +export { + transferNestedShareRecords, + formatTransferNestedShareRecordResults, +} from './nsfTransferRecord' + +export { updateNestedShareFolder } from './updateNsfFolder' + +export { + NSF_RECORD_PERMISSION_ROLES, + getFolderPermissionsForRole, +} from './nsfConstants' +export type { NsfRecordPermissionRole } from './nsfConstants' diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index b8d09c45..dc23bcb7 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -85,3 +85,158 @@ export const NSF_STRUCTURED_SUBKEYS: Record> = { securityquestion: new Set(['question', 'answer']), phone: new Set(['number', 'region', 'type', 'ext']), } + +export const NSF_RECORD_PERMISSION_ROLES = [ + 'viewer', + 'share-manager', + 'content-manager', + 'content-share-manager', + 'full-manager', +] as const +export type NsfRecordPermissionRole = (typeof NSF_RECORD_PERMISSION_ROLES)[number] + +export const NSF_RECORD_PERMISSION_ROLE_LABELS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: 'contributor', + [Folder.AccessRoleType.REQUESTOR]: 'contributor', + [Folder.AccessRoleType.VIEWER]: 'viewer', + [Folder.AccessRoleType.SHARED_MANAGER]: 'share-manager', + [Folder.AccessRoleType.CONTENT_MANAGER]: 'content-manager', + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: 'content-share-manager', + [Folder.AccessRoleType.MANAGER]: 'full-manager', + [Folder.AccessRoleType.UNRESOLVED]: 'unresolved', +} + +export const NSF_RECORD_PERMISSION_ROLE_MAP: Record = { + contributor: Folder.AccessRoleType.REQUESTOR, + requestor: Folder.AccessRoleType.REQUESTOR, + viewer: Folder.AccessRoleType.VIEWER, + 'share-manager': Folder.AccessRoleType.SHARED_MANAGER, + share_manager: Folder.AccessRoleType.SHARED_MANAGER, + shared_manager: Folder.AccessRoleType.SHARED_MANAGER, + 'content-manager': Folder.AccessRoleType.CONTENT_MANAGER, + content_manager: Folder.AccessRoleType.CONTENT_MANAGER, + 'content-share-manager': Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + content_share_manager: Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + 'full-manager': Folder.AccessRoleType.MANAGER, + full_manager: Folder.AccessRoleType.MANAGER, +} + +export const NSF_SHARE_BATCH_SIZE = 200 + +export enum TeamGetKeysResponseKeyType { + EccPublicKey = -1, + RsaPublicKey = -3, + EncryptedAesTeamKeyByDataKey = 1, + EncryptedAesTeamKeyByRsa = 2, + EncryptedAesTeamKeyByDataKeyGcm = 3, + EncryptedAesTeamKeyByEcc = 4, +} + +export const MIN_SHARE_EXPIRATION_MS = 60_000 +export const NSF_TLA_EXPIRATION_TOLERANCE_MS = 60_000 + +const NSF_SHARE_EXPIRATION_DAY_MS = 86_400_000 + +export const NSF_SHARE_EXPIRATION_RE = /^(\d+)\s*(mi(?:nutes?)?|h(?:ours?)?|d(?:ays?)?|mo(?:nths?)?|y(?:ears?)?)$/i + +export const NSF_SHARE_EXPIRATION_UNIT_MS: Record = { + mi: 60_000, + m: 60_000, + minute: 60_000, + minutes: 60_000, + h: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, + d: NSF_SHARE_EXPIRATION_DAY_MS, + day: NSF_SHARE_EXPIRATION_DAY_MS, + days: NSF_SHARE_EXPIRATION_DAY_MS, + mo: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + month: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + months: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + y: 365 * NSF_SHARE_EXPIRATION_DAY_MS, + year: 365 * NSF_SHARE_EXPIRATION_DAY_MS, + years: 365 * NSF_SHARE_EXPIRATION_DAY_MS, +} + +type FolderRolePermissionFlags = { + canAdd?: boolean + canRemove?: boolean + canDelete?: boolean + canListAccess?: boolean + canUpdateAccess?: boolean + canChangeOwnership?: boolean + canEditRecords?: boolean + canViewRecords?: boolean + canApproveAccess?: boolean + canRequestAccess?: boolean + canUpdateSetting?: boolean + canListRecords?: boolean + canListFolders?: boolean +} + +const NSF_FOLDER_ROLE_PERMISSIONS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: { + canListFolders: true, + }, + [Folder.AccessRoleType.REQUESTOR]: { + canRequestAccess: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.VIEWER]: { + canListAccess: true, + canViewRecords: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.SHARED_MANAGER]: { + canListAccess: true, + canUpdateAccess: true, + canViewRecords: true, + canApproveAccess: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.CONTENT_MANAGER]: { + canAdd: true, + canListAccess: true, + canEditRecords: true, + canViewRecords: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: { + canAdd: true, + canRemove: true, + canListAccess: true, + canUpdateAccess: true, + canEditRecords: true, + canViewRecords: true, + canApproveAccess: true, + canUpdateSetting: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.MANAGER]: { + canAdd: true, + canRemove: true, + canDelete: true, + canListAccess: true, + canUpdateAccess: true, + canChangeOwnership: true, + canEditRecords: true, + canViewRecords: true, + canApproveAccess: true, + canUpdateSetting: true, + canListRecords: true, + canListFolders: true, + }, +} + +export function getFolderPermissionsForRole(roleType: Folder.AccessRoleType): Folder.IFolderPermissions { + const flags = NSF_FOLDER_ROLE_PERMISSIONS[roleType] + if (!flags) { + throw new Error(`Unknown folder access role type: ${roleType}`) + } + return Folder.FolderPermissions.create(flags) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 36ca1de2..5d474a99 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -14,6 +14,7 @@ import { getRecordAccessMessage, getShareObjectsMessage, normal64Bytes, + record, webSafe64FromBytes, } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' @@ -27,9 +28,17 @@ import { NSF_NOTE_FIELD_TYPES, NSF_PATH_SENTINEL, NSF_RECORD_DESCRIPTION_MAX_LENGTH, + NSF_RECORD_PERMISSION_ROLE_LABELS, + NSF_RECORD_PERMISSION_ROLE_MAP, + NSF_RECORD_PERMISSION_ROLES, NSF_SENSITIVE_FIELD_TYPES, + NSF_SHARE_BATCH_SIZE, + MIN_SHARE_EXPIRATION_MS, + NSF_TLA_EXPIRATION_TOLERANCE_MS, + NSF_SHARE_EXPIRATION_RE, + NSF_SHARE_EXPIRATION_UNIT_MS, } from './nsfConstants' -import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS } from './nsfTypes' +import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS, type ParseShareExpirationInput } from './nsfTypes' export enum KeeperDriveKind { Folder = 'keeper_drive_folder', @@ -426,12 +435,18 @@ function isCurrentUserRecordAccess( username: string, accountUidStr: string ): boolean { + if ( + entry.accessType !== Folder.AccessType.AT_USER && + entry.accessType !== Folder.AccessType.AT_OWNER + ) { + return false + } return ( - (entry.accessType === Folder.AccessType.AT_USER && entry.accessTypeUid === accountUidStr) || + entry.accessTypeUid === accountUidStr || (username.length > 0 && storage.getAll('user').some( (user) => - user.username === username && + user.username.toLowerCase() === username.toLowerCase() && webSafe64FromBytes(user.accountUid) === entry.accessTypeUid )) ) @@ -466,7 +481,12 @@ function isFolderOwnerAccount(storage: InMemoryStorage, folderUid: string, accou return folder?.ownerInfo?.accountUid === accountUidStr } -type FolderPermissionFlag = 'canRemove' | 'canDelete' +type FolderPermissionFlag = + | 'canRemove' + | 'canDelete' + | 'canUpdateAccess' + | 'canEditRecords' + | 'canChangeOwnership' function hasFolderPermission( storage: InMemoryStorage, @@ -522,6 +542,102 @@ function canRecordBeDeleted( ) } +function canShareRecord( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, + folderUid?: string +): boolean { + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getRecordAccessEntries(storage, recordUid) + if (entries.length === 0) return true + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue + if (entry.owner || entry.canUpdateAccess) return true + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canUpdateAccess') + ) { + return true + } + for (const parentFolderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission(storage, parentFolderUid, username, accountUid, 'canUpdateAccess') + ) { + return true + } + } + return false + } + + for (const parentFolderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission(storage, parentFolderUid, username, accountUid, 'canUpdateAccess') + ) { + return true + } + } + + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canUpdateAccess') + ) +} + +function canChangeRecordOwnership( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, + folderUid?: string +): boolean { + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getRecordAccessEntries(storage, recordUid) + if (entries.length === 0) return true + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue + if (entry.owner || entry.canChangeOwnership) return true + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canChangeOwnership') + ) { + return true + } + for (const parentFolderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission(storage, parentFolderUid, username, accountUid, 'canChangeOwnership') + ) { + return true + } + } + return false + } + + for (const parentFolderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission(storage, parentFolderUid, username, accountUid, 'canChangeOwnership') + ) { + return true + } + } + + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canChangeOwnership') + ) +} + export function checkFolderRemovePermission( storage: InMemoryStorage, folderUid: string, @@ -841,3 +957,381 @@ export function isFolderUserPermission(entry: DKdFolderAccess): boolean { entry.accessType === Folder.AccessType.AT_OWNER ) } + +export async function patchNsfFolderMetadata( + storage: InMemoryStorage, + folderUid: string, + metadata: { name: string; color?: string } +): Promise { + const folder = getKeeperDriveFolder(storage, folderUid) + if (!folder) return + await storage.put({ + ...folder, + data: { + ...folder.data, + name: metadata.name, + ...(metadata.color ? { color: metadata.color } : {}), + }, + }) +} + +export function checkFolderEditPermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array +): void { + if (hasFolderPermission(storage, folderUid, username, accountUid, 'canEditRecords')) return + throw new KeeperSdkError( + 'You do not have permission to edit this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function checkFolderSharePermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array +): void { + if (hasFolderPermission(storage, folderUid, username, accountUid, 'canUpdateAccess')) return + throw new KeeperSdkError( + 'You do not have permission to share this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function checkRecordSharePermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array +): void { + if (canShareRecord(storage, recordUid, username, accountUid)) return + throw new KeeperSdkError( + 'You do not have permission to share this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function checkRecordChangeOwnershipPermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array +): void { + if (canChangeRecordOwnership(storage, recordUid, username, accountUid)) return + throw new KeeperSdkError( + 'You do not have permission to transfer ownership of this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function findFolderAccessEntry( + storage: InMemoryStorage, + folderUid: string, + accessTypeUid: string, + accessType: Folder.AccessType +): DKdFolderAccess | undefined { + return getFolderAccessEntries(storage, folderUid).find( + (entry) => entry.accessTypeUid === accessTypeUid && entry.accessType === accessType + ) +} + +export function collectExistingFolderShareTargets( + storage: InMemoryStorage, + folderUid: string, + currentUsername: string +): Array<{ recipient: string; isTeam: boolean; accountUid?: string }> { + const targets: Array<{ recipient: string; isTeam: boolean; accountUid?: string }> = [] + for (const entry of getFolderAccessEntries(storage, folderUid)) { + if (entry.accessType === Folder.AccessType.AT_TEAM) { + targets.push({ recipient: entry.accessTypeUid, isTeam: true, accountUid: entry.accessTypeUid }) + continue + } + if (entry.accessType !== Folder.AccessType.AT_USER) continue + const username = resolveAccessUsername(storage, entry.accessTypeUid) + if (!username || username.toLowerCase() === currentUsername.toLowerCase()) continue + targets.push({ recipient: username, isTeam: false, accountUid: entry.accessTypeUid }) + } + return targets +} + +export function resolveNsfRoleName(role: string): Folder.AccessRoleType { + const normalized = role.trim().toLowerCase().replace(/\s+/g, '-') + const mapped = NSF_RECORD_PERMISSION_ROLE_MAP[normalized] + if (mapped != null) return mapped + throw new KeeperSdkError( + `Invalid role '${role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}.`, + ResultCodes.NSF_SHARE_FAILED + ) +} + +export function getNsfRecordPermissionRoleLabel( + accessRoleType: Folder.AccessRoleType | number | null | undefined +): string { + if (accessRoleType == null) return 'unresolved' + return NSF_RECORD_PERMISSION_ROLE_LABELS[accessRoleType] ?? `role-${accessRoleType}` +} + +export type NsfRecordAccessFlags = { + accessRoleType?: number + owner?: boolean +} + +export function getNsfAccessRoleLabel(access: NsfRecordAccessFlags): string { + if (access.owner) return 'owner' + return getNsfRecordPermissionRoleLabel(access.accessRoleType) +} + +export function normalizeNsfRecordPermissionRole(role?: string): string | undefined { + if (!role?.trim()) return undefined + const normalized = role.trim().toLowerCase().replace(/\s+/g, '-') + if ((NSF_RECORD_PERMISSION_ROLES as readonly string[]).includes(normalized)) return normalized + if (normalized in NSF_RECORD_PERMISSION_ROLE_MAP) return normalized + throw new KeeperSdkError( + `Invalid role '${role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) +} + +export type NsfLiveRecordAccessEntry = { + recordUid: string + accessorName: string + accessTypeUid: string + owner: boolean + inherited: boolean + accessRoleType: number + canViewTitle: boolean + canEdit: boolean + canView: boolean + canListAccess: boolean + canUpdateAccess: boolean + canDelete: boolean + canChangeOwnership: boolean + canRequestAccess: boolean + canApproveAccess: boolean +} + +function mapLiveRecordAccessEntry( + storage: InMemoryStorage, + shareUsers: Map, + entry: record.v3.details.IRecordAccess +): NsfLiveRecordAccessEntry | undefined { + const data = entry.data + if (!data?.recordUid?.length || !data.accessTypeUid?.length || data.accessType == null) return undefined + + const accessType = data.accessType + if ( + accessType !== Folder.AccessType.AT_USER && + accessType !== Folder.AccessType.AT_OWNER && + !data.owner + ) { + return undefined + } + + const recordUid = webSafe64FromBytes(data.recordUid) + const accessTypeUid = webSafe64FromBytes(data.accessTypeUid) + const accessorName = + entry.accessorInfo?.name?.trim() || + shareUsers.get(accessTypeUid) || + resolveAccessUsername(storage, accessTypeUid) + + if (!accessorName) return undefined + + return { + recordUid, + accessorName, + accessTypeUid, + owner: !!data.owner, + inherited: !!data.inherited, + accessRoleType: data.accessRoleType ?? Folder.AccessRoleType.UNRESOLVED, + canViewTitle: data.canViewTitle ?? true, + canEdit: !!data.canEdit, + canView: !!data.canView, + canListAccess: !!data.canListAccess, + canUpdateAccess: !!data.canUpdateAccess, + canDelete: !!data.canDelete, + canChangeOwnership: !!data.canChangeOwnership, + canRequestAccess: !!data.canRequestAccess, + canApproveAccess: !!data.canApproveAccess, + } +} + +export async function fetchLiveRecordAccessesV3( + auth: Auth, + storage: InMemoryStorage, + recordUids: string[] +): Promise<{ recordAccesses: NsfLiveRecordAccessEntry[]; forbiddenRecords: string[] }> { + if (recordUids.length === 0) { + return { recordAccesses: [], forbiddenRecords: [] } + } + + const shareUsers = await loadShareUserMap(auth, storage) + const recordAccesses: NsfLiveRecordAccessEntry[] = [] + const forbiddenRecords: string[] = [] + + for (let index = 0; index < recordUids.length; index += NSF_SHARE_BATCH_SIZE) { + const chunk = recordUids.slice(index, index + NSF_SHARE_BATCH_SIZE) + let response: record.v3.details.IRecordAccessResponse + try { + response = await auth.executeRest( + getRecordAccessMessage({ recordUids: chunk.map((uid) => normal64Bytes(uid)) }) + ) + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch record permissions: ${extractErrorMessage(err)}`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } + + for (const uid of response.forbiddenRecords ?? []) { + forbiddenRecords.push(webSafe64FromBytes(uid)) + } + + for (const entry of response.recordAccesses ?? []) { + const mapped = mapLiveRecordAccessEntry(storage, shareUsers, entry) + if (mapped) recordAccesses.push(mapped) + } + } + + return { recordAccesses, forbiddenRecords } +} + +export function findUserRecordShareEntry( + storage: InMemoryStorage, + recordUid: string, + email: string, + accountUidStr?: string, + shareUsers?: Map +): DKdRecordAccess | undefined { + const lower = email.toLowerCase() + const normalizedUid = accountUidStr?.trim() + for (const entry of getRecordAccessEntries(storage, recordUid)) { + if (entry.owner || entry.accessType !== Folder.AccessType.AT_USER) continue + if (normalizedUid && entry.accessTypeUid === normalizedUid) { + return entry + } + const accessorName = resolveAccessUsername(storage, entry.accessTypeUid, undefined, shareUsers) + if (accessorName.toLowerCase() === lower) return entry + } + return undefined +} + +export function findDirectUserRecordShare( + storage: InMemoryStorage, + recordUid: string, + email: string +): { recordUid: string; email: string; accessRoleType: number; expiration?: number } | undefined { + const entry = findUserRecordShareEntry(storage, recordUid, email) + if (!entry || entry.inherited) return undefined + return { + recordUid, + email, + accessRoleType: entry.accessRoleType ?? Folder.AccessRoleType.UNRESOLVED, + expiration: entry.tlaProperties?.expiration ?? undefined, + } +} + +export function validateShareExpirationTimestamp( + expirationMs: number | null | undefined, + cmdName: string +): void { + if (expirationMs == null || expirationMs === -1) return + const minAllowed = Date.now() + MIN_SHARE_EXPIRATION_MS + if (expirationMs < minAllowed) { + throw new KeeperSdkError( + `[${cmdName}] Share expiration must be at least 1 minute.`, + ResultCodes.NSF_SHARE_FAILED + ) + } +} + +export function parseShareExpiration(input: ParseShareExpirationInput): number | undefined { + const { expireAt, expireIn, expirationTimestamp, cmdName = 'nsf-share' } = input + const expireAtValue = expireAt?.trim() + const expireInValue = expireIn?.trim() + + if (expireAtValue && expireInValue) { + throw new KeeperSdkError( + `[${cmdName}] Cannot specify both expire-at and expire-in.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + + if (expirationTimestamp != null && (expireAtValue || expireInValue)) { + throw new KeeperSdkError( + `[${cmdName}] Cannot specify both expirationTimestamp and expire-at/expire-in.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + + if (expirationTimestamp != null) { + validateShareExpirationTimestamp(expirationTimestamp, cmdName) + return expirationTimestamp + } + + const raw = expireAtValue || expireInValue + if (!raw) return undefined + + if (raw.toLowerCase() === 'never') return -1 + + if (expireAtValue) { + const normalized = raw.replace('Z', '+00:00') + const dt = new Date(normalized) + if (Number.isNaN(dt.getTime())) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-at datetime '${raw}'.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + const expirationMs = dt.getTime() + validateShareExpirationTimestamp(expirationMs, cmdName) + return expirationMs + } + + const match = NSF_SHARE_EXPIRATION_RE.exec(raw) + if (!match) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-in period '${raw}'. Use e.g. 30d, 6mo, 1y, 24h, 30mi.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + + const amount = Number.parseInt(match[1], 10) + const unitMs = NSF_SHARE_EXPIRATION_UNIT_MS[match[2].toLowerCase()] + if (!unitMs) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-in period '${raw}'.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + + const expirationMs = Date.now() + amount * unitMs + validateShareExpirationTimestamp(expirationMs, cmdName) + return expirationMs +} + +export function parseShareExpirationValue( + value: string, + cmdName: string = 'nsf-share' +): number | undefined { + const raw = value.trim() + if (!raw) return undefined + if (NSF_SHARE_EXPIRATION_RE.test(raw)) { + return parseShareExpiration({ expireIn: raw, cmdName }) + } + return parseShareExpiration({ expireAt: raw, cmdName }) +} + +export function isShareExpirationNoop( + existingExpiration: number | undefined, + requestedExpiration: number | undefined +): boolean { + if (requestedExpiration == null) return true + if (requestedExpiration === -1) { + return existingExpiration == null || existingExpiration === -1 || existingExpiration === 0 + } + if (existingExpiration == null || existingExpiration === 0) return false + return Math.abs(existingExpiration - requestedExpiration) <= NSF_TLA_EXPIRATION_TOLERANCE_MS +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts new file mode 100644 index 00000000..3cd866f8 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts @@ -0,0 +1,708 @@ +import type { Auth, DKdFolderRecord } from '@keeper-security/keeperapi' +import { + Folder, + platform, + record, + recordsShareV3Message, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { + buildNsfRevokePermissionFromKeys, + buildNsfSharePermissionFromKeys, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, + preloadNsfUserShareKeys, + type UserShareKeys, +} from './nsfShareKeys' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + KeeperDriveKind, + fetchLiveRecordAccessesV3, + ensureNestedShareFolder, + getKeeperDriveFolder, + getKeeperDriveFolders, + getKeeperDriveRecord, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + resolveNsfFolderIdentifier, + resolveNsfRoleName, +} from './nsfHelpers' +import { NSF_RECORD_PERMISSION_ROLES, NSF_SHARE_BATCH_SIZE } from './nsfConstants' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import type { NsfLiveRecordAccessEntry } from './nsfHelpers' +import type { + NsfRecordPermissionActionInput, + NsfRecordPermissionFailure, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, +} from './nsfTypes' +import { NsfRecordPermissionAction } from './nsfTypes' + +type NsfRecordAccessEntry = NsfLiveRecordAccessEntry + +type NsfSharePermissionItem = { + recordUid: string + email: string + accessRoleType?: Folder.AccessRoleType + curRole?: string + newRole?: string +} + +type NsfShareOperationOutcome = { + recordUid: string + email: string + success: boolean + skipped?: boolean + message?: string +} + +type PermissionChangeBuckets = { + updates: NsfSharePermissionItem[] + creates: NsfSharePermissionItem[] + revokes: NsfSharePermissionItem[] + skipped: NsfRecordPermissionPlanItem[] +} + +function normalizeAction(action: NsfRecordPermissionActionInput): NsfRecordPermissionAction { + const value = action as NsfRecordPermissionAction + if (value === NsfRecordPermissionAction.Grant || value === NsfRecordPermissionAction.Revoke) { + return value + } + throw new KeeperSdkError( + `Invalid action '${action}'. Use: grant, revoke.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) +} + +function buildFolderRecordMap(storage: InMemoryStorage): Map> { + const map = new Map>() + for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { + const folderUid = entry.folderUid + if (!map.has(folderUid)) map.set(folderUid, new Set()) + map.get(folderUid)!.add(entry.recordUid) + } + return map +} + +function resolveFolderForPermission( + storage: InMemoryStorage, + folderInput?: string +): { folderUid: string | null; displayName: string } { + const trimmed = folderInput?.trim() + if (!trimmed) { + return { folderUid: null, displayName: 'root' } + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed) + if (!folderUid) { + throw new KeeperSdkError(`Folder "${trimmed}" not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, trimmed) + const folder = getKeeperDriveFolder(storage, folderUid) + return { + folderUid, + displayName: folder?.data.name || trimmed, + } +} + +export function collectNsfRecordUidsInFolder( + storage: InMemoryStorage, + folderUid: string | null, + recursive: boolean +): Set { + const folders = getKeeperDriveFolders(storage) + const folderUids = new Set(folders.map((folder) => folder.uid)) + const folderRecords = buildFolderRecordMap(storage) + const recordUids = new Set() + + const walk = (currentUid: string, visited = new Set()): void => { + if (visited.has(currentUid)) return + visited.add(currentUid) + for (const recordUid of folderRecords.get(currentUid) ?? []) { + recordUids.add(recordUid) + } + if (!recursive) return + for (const folder of folders) { + if (folder.parentUid !== currentUid) continue + if (visited.has(folder.uid)) continue + walk(folder.uid, visited) + } + } + + if (folderUid) { + walk(folderUid) + return recordUids + } + + for (const [fuid, records] of folderRecords.entries()) { + if (!folderUids.has(fuid)) { + for (const recordUid of records) recordUids.add(recordUid) + } + } + if (recursive) { + for (const folder of folders) { + walk(folder.uid) + } + } + return recordUids +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const recordEntry = getKeeperDriveRecord(storage, recordUid) + if (!recordEntry) return '' + return getRecordTitle(recordEntry).slice(0, 32) +} + + +async function getRecordAccessesV3( + auth: Auth, + storage: InMemoryStorage, + recordUids: string[] +): Promise<{ recordAccesses: NsfRecordAccessEntry[]; forbiddenRecords: string[] }> { + if (recordUids.length === 0) { + throw new KeeperSdkError('At least one record UID required.', ResultCodes.NSF_RECORD_PERMISSION_FAILED) + } + return fetchLiveRecordAccessesV3(auth, storage, recordUids) +} + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + return recordKey +} + +async function buildSharePermission( + storage: InMemoryStorage, + auth: Auth, + item: NsfSharePermissionItem, + userKeysByEmail: Map +): Promise { + const recordKey = await requireRecordKey(storage, auth, item.recordUid) + const emailKey = item.email.trim().toLowerCase() + let userKeys = userKeysByEmail.get(emailKey) + if (!userKeys) { + userKeys = await loadUserShareKeysOrInvite(auth, item.email, ResultCodes.NSF_RECORD_PERMISSION_FAILED) + userKeysByEmail.set(emailKey, userKeys) + } + return buildNsfSharePermissionFromKeys( + item.recordUid, + recordKey, + item.accessRoleType ?? Folder.AccessRoleType.VIEWER, + userKeys + ) +} + +async function buildRevokePermission( + item: NsfSharePermissionItem, + userKeysByEmail: Map +): Promise { + const emailKey = item.email.trim().toLowerCase() + const userKeys = userKeysByEmail.get(emailKey) + if (!userKeys) { + throw new KeeperSdkError( + `User ${item.email} not found`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } + return buildNsfRevokePermissionFromKeys(item.recordUid, userKeys) +} + +async function preloadUserShareKeys( + auth: Auth, + items: NsfSharePermissionItem[], + inviteMissing: boolean +): Promise> { + return preloadNsfUserShareKeys( + auth, + items.map((item) => item.email), + inviteMissing, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) +} + +async function executeShareBatch( + auth: Auth, + request: record.v3.sharing.IRequest, + statusField: 'updatedSharingStatus' | 'createdSharingStatus' | 'revokedSharingStatus' +): Promise> { + const response = await auth.executeRest(recordsShareV3Message(request)) + const statuses = response[statusField] ?? [] + return statuses.map((status) => parseRecordSharingStatus(status)) +} + +async function runShareBatch( + auth: Auth, + items: T[], + buildPermission: (item: T, userKeysByEmail: Map) => Promise, + applyToRequest: (request: record.v3.sharing.IRequest, permission: record.v3.sharing.IPermissions) => void, + statusField: 'updatedSharingStatus' | 'createdSharingStatus' | 'revokedSharingStatus', + inviteMissingKeys: boolean +): Promise { + const outcomes: NsfShareOperationOutcome[] = [] + + for (let index = 0; index < items.length; index += NSF_SHARE_BATCH_SIZE) { + const chunk = items.slice(index, index + NSF_SHARE_BATCH_SIZE) + const userKeysByEmail = await preloadUserShareKeys(auth, chunk, inviteMissingKeys) + const request: record.v3.sharing.IRequest = {} + const built: T[] = [] + + for (const item of chunk) { + try { + const permission = await buildPermission(item, userKeysByEmail) + applyToRequest(request, permission) + built.push(item) + } catch (err) { + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: false, + skipped: true, + message: extractErrorMessage(err), + }) + } + } + + if (built.length === 0) continue + + try { + const statusList = await executeShareBatch(auth, request, statusField) + for (let itemIndex = 0; itemIndex < built.length; itemIndex++) { + const item = built[itemIndex] + const status = statusList[itemIndex] + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: status?.success ?? false, + message: status?.message ?? 'No status returned', + }) + } + } catch (err) { + const message = extractErrorMessage(err) + for (const item of built) { + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: false, + message, + }) + } + } + } + + return outcomes +} + +async function batchUpdateRecordSharesV3( + storage: InMemoryStorage, + auth: Auth, + updates: NsfSharePermissionItem[] +): Promise { + return runShareBatch( + auth, + updates, + (item, userKeysByEmail) => buildSharePermission(storage, auth, item, userKeysByEmail), + (request, permission) => { + request.updateSharingPermissions = [...(request.updateSharingPermissions ?? []), permission] + }, + 'updatedSharingStatus', + true + ) +} + +async function batchCreateRecordSharesV3( + storage: InMemoryStorage, + auth: Auth, + creates: NsfSharePermissionItem[] +): Promise { + return runShareBatch( + auth, + creates, + (item, userKeysByEmail) => buildSharePermission(storage, auth, item, userKeysByEmail), + (request, permission) => { + request.createSharingPermissions = [...(request.createSharingPermissions ?? []), permission] + }, + 'createdSharingStatus', + true + ) +} + +async function batchUnshareRecordsV3( + auth: Auth, + revokes: NsfSharePermissionItem[] +): Promise { + return runShareBatch( + auth, + revokes, + (item, userKeysByEmail) => buildRevokePermission(item, userKeysByEmail), + (request, permission) => { + request.revokeSharingPermissions = [...(request.revokeSharingPermissions ?? []), permission] + }, + 'revokedSharingStatus', + false + ) +} + +function computePermissionChanges( + storage: InMemoryStorage, + accesses: NsfRecordAccessEntry[], + forbiddenRecords: string[], + recordUids: Set, + currentUser: string, + action: NsfRecordPermissionAction, + role: string | undefined, + accessRoleType: number | undefined +): PermissionChangeBuckets { + const updates: NsfSharePermissionItem[] = [] + const creates: NsfSharePermissionItem[] = [] + const revokes: NsfSharePermissionItem[] = [] + const skipped: NsfRecordPermissionPlanItem[] = [] + + const forbidden = new Set(forbiddenRecords) + const ownerFlags = new Map() + + for (const access of accesses) { + if (access.accessorName === currentUser) { + ownerFlags.set(access.recordUid, access.owner || access.canUpdateAccess) + } + } + + for (const recordUid of recordUids) { + if (!forbidden.has(recordUid)) continue + skipped.push({ + recordUid, + title: recordTitle(storage, recordUid), + email: '', + curRole: '', + reason: 'No access — record is forbidden', + }) + } + + for (const access of accesses) { + const recordUid = access.recordUid + if (!recordUids.has(recordUid) || access.owner) continue + + const email = access.accessorName + if (!email || email === currentUser) continue + + const curRole = getNsfAccessRoleLabel(access) + const isInherited = access.inherited + const title = recordTitle(storage, recordUid) + + if (!ownerFlags.get(recordUid)) { + skipped.push({ + recordUid, + title, + email, + curRole, + reason: 'Insufficient permission (can_update_access is false)', + }) + continue + } + + if (action === NsfRecordPermissionAction.Grant) { + if (curRole === role) continue + const entry: NsfSharePermissionItem = { + recordUid, + email, + curRole, + newRole: role, + accessRoleType, + } + if (isInherited) { + creates.push(entry) + } else { + updates.push(entry) + } + continue + } + + if (role && curRole !== role) continue + if (isInherited) { + skipped.push({ + recordUid, + title, + email, + curRole, + reason: 'Inherited from a shared folder — revoke at the parent shared folder', + }) + continue + } + revokes.push({ recordUid, email, curRole }) + } + + return { updates, creates, revokes, skipped } +} + +function planItemFromShare( + storage: InMemoryStorage, + item: NsfSharePermissionItem, + inherited = false +): NsfRecordPermissionPlanItem { + return { + recordUid: item.recordUid, + title: recordTitle(storage, item.recordUid), + email: item.email, + curRole: inherited ? `${item.curRole || ''} (inherited)`.trim() : item.curRole || '', + newRole: item.newRole, + } +} + +export function buildNsfRecordPermissionPlan( + storage: InMemoryStorage, + buckets: PermissionChangeBuckets +): NsfRecordPermissionPlan { + return { + grants: [ + ...buckets.updates.map((item) => planItemFromShare(storage, item)), + ...buckets.creates.map((item) => planItemFromShare(storage, item, true)), + ], + revokes: buckets.revokes.map((item) => ({ + recordUid: item.recordUid, + title: recordTitle(storage, item.recordUid), + email: item.email, + curRole: item.curRole || '', + })), + skipped: buckets.skipped, + } +} + +function formatPermissionPlanTable( + title: string, + headers: string[], + rows: string[][] +): string[] { + if (rows.length === 0) return [] + + const columnCount = headers.length + const widths = headers.map((header, columnIndex) => { + let width = header.length + for (const row of rows) { + width = Math.max(width, (row[columnIndex] ?? '').length) + } + return width + }) + const pad = (cell: string, columnIndex: number) => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]) => cells.map((cell, columnIndex) => pad(cell, columnIndex)).join(' ') + const rule = widths.map((width, columnIndex) => pad('-'.repeat(width), columnIndex)).join(' ') + + return [title, '', formatRow(headers), rule, ...rows.map((row) => formatRow(row)), ''] +} + +export function formatNsfRecordPermissionRequestHeader( + action: NsfRecordPermissionActionInput, + role: string | undefined, + folderDisplayName: string, + recursive: boolean +): string { + const normalizedAction = normalizeAction(action) + const scope = recursive ? 'and sub-folders' : 'only' + if (normalizedAction === NsfRecordPermissionAction.Grant) { + return `Request to GRANT "${role ?? ''}" permission(s) in "${folderDisplayName}" folder ${scope}` + } + const roleSuffix = role ? ` matching "${role}"` : '' + return `Request to REVOKE record permission(s)${roleSuffix} in "${folderDisplayName}" folder ${scope}` +} + +export function formatNsfRecordPermissionPlan(plan: NsfRecordPermissionPlan): string { + const lines: string[] = [] + + if (plan.skipped.length > 0) { + lines.push( + ...formatPermissionPlanTable( + 'SKIP — Record permission(s). Not permitted', + ['Record UID', 'Title', 'Email', 'Current Role', 'Reason'], + plan.skipped.map((item) => [ + item.recordUid, + item.title || '—', + item.email || '—', + item.curRole || '—', + item.reason || 'Unknown', + ]) + ) + ) + } + + if (plan.grants.length > 0) { + lines.push( + ...formatPermissionPlanTable( + 'GRANT Record permission(s)', + ['#', 'Record UID', 'Title', 'Email', 'Current Role', 'New Role'], + plan.grants.map((item, index) => [ + String(index + 1), + item.recordUid, + item.title || '—', + item.email, + item.curRole || '—', + item.newRole || '—', + ]) + ) + ) + lines.push('ALERT!!!') + lines.push('') + } + + if (plan.revokes.length > 0) { + lines.push( + ...formatPermissionPlanTable( + 'REVOKE — Record share(s)', + ['#', 'Record UID', 'Title', 'Email', 'Current Role'], + plan.revokes.map((item, index) => [ + String(index + 1), + item.recordUid, + item.title || '—', + item.email, + item.curRole || '—', + ]) + ) + ) + lines.push('ALERT!!!') + lines.push('') + } + + return lines.join('\n').trimEnd() +} + +function mapFailures( + outcomes: NsfShareOperationOutcome[], + defaultCode: string +): NsfRecordPermissionFailure[] { + return outcomes + .filter((outcome) => !outcome.success) + .map((outcome) => ({ + recordUid: outcome.recordUid, + email: outcome.email, + code: outcome.skipped ? 'skipped' : defaultCode, + message: outcome.message || 'Unknown error', + })) +} + +export async function updateNestedShareRecordPermissions( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordPermissionInput +): Promise { + const action = normalizeAction(input.action) + const recursive = input.recursive ?? false + const dryRun = input.dryRun ?? false + const normalizedRole = normalizeNsfRecordPermissionRole(input.role) + + if (action === NsfRecordPermissionAction.Grant) { + if (!normalizedRole) { + throw new KeeperSdkError('Role is required for grant action.', ResultCodes.NSF_RECORD_PERMISSION_FAILED) + } + if (!(NSF_RECORD_PERMISSION_ROLES as readonly string[]).includes(normalizedRole)) { + throw new KeeperSdkError( + `Invalid role '${input.role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } + } else if (input.role) { + normalizeNsfRecordPermissionRole(input.role) + } + + const accessRoleType = + action === NsfRecordPermissionAction.Grant && normalizedRole + ? resolveNsfRoleName(normalizedRole) + : undefined + + const { folderUid, displayName } = resolveFolderForPermission(storage, input.folder) + const recordUids = collectNsfRecordUidsInFolder(storage, folderUid, recursive) + if (recordUids.size === 0) { + throw new KeeperSdkError('No records found in the specified folder.', ResultCodes.NSF_NOT_FOUND) + } + + try { + const accessesResult = await getRecordAccessesV3(auth, storage, [...recordUids]) + const buckets = computePermissionChanges( + storage, + accessesResult.recordAccesses, + accessesResult.forbiddenRecords, + recordUids, + auth.username, + action, + normalizedRole, + accessRoleType + ) + const plan = buildNsfRecordPermissionPlan(storage, buckets) + + if (buckets.updates.length === 0 && buckets.creates.length === 0 && buckets.revokes.length === 0) { + return { + confirmed: false, + dryRun, + folderDisplayName: displayName, + plan, + grantFailures: [], + revokeFailures: [], + message: plan.skipped.length + ? 'No permission changes can be made.' + : 'No permission changes are needed.', + } + } + + if (dryRun || !input.force) { + return { + confirmed: false, + dryRun, + folderDisplayName: displayName, + plan, + grantFailures: [], + revokeFailures: [], + message: dryRun + ? undefined + : 'Confirmation required. Set force=true to proceed.', + } + } + + const grantOutcomes: NsfShareOperationOutcome[] = [] + if (buckets.updates.length > 0) { + grantOutcomes.push(...(await batchUpdateRecordSharesV3(storage, auth, buckets.updates))) + } + if (buckets.creates.length > 0) { + grantOutcomes.push(...(await batchCreateRecordSharesV3(storage, auth, buckets.creates))) + } + + const revokeOutcomes = + buckets.revokes.length > 0 ? await batchUnshareRecordsV3(auth, buckets.revokes) : [] + + return { + confirmed: true, + dryRun: false, + folderDisplayName: displayName, + plan, + grantFailures: mapFailures(grantOutcomes, 'error'), + revokeFailures: mapFailures(revokeOutcomes, 'error'), + message: 'Record permission changes applied.', + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to update nested share record permissions: ${extractErrorMessage(err)}`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } +} + +export function formatNsfRecordPermissionFailures( + failures: NsfRecordPermissionFailure[], + kind: 'GRANT' | 'REVOKE' +): string { + if (failures.length === 0) return '' + const lines = [`Failed to ${kind} — Record permission(s)`] + for (const failure of failures) { + lines.push(` ${failure.recordUid} ${failure.email} ${failure.code} ${failure.message}`) + } + return lines.join('\n') +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfShare.ts b/KeeperSdk/src/nestedShareFolders/nsfShare.ts new file mode 100644 index 00000000..a080ed3d --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShare.ts @@ -0,0 +1,799 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + normal64Bytes, + platform, + recordsShareV3Message, + folderAccessUpdateMessage, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { + buildNsfRecordSharePermission, + buildNsfRevokePermissionFromKeys, + loadUserShareKeys, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, +} from './nsfShareKeys' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { collectNsfRecordUidsInFolder } from './nsfRecordPermission' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import { transferNestedShareRecordOwnership } from './nsfTransferRecord' +import { getFolderPermissionsForRole } from './nsfConstants' +import { + checkFolderSharePermission, + checkRecordSharePermission, + collectExistingFolderShareTargets, + ensureNestedShareFolder, + ensureNestedShareRecord, + fetchLiveRecordAccessesV3, + findDirectUserRecordShare, + findUserRecordShareEntry, + findFolderAccessEntry, + loadShareUserMap, + getKeeperDriveRecord, + getNsfRecordPermissionRoleLabel, + isShareExpirationNoop, + parseShareExpiration, + requireAuthAccountUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, + resolveNsfRoleName, +} from './nsfHelpers' +import { + encryptNsfFolderKeyForTeam, + fetchNsfTeamPublicKeys, + resolveNsfShareRecipient, +} from './nsfTeamShare' +import type { + NsfDirectUserRecordShare, + NsfFolderAccessOperationResult, + NsfFolderShareActionInput, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfResolvedShareRecipient, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, +} from './nsfTypes' +import { NsfFolderShareAction, NsfRecordShareAction } from './nsfTypes' + +const SHARE_ERROR = ResultCodes.NSF_SHARE_FAILED + +function normalizeFolderAction(action: NsfFolderShareActionInput = NsfFolderShareAction.Grant): NsfFolderShareAction { + const value = action as NsfFolderShareAction + if (value === NsfFolderShareAction.Grant || value === NsfFolderShareAction.Remove) return value + throw new KeeperSdkError(`Invalid action '${action}'. Use: grant, remove.`, SHARE_ERROR) +} + +function normalizeRecordAction(action: NsfRecordShareActionInput = NsfRecordShareAction.Grant): NsfRecordShareAction { + const value = action as NsfRecordShareAction + if ( + value === NsfRecordShareAction.Grant || + value === NsfRecordShareAction.Revoke || + value === NsfRecordShareAction.Owner + ) { + return value + } + throw new KeeperSdkError(`Invalid action '${action}'. Use: grant, revoke, owner.`, SHARE_ERROR) +} + +function parseFolderAccessResult( + result: Folder.IFolderAccessResult | null | undefined, + folderUid: string, + recipient: string +): NsfFolderAccessOperationResult { + if (!result) { + return { folderUid, recipient, success: false, message: 'No folder access result returned' } + } + const status = result.status ?? Folder.FolderModifyStatus.SUCCESS + const statusName = Folder.FolderModifyStatus[status] ?? String(status) + return { + folderUid, + recipient, + success: status === Folder.FolderModifyStatus.SUCCESS, + message: result.message || statusName, + } +} + +function buildFolderAccessRemoveData( + folderUid: string, + accessTypeUid: Uint8Array, + accessType: Folder.AccessType +): Folder.IFolderAccessData { + return Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid, + accessType, + }) +} + +async function buildEncryptedFolderKeyForUser( + auth: Auth, + folderKey: Uint8Array, + email: string +): Promise { + const userKeys = await loadUserShareKeysOrInvite(auth, email, SHARE_ERROR) + if (userKeys.rsaPublicKey?.length) { + const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey) + return Folder.EncryptedDataKey.create({ + encryptedKey: platform.publicEncrypt(folderKey, rsaKeyBase64), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key, + }) + } + if (userKeys.eccPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: await platform.publicEncryptEC(folderKey, userKeys.eccPublicKey), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key_ecc, + }) + } + throw new KeeperSdkError(`No usable public key available for ${email}`, SHARE_ERROR) +} + +async function buildFolderAccessGrantData( + auth: Auth, + storage: InMemoryStorage, + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Promise { + const folderKey = await storage.getKeyBytes(folderUid) + if (!folderKey) { + throw new KeeperSdkError( + `Folder key not available for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + + const accessType = target.isTeam ? Folder.AccessType.AT_TEAM : Folder.AccessType.AT_USER + let accessTypeUid: Uint8Array + let encryptedFolderKey: Folder.IEncryptedDataKey + + if (target.isTeam) { + if (!target.accountUid?.length) { + throw new KeeperSdkError(`Team ${target.recipient} not found`, ResultCodes.TEAM_NOT_FOUND) + } + accessTypeUid = target.accountUid + const teamKeys = await fetchNsfTeamPublicKeys(auth, target.recipient, storage) + encryptedFolderKey = await encryptNsfFolderKeyForTeam(folderKey, teamKeys) + } else { + const userKeys = await loadUserShareKeysOrInvite(auth, target.recipient, SHARE_ERROR) + accessTypeUid = userKeys.accountUid + encryptedFolderKey = await buildEncryptedFolderKeyForUser(auth, folderKey, target.recipient) + } + + const data = Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid, + accessType, + accessRoleType, + permissions: getFolderPermissionsForRole(accessRoleType), + folderKey: encryptedFolderKey, + }) + + if (expirationTimestamp != null) { + data.tlaProperties = { expiration: expirationTimestamp } + } + + return data +} + +function buildFolderAccessUpdateData( + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Folder.IFolderAccessData { + if (!target.accountUid?.length) { + throw new KeeperSdkError( + target.isTeam + ? `Team ${target.recipient} not found` + : `User ${target.recipient} not found`, + target.isTeam ? ResultCodes.TEAM_NOT_FOUND : SHARE_ERROR + ) + } + + const data = Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid: target.accountUid, + accessType: target.isTeam ? Folder.AccessType.AT_TEAM : Folder.AccessType.AT_USER, + accessRoleType, + permissions: getFolderPermissionsForRole(accessRoleType), + }) + if (expirationTimestamp != null) { + data.tlaProperties = { expiration: expirationTimestamp } + } + return data +} + +async function resolveFolderShareTargets( + auth: Auth, + storage: InMemoryStorage, + folderUid: string, + recipients: string[], + currentUsername: string +): Promise { + const targets: NsfResolvedShareRecipient[] = [] + const seen = new Set() + + for (const raw of recipients) { + const trimmed = raw.trim() + if (!trimmed) continue + + if (trimmed === '@existing' || trimmed === '@current') { + for (const entry of collectExistingFolderShareTargets(storage, folderUid, currentUsername)) { + const key = `${entry.isTeam ? 'team' : 'user'}:${entry.recipient.toLowerCase()}` + if (seen.has(key)) continue + seen.add(key) + targets.push({ + recipient: entry.recipient, + isTeam: entry.isTeam, + accountUid: entry.accountUid ? normal64Bytes(entry.accountUid) : undefined, + }) + } + continue + } + + const classified = await resolveNsfShareRecipient(auth, trimmed) + if (!classified) continue + const key = `${classified.isTeam ? 'team' : 'user'}:${classified.recipient.toLowerCase()}` + if (seen.has(key)) continue + seen.add(key) + + if (!classified.isTeam) { + const userKeys = await loadUserShareKeysOrInvite(auth, classified.recipient, SHARE_ERROR) + targets.push({ + recipient: classified.recipient, + isTeam: false, + accountUid: userKeys.accountUid, + }) + } else { + targets.push(classified) + } + } + + return targets +} + +async function grantFolderAccess( + storage: InMemoryStorage, + auth: Auth, + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Promise { + const accessType = target.isTeam ? Folder.AccessType.AT_TEAM : Folder.AccessType.AT_USER + const accessTypeUid = target.accountUid ? webSafe64FromBytes(target.accountUid) : target.recipient + + const existing = findFolderAccessEntry(storage, folderUid, accessTypeUid, accessType) + if (existing && existing.accessRoleType === accessRoleType && expirationTimestamp == null) { + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: true, + actionTaken: 'already_had_access', + message: `Already has ${getNsfRecordPermissionRoleLabel(accessRoleType)} access`, + } + } + + const request = + existing != null + ? buildFolderAccessUpdateData(folderUid, target, accessRoleType, expirationTimestamp) + : await buildFolderAccessGrantData( + auth, + storage, + folderUid, + target, + accessRoleType, + expirationTimestamp + ) + + const response = await auth.executeRest( + folderAccessUpdateMessage({ + [existing != null ? 'folderAccessUpdates' : 'folderAccessAdds']: [request], + }) + ) + + const parsed = parseFolderAccessResult( + response.folderAccessResults?.[0], + folderUid, + target.recipient + ) + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: parsed.success, + actionTaken: existing != null ? 'updated' : 'granted', + message: parsed.message, + } +} + +async function removeFolderAccess( + auth: Auth, + folderUid: string, + target: NsfResolvedShareRecipient +): Promise { + const accessType = target.isTeam ? Folder.AccessType.AT_TEAM : Folder.AccessType.AT_USER + const accountUid = + target.accountUid ?? + (target.isTeam + ? normal64Bytes(target.recipient) + : (await loadUserShareKeysOrInvite(auth, target.recipient, SHARE_ERROR)).accountUid) + + const response = await auth.executeRest( + folderAccessUpdateMessage({ + folderAccessRemoves: [buildFolderAccessRemoveData(folderUid, accountUid, accessType)], + }) + ) + + const parsed = parseFolderAccessResult( + response.folderAccessResults?.[0], + folderUid, + target.recipient + ) + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: parsed.success, + actionTaken: 'removed', + message: parsed.message, + } +} + +export async function shareNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: ShareNestedShareFolderInput +): Promise { + const action = normalizeFolderAction(input.action) + const folders = input.folders.map((folder) => folder.trim()).filter(Boolean) + const recipients = input.recipients.map((recipient) => recipient.trim()).filter(Boolean) + + if (folders.length === 0) { + throw new KeeperSdkError('Folder path or UID is required.', SHARE_ERROR) + } + if (recipients.length === 0) { + throw new KeeperSdkError( + 'Recipient is required (email, team name/UID, or @existing).', + SHARE_ERROR + ) + } + + const role = input.role?.trim() || 'viewer' + const accessRoleType = + action === NsfFolderShareAction.Grant ? resolveNsfRoleName(role) : undefined + + const expirationTimestamp = + action === NsfFolderShareAction.Grant + ? parseShareExpiration({ + expireAt: input.expireAt, + expireIn: input.expireIn, + expirationTimestamp: input.expirationTimestamp, + cmdName: 'nsf-share-folder', + }) + : undefined + + const accountUid = requireAuthAccountUid(auth) + const results: NsfFolderShareResultItem[] = [] + + try { + for (const folderArg of folders) { + const folderUid = resolveNsfFolderIdentifier(storage, folderArg) + if (!folderUid) { + throw new KeeperSdkError(`No such folder: ${folderArg}`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, folderArg) + checkFolderSharePermission(storage, folderUid, auth.username, accountUid) + + const targets = await resolveFolderShareTargets( + auth, + storage, + folderUid, + recipients, + auth.username + ) + if (targets.length === 0) { + throw new KeeperSdkError( + `No share targets resolved for folder '${folderArg}'.`, + SHARE_ERROR + ) + } + + for (const target of targets) { + if (action === NsfFolderShareAction.Remove) { + results.push(await removeFolderAccess(auth, folderUid, target)) + } else { + results.push( + await grantFolderAccess( + storage, + auth, + folderUid, + target, + accessRoleType!, + expirationTimestamp + ) + ) + } + } + } + + return { results } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to share nested share folder: ${extractErrorMessage(err)}`, + SHARE_ERROR + ) + } +} + +function resolveRecordUids( + storage: InMemoryStorage, + recordArg: string, + recursive: boolean +): string[] { + const trimmed = recordArg.trim() + if (!trimmed) { + throw new KeeperSdkError('Record path or UID is required.', SHARE_ERROR) + } + + if (getKeeperDriveRecord(storage, trimmed)) { + ensureNestedShareRecord(storage, trimmed, trimmed) + return [trimmed] + } + + const recordUid = resolveNsfRecordIdentifier(storage, trimmed) + if (recordUid) { + ensureNestedShareRecord(storage, recordUid, trimmed) + return [recordUid] + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed) + if (folderUid) { + ensureNestedShareFolder(storage, folderUid, trimmed) + const recordUids = collectNsfRecordUidsInFolder(storage, folderUid, recursive) + if (recordUids.size === 0) { + throw new KeeperSdkError('No records found in the specified folder.', ResultCodes.NSF_NOT_FOUND) + } + return [...recordUids] + } + + throw new KeeperSdkError(`Record or folder '${trimmed}' not found`, ResultCodes.NSF_NOT_FOUND) +} + +function isShareUpdateNoop( + existing: NsfDirectUserRecordShare, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): boolean { + if (existing.accessRoleType !== accessRoleType) return false + return isShareExpirationNoop(existing.expiration, expirationTimestamp) +} + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + return recordKey +} + +async function transferRecordOwnership( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string +): Promise { + const result = await transferNestedShareRecordOwnership(storage, auth, recordUid, email) + return { + recordUid, + email, + success: result.success, + actionTaken: 'owner', + message: result.message, + } +} + +async function resolveUserRecordShareForRevoke( + auth: Auth, + storage: InMemoryStorage, + recordUid: string, + email: string +): Promise<{ hasDirectShare: boolean; inherited: boolean; userKeys: Awaited> }> { + const userKeys = await loadUserShareKeys(auth, email) + const accountUidStr = webSafe64FromBytes(userKeys.accountUid) + const shareUsers = await loadShareUserMap(auth, storage) + + const storageEntry = findUserRecordShareEntry(storage, recordUid, email, accountUidStr, shareUsers) + if (storageEntry) { + return { + hasDirectShare: !storageEntry.inherited, + inherited: !!storageEntry.inherited, + userKeys, + } + } + + const { recordAccesses } = await fetchLiveRecordAccessesV3(auth, storage, [recordUid]) + const liveEntry = recordAccesses.find( + (access) => + access.recordUid === recordUid && + !access.owner && + (access.accessTypeUid === accountUidStr || + access.accessorName.toLowerCase() === email.toLowerCase()) + ) + if (!liveEntry) { + return { hasDirectShare: false, inherited: false, userKeys } + } + return { + hasDirectShare: !liveEntry.inherited, + inherited: !!liveEntry.inherited, + userKeys, + } +} + +async function revokeRecordShare( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string +): Promise { + const shareState = await resolveUserRecordShareForRevoke(auth, storage, recordUid, email) + if (!shareState.hasDirectShare && !shareState.inherited) { + return { + recordUid, + email, + success: true, + actionTaken: 'no_access', + message: 'User does not have access to this record', + } + } + if (shareState.inherited) { + return { + recordUid, + email, + success: true, + actionTaken: 'skipped', + message: 'Access is inherited from a shared folder — revoke at the parent folder', + } + } + + const permission = buildNsfRevokePermissionFromKeys(recordUid, shareState.userKeys) + const response = await auth.executeRest( + recordsShareV3Message({ revokeSharingPermissions: [permission] }) + ) + const parsed = parseRecordSharingStatus(response.revokedSharingStatus?.[0]) + return { + recordUid, + email, + success: parsed.success, + actionTaken: 'revoke', + message: parsed.message, + } +} + +async function grantRecordShare( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Promise { + const existing = findDirectUserRecordShare(storage, recordUid, email) + if (existing && isShareUpdateNoop(existing, accessRoleType, expirationTimestamp)) { + return { + recordUid, + email, + success: true, + actionTaken: 'update', + message: 'Already has requested access', + } + } + + if (existing && expirationTimestamp != null && expirationTimestamp > 0) { + const revokeResult = await revokeRecordShare(storage, auth, recordUid, email) + if (!revokeResult.success) { + return { ...revokeResult, actionTaken: 'update' } + } + const recordKey = await requireRecordKey(storage, auth, recordUid) + const permission = await buildNsfRecordSharePermission( + auth, + recordUid, + recordKey, + email, + accessRoleType, + expirationTimestamp, + SHARE_ERROR + ) + const response = await auth.executeRest( + recordsShareV3Message({ createSharingPermissions: [permission] }) + ) + const parsed = parseRecordSharingStatus(response.createdSharingStatus?.[0]) + return { + recordUid, + email, + success: parsed.success, + actionTaken: 'grant', + message: parsed.message, + } + } + + const recordKey = await requireRecordKey(storage, auth, recordUid) + const permission = await buildNsfRecordSharePermission( + auth, + recordUid, + recordKey, + email, + accessRoleType, + expirationTimestamp, + SHARE_ERROR + ) + + const field = existing ? 'updateSharingPermissions' : 'createSharingPermissions' + const response = await auth.executeRest(recordsShareV3Message({ [field]: [permission] })) + const statusField = existing ? 'updatedSharingStatus' : 'createdSharingStatus' + const parsed = parseRecordSharingStatus(response[statusField]?.[0]) + + return { + recordUid, + email, + success: parsed.success, + actionTaken: existing ? 'update' : 'grant', + message: parsed.message, + } +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const recordEntry = getKeeperDriveRecord(storage, recordUid) + if (!recordEntry) return '' + return getRecordTitle(recordEntry).slice(0, 32) +} + +export function formatNsfRecordSharePlan(plan: NsfRecordSharePlanItem[], dryRun = false): string { + if (plan.length === 0) return 'No record share changes planned.' + const lines = ['Record share plan'] + for (const item of plan) { + let line = ` ${item.recordUid} ${item.title || '—'} ${item.email} ${item.action}${item.role ? ` ${item.role}` : ''}` + if (dryRun && item.expirationTimestamp != null) { + line += ` expires=${item.expirationTimestamp} ms` + } + lines.push(line) + } + return lines.join('\n') +} + +export function formatNsfRecordShareResults(results: NsfRecordShareResultItem[]): string { + if (results.length === 0) return '' + const lines: string[] = [] + for (const item of results) { + lines.push( + `${item.recordUid} ${item.email} ${item.actionTaken} ${item.success ? 'success' : 'failed'} ${item.message || ''}` + ) + } + return lines.join('\n') +} + +export function formatNsfFolderShareResults(results: NsfFolderShareResultItem[]): string { + if (results.length === 0) return '' + const lines: string[] = [] + for (const item of results) { + lines.push( + `${item.folderUid} ${item.recipient} ${item.actionTaken} ${item.success ? 'success' : 'failed'} ${item.message || ''}` + ) + } + return lines.join('\n') +} + +export async function shareNestedShareRecord( + storage: InMemoryStorage, + auth: Auth, + input: ShareNestedShareRecordInput +): Promise { + const action = normalizeRecordAction(input.action) + const emails = input.emails.map((email) => email.trim()).filter(Boolean) + const dryRun = input.dryRun ?? false + + if (!input.record?.trim()) { + throw new KeeperSdkError('Record path or UID is required.', SHARE_ERROR) + } + if (emails.length === 0) { + throw new KeeperSdkError('Recipient email is required.', SHARE_ERROR) + } + if (action === NsfRecordShareAction.Owner && emails.length > 1) { + throw new KeeperSdkError( + 'Ownership can only be transferred to a single account.', + SHARE_ERROR + ) + } + if (action === NsfRecordShareAction.Grant && !input.role?.trim()) { + throw new KeeperSdkError('Role is required for grant action.', SHARE_ERROR) + } + + const role = input.role?.trim() || 'viewer' + const accessRoleType = + action === NsfRecordShareAction.Grant ? resolveNsfRoleName(role) : undefined + + const expirationTimestamp = + action === NsfRecordShareAction.Grant + ? parseShareExpiration({ + expireAt: input.expireAt, + expireIn: input.expireIn, + expirationTimestamp: input.expirationTimestamp, + cmdName: 'nsf-share-record', + }) + : undefined + + const recordUids = resolveRecordUids(storage, input.record, input.recursive ?? false) + const accountUid = requireAuthAccountUid(auth) + + for (const recordUid of recordUids) { + checkRecordSharePermission(storage, recordUid, auth.username, accountUid) + } + + const plan: NsfRecordSharePlanItem[] = [] + for (const email of emails) { + for (const recordUid of recordUids) { + plan.push({ + recordUid, + title: recordTitle(storage, recordUid), + email, + action, + role: action === NsfRecordShareAction.Grant ? role : undefined, + expirationTimestamp, + }) + } + } + + if (dryRun) { + return { dryRun: true, plan, results: [] } + } + + const results: NsfRecordShareResultItem[] = [] + + try { + for (const email of emails) { + for (const recordUid of recordUids) { + if (action === NsfRecordShareAction.Owner) { + results.push(await transferRecordOwnership(storage, auth, recordUid, email)) + } else if (action === NsfRecordShareAction.Revoke) { + results.push(await revokeRecordShare(storage, auth, recordUid, email)) + } else { + results.push( + await grantRecordShare( + storage, + auth, + recordUid, + email, + accessRoleType!, + expirationTimestamp + ) + ) + } + } + } + + return { dryRun: false, plan, results } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to share nested share record: ${extractErrorMessage(err)}`, + SHARE_ERROR + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts b/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts new file mode 100644 index 00000000..40fa0fa7 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts @@ -0,0 +1,242 @@ +import type { Auth, Authentication } from '@keeper-security/keeperapi' +import { + Folder, + common, + getPublicKeysMessage, + normal64Bytes, + platform, + record, + sendShareInviteMessage, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError } from '../utils' + +const MISSING_PUBLIC_KEY = 'missing_public_key' + +export type UserShareKeys = { + username: string + accountUid: Uint8Array + rsaPublicKey: Uint8Array | null + eccPublicKey: Uint8Array | null +} + +function mapUserShareKeysEntry( + email: string, + entry: NonNullable[number] +): UserShareKeys | null { + if (!entry || entry.errorCode || !entry.accountUid?.length) return null + return { + username: entry.username || email, + accountUid: entry.accountUid as Uint8Array, + rsaPublicKey: entry.publicKey?.length ? (entry.publicKey as Uint8Array) : null, + eccPublicKey: entry.publicEccKey?.length ? (entry.publicEccKey as Uint8Array) : null, + } +} + +function dedupeNsfShareEmails(emails: string[]): string[] { + const seen = new Set() + const deduped: string[] = [] + for (const rawEmail of emails) { + const normalized = rawEmail.trim().toLowerCase() + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + deduped.push(normalized) + } + return deduped +} + +async function fetchUserShareKeys(auth: Auth, email: string): Promise { + const response = await auth.executeRest(getPublicKeysMessage({ usernames: [email] })) + const entry = response.keyResponses?.[0] + if (!entry) return null + return mapUserShareKeysEntry(email, entry) +} + +export async function loadUserShareKeys(auth: Auth, email: string): Promise { + const keys = await fetchUserShareKeys(auth, email) + if (!keys?.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, MISSING_PUBLIC_KEY) + } + return keys +} + +export async function loadUserShareKeysOrInvite( + auth: Auth, + email: string, + errorCode: string = MISSING_PUBLIC_KEY +): Promise { + let keys = await fetchUserShareKeys(auth, email) + if (!keys?.accountUid?.length) { + try { + await auth.executeRestAction(sendShareInviteMessage({ email })) + } catch (err) { + throw new KeeperSdkError( + `Failed to invite ${email}: ${extractErrorMessage(err)}`, + errorCode + ) + } + keys = await fetchUserShareKeys(auth, email) + } + if (!keys?.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode) + } + if (!keys.rsaPublicKey?.length && !keys.eccPublicKey?.length) { + throw new KeeperSdkError(`No usable public key available for ${email}`, errorCode) + } + return keys +} + +export async function loadNsfUserShareKeysMap( + auth: Auth, + emails: string[] +): Promise> { + const deduped = dedupeNsfShareEmails(emails) + const keysByEmail = new Map() + if (deduped.length === 0) return keysByEmail + + const response = await auth.executeRest(getPublicKeysMessage({ usernames: deduped })) + for (const entry of response.keyResponses ?? []) { + const email = (entry.username || '').trim().toLowerCase() + if (!email) continue + const keys = mapUserShareKeysEntry(email, entry) + if (keys) keysByEmail.set(email, keys) + } + return keysByEmail +} + +export async function preloadNsfUserShareKeys( + auth: Auth, + emails: string[], + inviteMissing: boolean, + errorCode: string +): Promise> { + const keysByEmail = await loadNsfUserShareKeysMap(auth, emails) + if (!inviteMissing) return keysByEmail + + const uniqueEmails = dedupeNsfShareEmails(emails) + for (const emailKey of uniqueEmails) { + let userKeys = keysByEmail.get(emailKey) + if (!userKeys || (!userKeys.rsaPublicKey?.length && !userKeys.eccPublicKey?.length)) { + userKeys = await loadUserShareKeysOrInvite(auth, emailKey, errorCode) + keysByEmail.set(emailKey, userKeys) + } + } + return keysByEmail +} + +export async function encryptKeyForRecipient( + key: Uint8Array, + userKeys: UserShareKeys +): Promise<{ encryptedKey: Uint8Array; useEccKey: boolean }> { + if (userKeys.eccPublicKey?.length) { + return { + encryptedKey: await platform.publicEncryptEC(key, userKeys.eccPublicKey), + useEccKey: true, + } + } + if (userKeys.rsaPublicKey?.length) { + const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey) + return { + encryptedKey: platform.publicEncrypt(key, rsaKeyBase64), + useEccKey: false, + } + } + throw new KeeperSdkError( + `No usable public key available for ${userKeys.username}`, + MISSING_PUBLIC_KEY + ) +} + +export function parseRecordSharingStatus( + status: record.v3.sharing.IStatus | null | undefined +): { recordUid: string; success: boolean; message: string } { + if (!status?.recordUid?.length) { + return { recordUid: '', success: false, message: 'No status returned' } + } + const recordUid = webSafe64FromBytes(status.recordUid) + const sharingStatus = status.status ?? record.v3.sharing.SharingStatus.SUCCESS + const statusName = record.v3.sharing.SharingStatus[sharingStatus] ?? String(sharingStatus) + const success = + sharingStatus === record.v3.sharing.SharingStatus.SUCCESS || + sharingStatus === record.v3.sharing.SharingStatus.PENDING_ACCEPT + return { recordUid, success, message: status.message || statusName } +} + +export async function buildNsfSharePermissionFromKeys( + recordUid: string, + recordKey: Uint8Array, + accessRoleType: Folder.AccessRoleType, + userKeys: UserShareKeys, + expirationTimestamp?: number +): Promise { + const { encryptedKey, useEccKey } = await encryptKeyForRecipient(recordKey, userKeys) + const recordUidBytes = normal64Bytes(recordUid) + const rules: Folder.IRecordAccessData = { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + owner: false, + accessRoleType, + } + if (expirationTimestamp != null) { + rules.tlaProperties = { expiration: expirationTimestamp } + if (expirationTimestamp > 0) { + rules.tlaProperties.timerNotificationType = common.tla.TimerNotificationType.NOTIFY_OWNER + } + } + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + recordKey: encryptedKey, + useEccKey, + rules, + } +} + +export function buildNsfRevokePermissionFromKeys( + recordUid: string, + userKeys: UserShareKeys +): record.v3.sharing.IPermissions { + const recordUidBytes = normal64Bytes(recordUid) + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + rules: { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + }, + } +} + +export async function buildNsfRecordSharePermission( + auth: Auth, + recordUid: string, + recordKey: Uint8Array, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, + errorCode: string = MISSING_PUBLIC_KEY +): Promise { + const userKeys = await loadUserShareKeysOrInvite(auth, email, errorCode) + return buildNsfSharePermissionFromKeys( + recordUid, + recordKey, + accessRoleType, + userKeys, + expirationTimestamp + ) +} + +export async function buildNsfRecordRevokePermission( + auth: Auth, + recordUid: string, + email: string, + errorCode: string = MISSING_PUBLIC_KEY +): Promise { + const userKeys = await loadUserShareKeys(auth, email) + if (!userKeys.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode) + } + return buildNsfRevokePermissionFromKeys(recordUid, userKeys) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts new file mode 100644 index 00000000..b81abfd4 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts @@ -0,0 +1,354 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Folder, folderRecordUpdateMessage, normal64Bytes, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { ListNsfFormat, type ListNsfFormatInput } from './nsfTypes' +import { + KeeperDriveKind, + checkFolderRemovePermission, + ensureNestedShareRecord, + getKeeperDriveRecord, + getFolderDisplayName, + isNestedShareFolder, + isRootFolderUid, + requireAuthAccountUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import type { DKdFolderRecord } from '@keeper-security/keeperapi' +import type { + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + ListNsfShortcutsOptions, + NsfShortcutRow, +} from './nsfTypes' + +const SHORTCUT_ERROR = ResultCodes.NSF_SHORTCUT_FAILED +const ROOT_FOLDER_LABEL = 'root' + +function isShortcutFolder(storage: InMemoryStorage, folderUid: string): boolean { + return isRootFolderUid(storage, folderUid) || isNestedShareFolder(storage, folderUid) +} + +function formatFolderLabel(storage: InMemoryStorage, folderUid: string): string { + if (isRootFolderUid(storage, folderUid)) { + return `root (${ROOT_FOLDER_LABEL})` + } + const name = getFolderDisplayName(storage, folderUid) + return `${name} (${folderUid})` +} + +export function getNsfRecordShortcuts(storage: InMemoryStorage): Map> { + const records = new Map>() + + for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { + const folderUid = entry.folderUid + if (!isShortcutFolder(storage, folderUid)) continue + if (!entry.recordUid) continue + + const folderSet = records.get(entry.recordUid) ?? new Set() + folderSet.add(folderUid) + records.set(entry.recordUid, folderSet) + } + + return new Map([...records.entries()].filter(([, folders]) => folders.size > 1)) +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const record = getKeeperDriveRecord(storage, recordUid) + if (!record) return '' + return getRecordTitle(record).slice(0, 32) +} + +function resolveShortcutRecordUids( + storage: InMemoryStorage, + target: string, + shortcuts: Map> +): Set { + const trimmed = target.trim() + if (!trimmed) return new Set() + + const byUid = shortcuts.get(trimmed) + if (byUid) return new Set([trimmed]) + + const recordUid = resolveNsfRecordIdentifier(storage, trimmed) + if (recordUid && shortcuts.has(recordUid)) { + return new Set([recordUid]) + } + + const lower = trimmed.toLowerCase() + const titleMatches = [...shortcuts.keys()].filter((uid) => { + const title = recordTitle(storage, uid) + return title.toLowerCase() === lower + }) + if (titleMatches.length === 1) return new Set(titleMatches) + if (titleMatches.length > 1) { + throw new KeeperSdkError( + `Multiple records matched "${trimmed}". Use a UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES + ) + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed) + if (folderUid) { + const matches = [...shortcuts.entries()] + .filter(([, folders]) => folders.has(folderUid)) + .map(([recordUid]) => recordUid) + return new Set(matches) + } + + if (recordUid) { + throw new KeeperSdkError( + `Record '${trimmed}' is not linked to multiple folders.`, + SHORTCUT_ERROR + ) + } + + throw new KeeperSdkError(`Target '${trimmed}' not found`, ResultCodes.NSF_NOT_FOUND) +} + +function collectShortcutRows( + storage: InMemoryStorage, + shortcuts: Map>, + target?: string +): NsfShortcutRow[] { + const recordUids = target?.trim() + ? resolveShortcutRecordUids(storage, target, shortcuts) + : new Set(shortcuts.keys()) + + return [...recordUids] + .sort((a, b) => recordTitle(storage, a).localeCompare(recordTitle(storage, b))) + .map((recordUid) => ({ + recordUid, + title: recordTitle(storage, recordUid), + folders: [...(shortcuts.get(recordUid) ?? [])] + .sort((a, b) => + formatFolderLabel(storage, a).localeCompare(formatFolderLabel(storage, b)) + ) + .map((folderUid) => formatFolderLabel(storage, folderUid)), + })) +} + +function escapeCsvCell(value: string): string { + if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"` + return value +} + +export function formatNsfShortcutTable(rows: NsfShortcutRow[]): string { + if (rows.length === 0) return 'No multi-folder records found.' + const headers = ['Record UID', 'Title', 'Folders'] + const tableRows = rows.map((row) => [row.recordUid, row.title, row.folders.join('; ')]) + const columnWidths = headers.map((header, index) => + Math.max(header.length, ...tableRows.map((cells) => (cells[index] || '').length)) + ) + const pad = (cell: string, index: number) => cell + ' '.repeat(columnWidths[index] - cell.length) + const formatRow = (cells: string[]) => cells.map((cell, index) => pad(cell, index)).join(' ') + const rule = columnWidths.map((width, index) => pad('-'.repeat(width), index)).join(' ') + return [formatRow(headers), rule, ...tableRows.map(formatRow)].join('\n') +} + +export function formatNsfShortcutCsv(rows: NsfShortcutRow[]): string { + const lines = ['record_uid,title,folders'] + for (const row of rows) { + lines.push( + [row.recordUid, row.title, row.folders.join('; ')] + .map(escapeCsvCell) + .join(',') + ) + } + return lines.join('\n') +} + +export function formatNsfShortcutJson(rows: NsfShortcutRow[]): string { + return JSON.stringify( + rows.map((row) => ({ + record_uid: row.recordUid, + title: row.title, + folders: row.folders, + })), + null, + 2 + ) +} + +export function formatNsfShortcutOutput( + rows: NsfShortcutRow[], + format: ListNsfFormatInput = ListNsfFormat.Table +): string { + const value = format as ListNsfFormat + if (value === ListNsfFormat.JSON) return formatNsfShortcutJson(rows) + if (value === ListNsfFormat.CSV) return formatNsfShortcutCsv(rows) + return formatNsfShortcutTable(rows) +} + +export function listNsfShortcuts( + storage: InMemoryStorage, + options: ListNsfShortcutsOptions = {} +): NsfShortcutRow[] { + const shortcuts = getNsfRecordShortcuts(storage) + return collectShortcutRows(storage, shortcuts, options.target) +} + +function resolveKeepFolderUid( + storage: InMemoryStorage, + folderArg: string | undefined, + defaultFolderUid: string | undefined +): string { + if (folderArg?.trim()) { + const folderUid = resolveNsfFolderIdentifier(storage, folderArg.trim()) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${folderArg}' not found`, ResultCodes.NSF_NOT_FOUND) + } + if (!isShortcutFolder(storage, folderUid)) { + throw new KeeperSdkError( + `Folder '${folderArg}' is not a nested share folder.`, + ResultCodes.NSF_LEGACY_FOLDER + ) + } + return folderUid + } + + if (defaultFolderUid && isNestedShareFolder(storage, defaultFolderUid)) { + return defaultFolderUid + } + + throw new KeeperSdkError( + 'Folder to keep record in is required (or set current folder to an NSF folder).', + ResultCodes.NSF_FOLDER_REQUIRED + ) +} + +function parseFolderRecordUpdateResult( + response: Folder.IFolderRecordUpdateResponse, + folderUid: string, + recordUid: string +): KeepNsfShortcutResultItem { + const result = response.folderRecordUpdateResult?.find((entry) => { + if (!entry.recordUid?.length) return true + return webSafe64FromBytes(entry.recordUid) === recordUid + }) ?? response.folderRecordUpdateResult?.[0] + + if (!result) { + return { + folderUid, + success: true, + message: 'Record unlinked from folder', + } + } + + const status = result.status ?? Folder.FolderModifyStatus.SUCCESS + const statusName = Folder.FolderModifyStatus[status] ?? String(status) + return { + folderUid, + success: status === Folder.FolderModifyStatus.SUCCESS, + message: result.message || statusName, + } +} + +async function unlinkRecordFromFolder( + auth: Auth, + folderUid: string, + recordUid: string +): Promise { + const response = await auth.executeRest( + folderRecordUpdateMessage({ + folderUid: normal64Bytes(folderUid), + removeRecords: [{ recordUid: normal64Bytes(recordUid) }], + }) + ) + return parseFolderRecordUpdateResult(response, folderUid, recordUid) +} + +export function formatKeepNsfShortcutPlan(plan: KeepNsfShortcutPlanItem): string { + const lines = [ + 'Shortcut keep plan', + ` Record: ${plan.recordUid} ${plan.title || '—'}`, + ` Keep in: ${plan.keepFolderLabel}`, + ] + if (plan.removeFolderLabels.length === 0) { + lines.push(' Remove from: (none)') + } else { + lines.push(' Remove from:') + for (const label of plan.removeFolderLabels) { + lines.push(` ${label}`) + } + } + return lines.join('\n') +} + +export async function keepNsfShortcut( + storage: InMemoryStorage, + auth: Auth, + input: KeepNsfShortcutInput, + defaultFolderUid?: string +): Promise { + const recordArg = input.record?.trim() + if (!recordArg) { + throw new KeeperSdkError('Record UID or title is required.', SHORTCUT_ERROR) + } + + const shortcuts = getNsfRecordShortcuts(storage) + const recordMatches = resolveShortcutRecordUids(storage, recordArg, shortcuts) + if (recordMatches.size !== 1) { + throw new KeeperSdkError( + `Record '${recordArg}' could not be resolved uniquely.`, + recordMatches.size === 0 ? SHORTCUT_ERROR : ResultCodes.MULTIPLE_NSF_MATCHES + ) + } + + const recordUid = [...recordMatches][0] + ensureNestedShareRecord(storage, recordUid, recordArg) + + const keepFolderUid = resolveKeepFolderUid(storage, input.folder, defaultFolderUid) + const folderSet = shortcuts.get(recordUid) + if (!folderSet || folderSet.size < 2) { + throw new KeeperSdkError( + `Record '${recordArg}' is not linked to multiple folders.`, + SHORTCUT_ERROR + ) + } + if (!folderSet.has(keepFolderUid)) { + throw new KeeperSdkError( + `Record '${recordArg}' is not in folder '${input.folder ?? keepFolderUid}'.`, + SHORTCUT_ERROR + ) + } + + const removeFolderUids = [...folderSet].filter((folderUid) => folderUid !== keepFolderUid) + const plan: KeepNsfShortcutPlanItem = { + recordUid, + title: recordTitle(storage, recordUid), + keepFolderUid, + keepFolderLabel: formatFolderLabel(storage, keepFolderUid), + removeFolderUids, + removeFolderLabels: removeFolderUids.map((folderUid) => formatFolderLabel(storage, folderUid)), + } + + if (removeFolderUids.length === 0) { + return { dryRun: input.dryRun ?? false, plan, results: [], nothingToDo: true } + } + + if (input.dryRun) { + return { dryRun: true, plan, results: [], nothingToDo: false } + } + + const accountUid = requireAuthAccountUid(auth) + const results: KeepNsfShortcutResultItem[] = [] + + try { + for (const folderUid of removeFolderUids) { + checkFolderRemovePermission(storage, folderUid, recordUid, auth.username, accountUid) + results.push(await unlinkRecordFromFolder(auth, folderUid, recordUid)) + } + return { dryRun: false, plan, results, nothingToDo: false } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to keep nested share record shortcut: ${extractErrorMessage(err)}`, + SHORTCUT_ERROR + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts b/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts new file mode 100644 index 00000000..974fd96f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts @@ -0,0 +1,212 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + getShareObjectsMessage, + normal64Bytes, + platform, + teamGetKeysCommand, + webSafe64FromBytes, + type TeamGetKeyEntry, + type TeamGetKeysResponse, + type Records, +} from '@keeper-security/keeperapi' +import * as crypto from 'crypto' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage, isValidEmail, logger } from '../utils' +import { TeamGetKeysResponseKeyType } from './nsfConstants' +import type { NsfResolvedShareRecipient, NsfTeamPublicKeys } from './nsfTypes' + +const SHARE_ERROR = ResultCodes.NSF_SHARE_FAILED + +function deriveRsaPublicKeyFromPkcs1PrivateKey(privateKeyDer: Uint8Array): Uint8Array { + const privateKey = crypto.createPrivateKey({ + key: Buffer.from(privateKeyDer), + format: 'der', + type: 'pkcs1', + }) + const publicKey = crypto.createPublicKey(privateKey) + return new Uint8Array(publicKey.export({ format: 'der', type: 'pkcs1' })) +} + +function hasAsymmetricTeamPublicKeys(keys: NsfTeamPublicKeys): boolean { + return Boolean(keys.rsaPublicKey?.length || keys.eccPublicKey?.length) +} + +async function decryptTeamGetKeysEntry( + auth: Auth, + entry: TeamGetKeyEntry +): Promise> { + if (!entry.key) return {} + + const keyBytes = normal64Bytes(entry.key) + const partial: Partial = {} + + switch (entry.type) { + case TeamGetKeysResponseKeyType.EccPublicKey: + partial.eccPublicKey = keyBytes + break + case TeamGetKeysResponseKeyType.RsaPublicKey: + partial.rsaPublicKey = keyBytes + break + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByDataKey: + if (auth.dataKey?.length) { + partial.aesTeamKey = await platform.aesCbcDecrypt(keyBytes, auth.dataKey, true) + } + break + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByRsa: + if (auth.privateKey?.length) { + partial.aesTeamKey = platform.privateDecrypt(keyBytes, auth.privateKey) + } + break + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByDataKeyGcm: + if (auth.dataKey?.length) { + partial.aesTeamKey = await platform.aesGcmDecrypt(keyBytes, auth.dataKey) + } + break + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByEcc: + if (auth.eccPrivateKey?.length) { + partial.aesTeamKey = await platform.privateDecryptEC(keyBytes, auth.eccPrivateKey) + } + break + } + + return partial +} + +async function parseTeamGetKeysApiResponse( + auth: Auth, + teamUid: string, + response: TeamGetKeysResponse +): Promise { + const keys: NsfTeamPublicKeys = {} + + for (const entry of response.keys ?? []) { + if (entry.team_uid && entry.team_uid !== teamUid) continue + Object.assign(keys, await decryptTeamGetKeysEntry(auth, entry)) + } + + return keys +} + +async function fetchNsfTeamPublicKeysFromVaultStorage( + storage: InMemoryStorage, + teamUid: string +): Promise { + const teamPrivateKey = await storage.getKeyBytes(`${teamUid}_priv`) + if (!teamPrivateKey?.length) return {} + + try { + return { rsaPublicKey: deriveRsaPublicKeyFromPkcs1PrivateKey(teamPrivateKey) } + } catch (err) { + logger.debug( + `Could not derive team RSA public key from vault storage for ${teamUid}: ${extractErrorMessage(err)}` + ) + return {} + } +} + +function findMatchingShareTeams( + teams: Records.IShareTeam[], + query: string +): Records.IShareTeam[] { + const lower = query.toLowerCase() + return teams.filter((team) => { + const uid = team.teamUid?.length ? webSafe64FromBytes(team.teamUid) : '' + const name = team.teamname?.trim() ?? '' + return uid === query || name.toLowerCase() === lower + }) +} + +export async function fetchNsfTeamPublicKeys( + auth: Auth, + teamUid: string, + storage?: InMemoryStorage +): Promise { + const trimmed = teamUid.trim() + if (!trimmed) { + throw new KeeperSdkError('Team UID is required.', ResultCodes.TEAM_NOT_FOUND) + } + + try { + const response = await auth.executeRestCommand(teamGetKeysCommand({ teams: [trimmed] })) + let keys = await parseTeamGetKeysApiResponse(auth, trimmed, response) + + if (!hasAsymmetricTeamPublicKeys(keys) && storage) { + const storageKeys = await fetchNsfTeamPublicKeysFromVaultStorage(storage, trimmed) + keys = { + rsaPublicKey: keys.rsaPublicKey ?? storageKeys.rsaPublicKey, + eccPublicKey: keys.eccPublicKey ?? storageKeys.eccPublicKey, + aesTeamKey: keys.aesTeamKey ?? storageKeys.aesTeamKey, + } + } + + if (!hasAsymmetricTeamPublicKeys(keys)) { + throw new KeeperSdkError( + `No public key found for team ${trimmed}.`, + ResultCodes.TEAM_NOT_FOUND + ) + } + + return keys + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to load team keys for ${trimmed}: ${extractErrorMessage(err)}`, + SHARE_ERROR + ) + } +} + +export async function encryptNsfFolderKeyForTeam( + folderKey: Uint8Array, + teamPublicKeys: NsfTeamPublicKeys +): Promise { + if (teamPublicKeys.rsaPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: platform.publicEncrypt( + folderKey, + platform.bytesToBase64(teamPublicKeys.rsaPublicKey) + ), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key, + }) + } + if (teamPublicKeys.eccPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: await platform.publicEncryptEC(folderKey, teamPublicKeys.eccPublicKey), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key_ecc, + }) + } + throw new KeeperSdkError('No public key found for team.', SHARE_ERROR) +} + +export async function resolveNsfShareRecipient( + auth: Auth, + recipient: string +): Promise { + const trimmed = recipient.trim() + if (!trimmed) return null + + if (isValidEmail(trimmed)) { + return { recipient: trimmed.toLowerCase(), isTeam: false } + } + + const response = await auth.executeRest(getShareObjectsMessage({})) + const teams = [...(response.shareTeams ?? []), ...(response.shareMCTeams ?? [])] + const matches = findMatchingShareTeams(teams, trimmed) + + if (matches.length === 1) { + const teamUid = webSafe64FromBytes(matches[0].teamUid!) + return { recipient: teamUid, isTeam: true, accountUid: matches[0].teamUid as Uint8Array } + } + if (matches.length > 1) { + throw new KeeperSdkError( + `Multiple teams match '${trimmed}'. Use the team UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES + ) + } + + throw new KeeperSdkError( + `Recipient '${trimmed}' could not be resolved as an email or team.`, + SHARE_ERROR + ) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts new file mode 100644 index 00000000..1e1f703c --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts @@ -0,0 +1,128 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Records, normal64Bytes, recordsTransferV3Message } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { encryptKeyForRecipient, loadUserShareKeysOrInvite } from './nsfShareKeys' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + checkRecordChangeOwnershipPermission, + ensureNestedShareRecord, + requireAuthAccountUid, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import type { + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, +} from './nsfTypes' + +const TRANSFER_ERROR = ResultCodes.NSF_TRANSFER_FAILED + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + return recordKey +} + +export async function transferNestedShareRecordOwnership( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + newOwnerEmail: string +): Promise { + const email = newOwnerEmail.trim() + if (!email) { + throw new KeeperSdkError('New owner email is required.', TRANSFER_ERROR) + } + + const recordKey = await requireRecordKey(storage, auth, recordUid) + const userKeys = await loadUserShareKeysOrInvite(auth, email, TRANSFER_ERROR) + const { encryptedKey, useEccKey } = await encryptKeyForRecipient(recordKey, userKeys) + + const response = await auth.executeRest( + recordsTransferV3Message({ + transferRecords: [ + Records.TransferRecord.create({ + username: email, + recordUid: normal64Bytes(recordUid), + recordKey: encryptedKey, + useEccKey, + }), + ], + }) + ) + + const status = response.transferRecordStatus?.[0] + const success = status?.status?.toLowerCase().includes('success') ?? false + return { + recordUid, + success, + message: status?.message || status?.status || 'Ownership transfer completed', + } +} + +export async function transferNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: TransferNestedShareRecordInput +): Promise { + const records = input.records.map((record) => record.trim()).filter(Boolean) + const newOwnerEmail = input.newOwnerEmail.trim() + + if (records.length === 0) { + throw new KeeperSdkError('At least one record UID or title is required.', TRANSFER_ERROR) + } + if (!newOwnerEmail) { + throw new KeeperSdkError('New owner email is required.', TRANSFER_ERROR) + } + + const results: TransferNestedShareRecordResultItem[] = [] + const accountUid = requireAuthAccountUid(auth) + + try { + for (const identifier of records) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + checkRecordChangeOwnershipPermission(storage, recordUid, auth.username, accountUid) + results.push( + await transferNestedShareRecordOwnership(storage, auth, recordUid, newOwnerEmail) + ) + } + + return { + results, + success: results.every((item) => item.success), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to transfer nested share record ownership: ${extractErrorMessage(err)}`, + TRANSFER_ERROR + ) + } +} + +export function formatTransferNestedShareRecordResults( + results: TransferNestedShareRecordResultItem[] +): string { + if (results.length === 0) return '' + const lines: string[] = [] + for (const item of results) { + lines.push( + `${item.recordUid} ${item.success ? 'success' : 'failed'} ${item.message}` + ) + } + return lines.join('\n') +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts index a8bad745..842065da 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -401,3 +401,228 @@ export function resolveRecordPermissionRole(entry: NsfRecordPermission): NsfAcce if (entry.role) return entry.role return NsfAccessRoleLabel.ContentManager } + +export enum NsfFolderShareAction { + Grant = 'grant', + Remove = 'remove', +} + +export type NsfTeamPublicKeys = { + rsaPublicKey?: Uint8Array + eccPublicKey?: Uint8Array + aesTeamKey?: Uint8Array +} + +export type NsfResolvedShareRecipient = { + recipient: string + isTeam: boolean + accountUid?: Uint8Array +} + +export type NsfFolderAccessOperationResult = { + folderUid: string + recipient: string + success: boolean + message: string +} + +export type NsfDirectUserRecordShare = { + recordUid: string + email: string + accessRoleType: number + expiration?: number +} + +export type NsfFolderShareActionInput = NsfFolderShareAction | `${NsfFolderShareAction}` + +export type ParseShareExpirationInput = { + expireAt?: string + expireIn?: string + expirationTimestamp?: number + cmdName?: string +} + +export type ShareNestedShareFolderInput = { + folders: string[] + recipients: string[] + action?: NsfFolderShareActionInput + role?: string + expireAt?: string + expireIn?: string + expirationTimestamp?: number +} + +export type NsfFolderShareResultItem = { + folderUid: string + recipient: string + isTeam: boolean + success: boolean + actionTaken: string + message?: string +} + +export type ShareNestedShareFolderResult = { + results: NsfFolderShareResultItem[] +} + +export enum NsfRecordShareAction { + Grant = 'grant', + Revoke = 'revoke', + Owner = 'owner', +} + +export type NsfRecordShareActionInput = NsfRecordShareAction | `${NsfRecordShareAction}` + +export type ShareNestedShareRecordInput = { + record: string + emails: string[] + action?: NsfRecordShareActionInput + role?: string + recursive?: boolean + dryRun?: boolean + /** Absolute expiration (ISO datetime or `never`). Mutually exclusive with expireIn. */ + expireAt?: string + /** Relative expiration (e.g. `30d`, `6mo`, `1y`, `24h`, `30mi`, or `never`). Mutually exclusive with expireAt. */ + expireIn?: string + /** Pre-parsed expiration in milliseconds; `-1` = never. Mutually exclusive with expireAt/expireIn. */ + expirationTimestamp?: number +} + +export type NsfRecordSharePlanItem = { + recordUid: string + title: string + email: string + action: string + role?: string + expirationTimestamp?: number +} + +export type NsfRecordShareResultItem = { + recordUid: string + email: string + success: boolean + actionTaken: string + message?: string +} + +export type ShareNestedShareRecordResult = { + dryRun: boolean + plan: NsfRecordSharePlanItem[] + results: NsfRecordShareResultItem[] +} + +export enum NsfRecordPermissionAction { + Grant = 'grant', + Revoke = 'revoke', +} + +export type NsfRecordPermissionActionInput = NsfRecordPermissionAction | `${NsfRecordPermissionAction}` + +export type UpdateNsfRecordPermissionInput = { + folder?: string + action: NsfRecordPermissionActionInput + role?: string + recursive?: boolean + dryRun?: boolean + force?: boolean +} + +export type NsfRecordPermissionPlanItem = { + recordUid: string + title: string + email: string + curRole: string + newRole?: string + reason?: string +} + +export type NsfRecordPermissionPlan = { + grants: NsfRecordPermissionPlanItem[] + revokes: NsfRecordPermissionPlanItem[] + skipped: NsfRecordPermissionPlanItem[] +} + +export type NsfRecordPermissionFailure = { + recordUid: string + email: string + code: string + message: string +} + +export type UpdateNsfRecordPermissionResult = { + confirmed: boolean + dryRun: boolean + folderDisplayName: string + plan: NsfRecordPermissionPlan + grantFailures: NsfRecordPermissionFailure[] + revokeFailures: NsfRecordPermissionFailure[] + message?: string +} + +export type NsfShortcutRow = { + recordUid: string + title: string + folders: string[] +} + +export type ListNsfShortcutsOptions = { + target?: string + format?: ListNsfFormatInput +} + +export type KeepNsfShortcutInput = { + record: string + folder?: string + dryRun?: boolean +} + +export type KeepNsfShortcutPlanItem = { + recordUid: string + title: string + keepFolderUid: string + keepFolderLabel: string + removeFolderUids: string[] + removeFolderLabels: string[] +} + +export type KeepNsfShortcutResultItem = { + folderUid: string + success: boolean + message: string +} + +export type KeepNsfShortcutResult = { + dryRun: boolean + plan: KeepNsfShortcutPlanItem + results: KeepNsfShortcutResultItem[] + nothingToDo: boolean +} + +export type TransferNestedShareRecordInput = { + records: string[] + newOwnerEmail: string +} + +export type TransferNestedShareRecordResultItem = { + recordUid: string + success: boolean + message: string +} + +export type TransferNestedShareRecordResult = { + results: TransferNestedShareRecordResultItem[] + success: boolean +} + +export type UpdateNsfFolderInput = { + folder: string + name?: string + color?: NsfFolderColorInput + quiet?: boolean +} + +export type UpdateNsfFolderResult = { + folderUid: string + updated: boolean + message?: string +} diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts new file mode 100644 index 00000000..177620f2 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts @@ -0,0 +1,160 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Folder, folderUpdateMessage, normal64Bytes, platform } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_FOLDER_COLORS, type NsfFolderColor } from './nsfConstants' +import { + checkFolderEditPermission, + ensureNestedShareFolder, + getFolderDisplayName, + getKeeperDriveFolder, + parseFolderModifyStatus, + patchNsfFolderMetadata, + requireAuthAccountUid, + resolveNsfFolderIdentifier, +} from './nsfHelpers' +import type { NsfFolderColorInput, UpdateNsfFolderInput, UpdateNsfFolderResult } from './nsfTypes' + +type NsfFolderMetadata = { + name: string + color?: string +} + +function normalizeColor(color: NsfFolderColorInput): NsfFolderColor { + if (!(NSF_FOLDER_COLORS as readonly string[]).includes(color)) { + throw new KeeperSdkError( + `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(', ')}.`, + ResultCodes.NSF_UPDATE_FAILED + ) + } + return color +} + +function readExistingMetadata(folderUid: string, storage: InMemoryStorage): NsfFolderMetadata { + const folder = getKeeperDriveFolder(storage, folderUid) + if (!folder) { + throw new KeeperSdkError(`Folder '${folderUid}' not found`, ResultCodes.NSF_NOT_FOUND) + } + const data = folder.data as NsfFolderMetadata + return { + name: data.name || '', + color: data.color, + } +} + +function mergeFolderMetadata( + existing: NsfFolderMetadata, + newName: string | undefined, + color: NsfFolderColor | undefined +): NsfFolderMetadata { + const metadata: NsfFolderMetadata = { + name: newName !== undefined ? newName : existing.name, + } + if (color !== undefined) { + if (color !== 'none') metadata.color = color + } else if (existing.color && existing.color !== 'none') { + metadata.color = existing.color + } + return metadata +} + +async function buildUpdateFolderData( + storage: InMemoryStorage, + folderUid: string, + metadata: NsfFolderMetadata +): Promise { + const folderKey = await storage.getKeyBytes(folderUid) + if (!folderKey) { + throw new KeeperSdkError( + `Folder key not available for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + + const encryptedData = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(metadata)), + folderKey + ) + + return Folder.FolderData.create({ + folderUid: normal64Bytes(folderUid), + data: encryptedData, + }) +} + +function buildSuccessMessage( + folderDisplayName: string, + newName: string | undefined, + color: NsfFolderColor | undefined, + quiet?: boolean +): string | undefined { + if (quiet) return undefined + if (newName !== undefined) { + return `Folder "${folderDisplayName}" has been renamed to "${newName}".` + } + if (color !== undefined) { + return `Folder "${folderDisplayName}" color has been updated.` + } + return `Folder "${folderDisplayName}" has been updated.` +} + +export async function updateNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfFolderInput +): Promise { + const folderArg = input.folder?.trim() + if (!folderArg) { + throw new KeeperSdkError('Enter the path or UID of existing folder.', ResultCodes.NSF_UPDATE_FAILED) + } + + let newName = input.name + if (newName !== undefined) { + newName = newName.trim() + if (!newName) { + throw new KeeperSdkError('Folder name cannot be empty', ResultCodes.NSF_UPDATE_FAILED) + } + } + + const color = input.color !== undefined ? normalizeColor(input.color) : undefined + if (newName === undefined && color === undefined) { + throw new KeeperSdkError( + 'New folder name and/or color parameters are required.', + ResultCodes.NSF_UPDATE_FAILED + ) + } + + const folderUid = resolveNsfFolderIdentifier(storage, folderArg) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${folderArg}' not found`, ResultCodes.NSF_NOT_FOUND) + } + + ensureNestedShareFolder(storage, folderUid, folderArg) + const accountUid = requireAuthAccountUid(auth) + checkFolderEditPermission(storage, folderUid, auth.username, accountUid) + + const folderDisplayName = getFolderDisplayName(storage, folderUid) + + try { + const existing = readExistingMetadata(folderUid, storage) + const metadata = mergeFolderMetadata(existing, newName, color) + const folderData = await buildUpdateFolderData(storage, folderUid, metadata) + + const response = await auth.executeRest(folderUpdateMessage({ folderData: [folderData] })) + parseFolderModifyStatus(response.folderUpdateResults?.[0], ResultCodes.NSF_UPDATE_FAILED) + + await patchNsfFolderMetadata(storage, folderUid, metadata) + + return { + folderUid, + updated: true, + message: buildSuccessMessage(folderDisplayName, newName, color, input.quiet), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to update nested share folder: ${extractErrorMessage(err)}`, + ResultCodes.NSF_UPDATE_FAILED + ) + } +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 6146d3ac..f9062376 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -70,6 +70,10 @@ export enum NsfErrorCode { UpdateFailed = 'nsf_update_failed', DetailsFailed = 'nsf_details_failed', AddFailed = 'nsf_add_failed', + ShareFailed = 'nsf_share_failed', + ShortcutFailed = 'nsf_shortcut_failed', + TransferFailed = 'nsf_transfer_failed', + RecordPermissionFailed = 'nsf_record_permission_failed', } export enum TeamErrorCode { @@ -180,6 +184,10 @@ export const ResultCodes = { NSF_UPDATE_FAILED: NsfErrorCode.UpdateFailed, NSF_DETAILS_FAILED: NsfErrorCode.DetailsFailed, NSF_ADD_FAILED: NsfErrorCode.AddFailed, + NSF_SHARE_FAILED: NsfErrorCode.ShareFailed, + NSF_SHORTCUT_FAILED: NsfErrorCode.ShortcutFailed, + NSF_TRANSFER_FAILED: NsfErrorCode.TransferFailed, + NSF_RECORD_PERMISSION_FAILED: NsfErrorCode.RecordPermissionFailed, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 815ecd3d..5c76f341 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -94,6 +94,18 @@ import { } from '../nestedShareFolders/listNsf' import { formatNsfDetail } from '../nestedShareFolders/getNsf' import { formatRemoveNsfPreview } from '../nestedShareFolders/removeNsfRecord' +import { + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, +} from '../nestedShareFolders/nsfShare' +import { + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, +} from '../nestedShareFolders/nsfRecordPermission' +import { formatNsfShortcutOutput, formatKeepNsfShortcutPlan } from '../nestedShareFolders/nsfShortcut' +import { formatTransferNestedShareRecordResults } from '../nestedShareFolders/nsfTransferRecord' import type { AddNsfRecordInput, AddNsfRecordResult, @@ -103,20 +115,34 @@ import type { GetNsfResult, GetNsfRecordDetailsInput, GetNsfRecordDetailsResult, + KeepNsfShortcutInput, + KeepNsfShortcutResult, LinkNsfRecordResult, ListNsfFormatInput, ListNsfOptions, ListNsfRow, FormattedListNsfTable, + ListNsfShortcutsOptions, MkdirNsfInput, MkdirNsfResult, + NsfShortcutRow, RemoveNsfFolderInput, RemoveNsfFolderResult, RemoveNsfRecordInput, RemoveNsfRecordResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + UpdateNsfFolderInput, + UpdateNsfFolderResult, UpdateNsfRecordInput, UpdateNsfRecordItemInput, UpdateNsfRecordsInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from '../nestedShareFolders/nsfTypes' @@ -869,6 +895,104 @@ export class KeeperVault { return result } + public async updateNestedShareFolder(input: UpdateNsfFolderInput): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareFolder(input) + if (result.updated) await this.syncIfNeeded() + return result + } + + public async shareNestedShareFolder( + input: ShareNestedShareFolderInput + ): Promise { + const result = await this.nestedShareFolderManager.shareNestedShareFolder(input) + if (result.results.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public async shareNestedShareRecord( + input: ShareNestedShareRecordInput + ): Promise { + const result = await this.nestedShareFolderManager.shareNestedShareRecord(input) + if (!result.dryRun && result.results.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public formatNsfRecordSharePlan(result: ShareNestedShareRecordResult): string { + return formatNsfRecordSharePlan(result.plan, result.dryRun) + } + + public formatNsfRecordShareResults(results: ShareNestedShareRecordResult['results']): string { + return formatNsfRecordShareResults(results) + } + + public formatNsfFolderShareResults(results: ShareNestedShareFolderResult['results']): string { + return formatNsfFolderShareResults(results) + } + + public listNsfShortcuts(options?: ListNsfShortcutsOptions): NsfShortcutRow[] { + this.getAuthOrThrow() + return this.nestedShareFolderManager.listNsfShortcuts(options ?? {}) + } + + public formatNsfShortcutOutput(rows: NsfShortcutRow[], format?: ListNsfShortcutsOptions['format']): string { + return formatNsfShortcutOutput(rows, format) + } + + public async keepNsfShortcut(input: KeepNsfShortcutInput): Promise { + const current = this.folderSession.currentFolderUid + const defaultFolderUid = + current && isNestedShareFolder(this.storage, current) ? current : undefined + const result = await this.nestedShareFolderManager.keepNsfShortcut(input, defaultFolderUid) + if (!result.dryRun && result.results.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public formatKeepNsfShortcutPlan(result: KeepNsfShortcutResult): string { + return formatKeepNsfShortcutPlan(result.plan) + } + + public async transferNestedShareRecords( + input: TransferNestedShareRecordInput + ): Promise { + const result = await this.nestedShareFolderManager.transferNestedShareRecords(input) + if (result.success) await this.syncIfNeeded() + return result + } + + public formatTransferNestedShareRecordResults( + results: TransferNestedShareRecordResult['results'] + ): string { + return formatTransferNestedShareRecordResults(results) + } + + public async updateNestedShareRecordPermissions( + input: UpdateNsfRecordPermissionInput + ): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareRecordPermissions(input) + if (result.confirmed) await this.syncIfNeeded() + return result + } + + public formatNsfRecordPermissionPlan(result: UpdateNsfRecordPermissionResult): string { + return formatNsfRecordPermissionPlan(result.plan) + } + + public formatNsfRecordPermissionRequestHeader( + action: UpdateNsfRecordPermissionInput['action'], + role: string | undefined, + folderDisplayName: string, + recursive: boolean + ): string { + return formatNsfRecordPermissionRequestHeader(action, role, folderDisplayName, recursive) + } + + public formatNsfRecordPermissionFailures( + failures: UpdateNsfRecordPermissionResult['grantFailures'], + kind: 'GRANT' | 'REVOKE' + ): string { + return formatNsfRecordPermissionFailures(failures, kind) + } + public async shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index d8d97c80..4eef4b9c 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -379,6 +379,24 @@ export const teamEnterpriseUserRemoveCommand = ( request: TeamUserCommandRequest ): RestCommand => createCommand(request, 'team_enterprise_user_remove') +export type TeamGetKeysRequest = { + teams: string[] +} + +export type TeamGetKeyEntry = { + team_uid?: string + key?: string + type?: number +} + +export type TeamGetKeysResponse = KeeperResponse & { + keys?: TeamGetKeyEntry[] +} + +export const teamGetKeysCommand = ( + request: TeamGetKeysRequest +): RestCommand => createCommand(request, 'team_get_keys') + export type GetRecordHistoryRequest = { record_uid: string client_time: number From 669ad38ec26297e7aee177f1ebc9710b6ff510d8 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Fri, 10 Jul 2026 14:15:58 +0530 Subject: [PATCH 3/3] formating fix --- keeperapi/src/commands.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index 4eef4b9c..dcc38de2 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -393,9 +393,8 @@ export type TeamGetKeysResponse = KeeperResponse & { keys?: TeamGetKeyEntry[] } -export const teamGetKeysCommand = ( - request: TeamGetKeysRequest -): RestCommand => createCommand(request, 'team_get_keys') +export const teamGetKeysCommand = (request: TeamGetKeysRequest): RestCommand => + createCommand(request, 'team_get_keys') export type GetRecordHistoryRequest = { record_uid: string