From a9c6dfb453c06ded218352c896c6eef1aa93a4b2 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 16 Jun 2026 23:06:05 +0530 Subject: [PATCH 1/2] NSF command implementation (list, get, ln and rm) (#169) --- KeeperSdk/package-lock.json | 8 +- KeeperSdk/package.json | 2 +- KeeperSdk/src/index.ts | 54 ++ .../NestedShareFolderManager.ts | 97 ++++ KeeperSdk/src/nestedShareFolders/getNsf.ts | 500 ++++++++++++++++++ KeeperSdk/src/nestedShareFolders/index.ts | 77 +++ .../src/nestedShareFolders/linkNsfRecord.ts | 139 +++++ KeeperSdk/src/nestedShareFolders/listNsf.ts | 173 ++++++ .../src/nestedShareFolders/nsfConstants.ts | 56 ++ .../src/nestedShareFolders/nsfHelpers.ts | 326 ++++++++++++ .../src/nestedShareFolders/removeNsfRecord.ts | 237 +++++++++ KeeperSdk/src/storage/InMemoryStorage.ts | 4 + KeeperSdk/src/utils/constants.ts | 36 +- KeeperSdk/src/utils/index.ts | 1 + KeeperSdk/src/vault/KeeperVault.ts | 55 ++ examples/sdk_example/package.json | 4 + .../src/nestedShareFolders/get_nsf.ts | 51 ++ .../src/nestedShareFolders/link_nsf.ts | 44 ++ .../src/nestedShareFolders/list_nsf.ts | 69 +++ .../src/nestedShareFolders/remove_nsf.ts | 119 +++++ 20 files changed, 2043 insertions(+), 9 deletions(-) create mode 100644 KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts create mode 100644 KeeperSdk/src/nestedShareFolders/getNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/index.ts create mode 100644 KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts create mode 100644 KeeperSdk/src/nestedShareFolders/listNsf.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfConstants.ts create mode 100644 KeeperSdk/src/nestedShareFolders/nsfHelpers.ts create mode 100644 KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/get_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/link_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/list_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/remove_nsf.ts diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 0eed8f14..99c62392 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "17.2.3", + "@keeper-security/keeperapi": "17.2.7", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, @@ -56,9 +56,9 @@ } }, "node_modules/@keeper-security/keeperapi": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.2.3.tgz", - "integrity": "sha512-RT1ZnvfonFrPuLij8mPso/1eyTzurm2ikJxydzOA7oLQlqNRcH2FRramCan0sN85U5RNnM5QS05NVaJq4K72pg==", + "version": "17.2.7", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.2.7.tgz", + "integrity": "sha512-VRCRn6Y2sqxpjQHSSEOgo4qiJpx8XExvEgq9oqhBH5XX2Z8GTs4sZ2vyYN5Kaa3WibGSfXvezwdzATo5vZwVEA==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 7a1fe816..a6e66e17 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -21,7 +21,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "17.2.6", + "@keeper-security/keeperapi": "17.2.7", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 5e93ce18..317c7da8 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -25,6 +25,7 @@ export { extractResultCode, SdkDefaults, AuthDefaults, + NsfErrorCode, ResultCodes, AuthErrorCode, SessionErrorCode, @@ -443,6 +444,59 @@ export { export { UserManager } from './users/UserManager' +export { + ROOT_FOLDER_UID, + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + ListNsfFormat, + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, + GetNsfFormat, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, + linkNestedShareRecord, + NsfRemoveOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, + NestedShareFolderManager, +} from './nestedShareFolders' +export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, + LinkNsfRecordResult, + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, +} from './nestedShareFolders' + export type { DRecord, DRecordMetadata, diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts new file mode 100644 index 00000000..4a21c74f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -0,0 +1,97 @@ +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' + +export type AuthProvider = () => Auth + +export class NestedShareFolderManager { + private readonly storage: InMemoryStorage + private readonly authProvider: AuthProvider + + constructor(storage: InMemoryStorage, authProvider: AuthProvider) { + this.storage = storage + this.authProvider = authProvider + } + + private requireAuth(): Auth { + const auth = this.authProvider() + if (!auth?.sessionToken) { + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + } + return auth + } + + public listNestedShareFolders(options: ListNsfOptions = {}): ListNsfRow[] { + return listNestedShareFolders(this.storage, options) + } + + public formatListNsfTable(rows: ListNsfRow[], options: { columnWidth?: number } = {}): FormattedListNsfTable { + return formatListNsfTable(rows, options) + } + + public renderListNsfAsciiTable(table: FormattedListNsfTable, options: { minColWidth?: number } = {}): string { + return renderListNsfAsciiTable(table, options) + } + + public formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = 'table'): string { + return formatListNsfOutput(rows, format) + } + + public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { + return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) + } + + public formatNsfDetail(result: GetNsfResult, verbose = false): string { + return renderNsfDetail(result, verbose) + } + + public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { + return renderNsfFolderDetail(view, verbose) + } + + public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { + return renderNsfRecordDetail(view, verbose) + } + + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string + ): Promise { + return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) + } + + public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { + return removeNestedShareRecords(this.storage, this.requireAuth(), input) + } + + public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { + return formatRemoveNsfPreview(preview) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts new file mode 100644 index 00000000..9130ad20 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -0,0 +1,500 @@ +import type { Auth, DRecord, DKdFolder, DKdFolderAccess } from '@keeper-security/keeperapi' +import { + Folder, + Records, + getRecordsDetailsMessage, + getSharingAdminsMessage, + normal64Bytes, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { + getRecordFields, + getRecordLogin, + getRecordPassword, + getRecordTitle, + getRecordType, + getRecordUrl, +} from '../records/RecordUtils' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + buildFolderPath, + collectRecordsInFolder, + findRecordFolderLocation, + folderAccessDisplayRole, + formatAccessType, + getFolderAccessEntries, + getKeeperDriveFolder, + getKeeperDriveRecord, + isFolderShareAdministrator, + isFolderUserPermission, + isSensitiveFieldType, + normalizeParentUid, + resolveAccessUsername, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { + NSF_FOLDER_LABEL_WIDTH, + NSF_FOLDER_SHARE_ADMINS_HEADING, + NSF_FOLDER_USER_PERMISSIONS_HEADING, + NSF_MASKED_VALUE, + NSF_RECORD_LABEL_WIDTH, + NSF_RECORD_USER_PERMISSIONS_HEADING, + 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() +} + +function formatNsfFieldParts(values: unknown[]): string[] { + return values + .filter((value) => value != null && value !== '') + .map(formatNsfFieldValue) + .filter((part) => part.length > 0) +} + +function formatNsfFieldValue(value: unknown): string { + if (value == null || value === '') return '' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (Array.isArray(value)) { + return formatNsfFieldParts(value).join(', ') + } + if (typeof value === 'object') { + return formatNsfFieldParts(Object.values(value as Record)).join(', ') + } + 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}` +} + +function recordDetailRow(label: string, value: string): string { + return `${label.padStart(NSF_RECORD_LABEL_WIDTH)}: ${value}` +} + +function recordDetailsMessage(recordUid: string, include: Records.RecordDetailsInclude) { + return getRecordsDetailsMessage({ + clientTime: Date.now(), + recordUid: [normal64Bytes(recordUid)], + recordDetailsInclude: include, + }) +} + +function bytesToUid(bytes: Uint8Array | null | undefined): string | undefined { + return bytes?.length ? webSafe64FromBytes(bytes) : undefined +} + +function mapFolderPermission(entry: DKdFolderAccess): NsfFolderPermission { + const permission = entry.permission + return { + accessTypeUid: entry.accessTypeUid, + accessType: formatAccessType(entry.accessType), + accessRoleType: folderAccessDisplayRole(entry), + inherited: entry.inherited, + hidden: entry.hidden, + canAdd: permission?.canAdd ?? undefined, + canRemove: permission?.canRemove ?? undefined, + canDelete: permission?.canDelete ?? undefined, + canListAccess: permission?.canListAccess ?? undefined, + canUpdateAccess: permission?.canUpdateAccess ?? undefined, + canEditRecords: permission?.canEditRecords ?? undefined, + canViewRecords: permission?.canViewRecords ?? undefined, + canListRecords: permission?.canListRecords ?? undefined, + } +} + +function buildFolderAccessRow( + storage: InMemoryStorage, + folder: DKdFolder, + entry: DKdFolderAccess +): NsfFolderAccessRow { + return { + username: resolveAccessUsername(storage, entry.accessTypeUid, folder), + role: folderAccessDisplayRole(entry), + } +} + +function splitFolderPermissions(storage: InMemoryStorage, folder: DKdFolder) { + const entries = getFolderAccessEntries(storage, folder.uid) + const userPermissions: NsfFolderAccessRow[] = [] + const shareAdmins: NsfFolderAccessRow[] = [] + const teamPermissions: NsfFolderPermission[] = [] + for (const entry of entries) { + if (isFolderUserPermission(entry)) { + userPermissions.push(buildFolderAccessRow(storage, folder, entry)) + } + if (isFolderShareAdministrator(entry)) { + shareAdmins.push(buildFolderAccessRow(storage, folder, entry)) + } + 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] +} + +function ensureFolderOwnerListed( + folder: DKdFolder, + userPermissions: NsfFolderAccessRow[], + shareAdmins: NsfFolderAccessRow[] +): { userPermissions: NsfFolderAccessRow[]; shareAdmins: NsfFolderAccessRow[] } { + const ownerUsername = folder.ownerInfo?.username?.trim() + if (!ownerUsername) return { userPermissions, shareAdmins } + return { + userPermissions: prependOwnerRow(userPermissions, ownerUsername), + shareAdmins: prependOwnerRow(shareAdmins, ownerUsername), + } +} + +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 }) => ({ + type: field.type, + label: field.label, + value: !unmask && isSensitiveFieldType(field.type) ? NSF_MASKED_VALUE : displayValue, + })) +} + +function formatRecordUserPermissionBlock(entry: NsfRecordPermission): string[] { + const lines: 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') + 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 fetchRecordShareAdmins(auth: Auth, recordUid: string): Promise { + try { + const response = await auth.executeRest( + getSharingAdminsMessage({ recordUid: normal64Bytes(recordUid) }) + ) + return (response.userProfileExts ?? []) + .flatMap((ext) => (ext?.email ? [ext.email] : [])) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + } catch { + return [] + } +} + +async function fetchRecordDataFallback(auth: Auth, recordUid: string): Promise { + try { + const response = await auth.executeRest( + recordDetailsMessage(recordUid, Records.RecordDetailsInclude.DATA_ONLY) + ) + return response.recordDataWithAccessInfo?.[0]?.recordData as DRecord['data'] | undefined + } catch { + return undefined + } +} + +function buildFolderView(storage: InMemoryStorage, folderUid: string): NsfFolderView { + 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 { userPermissions, shareAdmins } = ensureFolderOwnerListed( + folder, + split.userPermissions, + split.shareAdmins + ) + + return { + objectType: 'folder', + folderUid, + name: folder.data.name || 'Unnamed', + parentUid: normalizeParentUid(folder.parentUid), + path: buildFolderPath(storage, folderUid), + userPermissions, + shareAdmins, + teamPermissions: split.teamPermissions, + records: collectRecordsInFolder(storage, folderUid).map((record) => ({ + uid: record.uid, + title: getRecordTitle(record), + type: getRecordType(record), + })), + } +} + +async function buildRecordView( + auth: Auth, + storage: InMemoryStorage, + recordUid: string, + unmask: boolean +): Promise { + let record = getKeeperDriveRecord(storage, recordUid) + if (!record) { + throw new KeeperSdkError(`Nested share record not found: ${recordUid}`, ResultCodes.NSF_NOT_FOUND) + } + + let title = getRecordTitle(record) + if (NSF_UNKNOWN_RECORD_TITLES.has(title)) { + const fallbackData = await fetchRecordDataFallback(auth, recordUid) + if (fallbackData) { + record = { ...record, data: fallbackData } + title = getRecordTitle(record) + } + } + + const password = getRecordPassword(record) + const [userPermissions, shareAdmins] = await Promise.all([ + fetchRecordPermissions(auth, recordUid), + fetchRecordShareAdmins(auth, recordUid), + ]) + const notes = + typeof record.data?.notes === 'string' && record.data.notes.trim() ? record.data.notes.trim() : undefined + + return { + objectType: 'record', + recordUid, + title, + type: getRecordType(record), + revision: record.revision, + version: record.version, + folderLocation: findRecordFolderLocation(storage, recordUid) || 'root', + login: getRecordLogin(record) || undefined, + password: password ? (unmask ? password : NSF_MASKED_VALUE) : undefined, + url: getRecordUrl(record) || undefined, + notes, + fields: buildRecordFields(record, unmask), + userPermissions, + shareAdmins, + } +} + +export function resolveNsfFolder(storage: InMemoryStorage, identifier: string): string | undefined { + return resolveNsfFolderIdentifier(storage, identifier) +} + +export function resolveNsfRecord(storage: InMemoryStorage, identifier: string): string | undefined { + const uid = resolveNsfRecordIdentifier(storage, identifier) + if (!uid) return undefined + return getKeeperDriveRecord(storage, uid)?.uid +} + +export async function getNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + identifier: string, + options: GetNsfOptions = {} +): Promise { + const trimmed = identifier.trim() + if (!trimmed) { + throw new KeeperSdkError('UID or title is required.', ResultCodes.NSF_NOT_FOUND) + } + + const folderUid = resolveNsfFolder(storage, trimmed) + if (folderUid) { + return { kind: 'folder', view: buildFolderView(storage, folderUid) } + } + + const recordUid = resolveNsfRecord(storage, trimmed) + if (recordUid) { + const view = await buildRecordView(auth, storage, recordUid, options.unmask ?? false) + return { kind: 'record', view } + } + + throw new KeeperSdkError( + `Cannot find any Nested Share Folder object with UID or title: ${trimmed}`, + ResultCodes.NSF_NOT_FOUND + ) +} + +export function formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { + const lines = [ + folderDetailRow('Nested Share Folder UID', view.folderUid), + folderDetailRow('Name', view.name), + '', + NSF_FOLDER_USER_PERMISSIONS_HEADING, + ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), + '', + NSF_FOLDER_SHARE_ADMINS_HEADING, + ...view.shareAdmins.map((entry) => `${entry.username}: ${entry.role}`), + ] + + if (!verbose) return lines.join('\n') + + lines.push('', folderDetailRow('Parent UID', view.parentUid), folderDetailRow('Path', view.path)) + if (view.records.length > 0) { + lines.push('', 'Records:') + for (const record of view.records) { + lines.push(` ${record.uid} ${record.title} (${record.type})`) + } + } + if (view.teamPermissions.length > 0) { + lines.push('', 'Team Permissions:') + for (const entry of view.teamPermissions) { + lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`) + } + } + return lines.join('\n') +} + +export function formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { + const lines = [ + recordDetailRow('UID', view.recordUid), + recordDetailRow('Type', view.type), + recordDetailRow('Title', view.title), + ] + + if (view.login) lines.push(recordDetailRow('Login', view.login)) + if (view.password) lines.push(recordDetailRow('Password', view.password)) + if (view.url) lines.push(recordDetailRow('Url', view.url)) + 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)) + } + + if (view.userPermissions.length > 0) { + lines.push('', NSF_RECORD_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) { + lines.push(` ${admin}`) + } + } + + if (verbose) { + lines.push( + '', + recordDetailRow('Folder', view.folderLocation), + recordDetailRow('Revision', String(view.revision)), + recordDetailRow('Version', String(view.version)) + ) + } + + return lines.join('\n') +} + +export function formatNsfDetail(result: GetNsfResult, verbose = false): string { + return result.kind === 'folder' + ? formatNsfFolderDetail(result.view, verbose) + : formatNsfRecordDetail(result.view, verbose) +} diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts new file mode 100644 index 00000000..d73f6513 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -0,0 +1,77 @@ +export { + ROOT_FOLDER_UID, + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + resolveAccessUsername, + folderAccessDisplayRole, + isNestedShareRecord, + isNestedShareFolder, + ensureNestedShareRecord, + ensureNestedShareFolder, + resolveNsfRecordIdentifier, + resolveNsfFolderIdentifier, + findNestedShareFoldersForRecord, + checkRecordDeletePermission, +} from './nsfHelpers' + +export { + ListNsfFormat, + listNestedShareFolders, + formatListNsfTable, + renderListNsfAsciiTable, + formatListNsfCsv, + formatListNsfJson, + formatListNsfOutput, +} from './listNsf' +export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, +} from './listNsf' + +export { + GetNsfFormat, + resolveNsfFolder, + resolveNsfRecord, + getNestedShareFolder, + formatNsfFolderDetail, + formatNsfRecordDetail, + formatNsfDetail, +} from './getNsf' +export type { + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, +} from './getNsf' + +export { linkNestedShareRecord } from './linkNsfRecord' +export type { LinkNsfRecordResult } from './linkNsfRecord' + +export { + NsfRemoveOperation, + removeNestedShareRecords, + formatRemoveNsfPreview, +} from './removeNsfRecord' +export type { + NsfRemoveOperationInput, + RemoveNsfRecordInput, + NsfRemovePreviewItem, + RemoveNsfRecordResult, +} from './removeNsfRecord' + +export { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts new file mode 100644 index 00000000..e277ffad --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -0,0 +1,139 @@ +import type { Auth, DRecordMetadata, EncryptionType } from '@keeper-security/keeperapi' +import { + Folder, + Records, + folderRecordUpdateMessage, + normal64Bytes, + platform, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { VaultObjectKind } from '../folders/folderHelpers' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + ensureNestedShareFolder, + ensureNestedShareRecord, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' + +function resolveRecordKeyType( + storage: InMemoryStorage, + recordUid: string +): { encryptionType: EncryptionType; keyType: Folder.EncryptedKeyType } { + const metadata = storage.getByUid(VaultObjectKind.Metadata, recordUid) + if (metadata?.recordKeyType === Records.RecordKeyType.ENCRYPTED_BY_DATA_KEY) { + return { + encryptionType: 'cbc', + keyType: Folder.EncryptedKeyType.encrypted_by_data_key, + } + } + return { + encryptionType: 'gcm', + keyType: Folder.EncryptedKeyType.encrypted_by_data_key_gcm, + } +} + +export type LinkNsfRecordResult = { + success: boolean + recordUid: string + folderUid: string + status: string + message: string +} + +async function buildRecordMetadata( + storage: InMemoryStorage, + folderUid: string, + recordUid: string +): Promise { + const recordKey = await storage.getKeyBytes(recordUid) + const folderKey = await storage.getKeyBytes(folderUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not found for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + if (!folderKey) { + throw new KeeperSdkError( + `Folder key not found for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + + const { encryptionType, keyType } = resolveRecordKeyType(storage, recordUid) + const encryptedRecordKey = await platform.wrapKey(recordUid, folderUid, encryptionType, storage) + return { + recordUid: normal64Bytes(recordUid), + encryptedRecordKey, + encryptedRecordKeyType: keyType, + } +} + +function parseFolderRecordUpdateResponse( + response: Folder.IFolderRecordUpdateResponse, + folderUid: string, + recordUid: string +): LinkNsfRecordResult { + const result = response.folderRecordUpdateResult?.[0] + if (!result) { + return { + success: true, + recordUid, + folderUid, + status: 'SUCCESS', + message: 'Record added to folder successfully', + } + } + const statusName = Folder.FolderModifyStatus[result.status ?? Folder.FolderModifyStatus.SUCCESS] ?? 'UNKNOWN' + const success = result.status === Folder.FolderModifyStatus.SUCCESS + return { + success, + recordUid: result.recordUid?.length ? webSafe64FromBytes(result.recordUid) : recordUid, + folderUid: response.folderUid?.length ? webSafe64FromBytes(response.folderUid) : folderUid, + status: statusName, + message: result.message || (success ? 'Record added to folder successfully' : 'Failed to link record'), + } +} + +export async function linkNestedShareRecord( + storage: InMemoryStorage, + auth: Auth, + recordIdentifier: string, + folderIdentifier: string +): Promise { + const recordUid = resolveNsfRecordIdentifier(storage, recordIdentifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${recordIdentifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + + const folderUid = resolveNsfFolderIdentifier(storage, folderIdentifier) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${folderIdentifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + + ensureNestedShareRecord(storage, recordUid, recordIdentifier) + ensureNestedShareFolder(storage, folderUid, folderIdentifier) + + try { + const recordMetadata = await buildRecordMetadata(storage, folderUid, recordUid) + const response = await auth.executeRest( + folderRecordUpdateMessage({ + folderUid: normal64Bytes(folderUid), + addRecords: [recordMetadata], + }) + ) + const parsed = parseFolderRecordUpdateResponse(response, folderUid, recordUid) + if (!parsed.success) { + throw new KeeperSdkError(parsed.message, ResultCodes.NSF_LINK_FAILED) + } + return parsed + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to link record to folder: ${extractErrorMessage(err)}`, + ResultCodes.NSF_LINK_FAILED + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts new file mode 100644 index 00000000..4971753f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -0,0 +1,173 @@ +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { + NsfItemType, + findRecordFolderLocation, + getKeeperDriveFolders, + getKeeperDriveRecords, + getRecordDescription, + normalizeParentUid, +} from './nsfHelpers' +import { getRecordTitle, getRecordType } from '../records/RecordUtils' +import { + NSF_LIST_DEFAULT_COLUMN_WIDTH, + NSF_LIST_FULL_HEADERS, + 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[][] +} + +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 collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { + return getKeeperDriveFolders(storage).map((folder) => ({ + itemType: NsfItemType.Folder, + uid: folder.uid, + title: folder.data.name || 'Unnamed', + type: '', + description: '', + parentOrFolder: normalizeParentUid(folder.parentUid), + })) +} + +function collectRecordRows(storage: InMemoryStorage): ListNsfRow[] { + return getKeeperDriveRecords(storage).map((record) => ({ + itemType: NsfItemType.Record, + uid: record.uid, + title: getRecordTitle(record), + type: getRecordType(record), + description: getRecordDescription(record), + parentOrFolder: findRecordFolderLocation(storage, record.uid) || 'root', + })) +} + +export function listNestedShareFolders(storage: InMemoryStorage, options: ListNsfOptions = {}): ListNsfRow[] { + const showFolders = options.folders ?? options.records == null + const showRecords = options.records ?? options.folders == null + const rows: ListNsfRow[] = [] + if (showFolders) rows.push(...collectFolderRows(storage)) + if (showRecords) rows.push(...collectRecordRows(storage)) + return rows.sort(compareRows) +} + +function truncateText(text: string, maxLength: number): string { + if (!text || text.length <= maxLength) return text + if (maxLength <= NSF_LIST_MIN_TRUNCATE_PREFIX) return text.slice(0, maxLength) + return `${text.slice(0, maxLength - NSF_LIST_MIN_TRUNCATE_PREFIX)}...` +} + +export function formatListNsfTable( + rows: ListNsfRow[], + options: { columnWidth?: number } = {} +): FormattedListNsfTable { + const columnWidth = options.columnWidth ?? NSF_LIST_DEFAULT_COLUMN_WIDTH + const outRows = rows.map((row, index) => [ + String(index + 1), + row.itemType, + truncateText(row.uid, columnWidth), + truncateText(row.title, columnWidth), + truncateText(row.type, columnWidth), + truncateText(row.description, columnWidth), + ]) + return { headers: [...NSF_LIST_TABLE_HEADERS], rows: outRows } +} + +export function renderListNsfAsciiTable( + table: FormattedListNsfTable, + options: { minColWidth?: number } = {} +): string { + const { minColWidth = 2 } = options + const { headers, rows } = table + const columnCount = headers.length + const columnWidths = headers.map((header, columnIndex) => { + let width = Math.max(header.length, minColWidth) + for (const row of rows) { + width = Math.max(width, (row[columnIndex] || '').length, minColWidth) + } + return width + }) + const padCell = (cell: string, columnIndex: number) => + cell + ' '.repeat(columnWidths[columnIndex] - cell.length) + const formatRow = (cells: string[]) => cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + const ruleRow = columnWidths.map((width, columnIndex) => padCell('-'.repeat(width), columnIndex)).join(' ') + return [formatRow(headers), ruleRow, ...rows.map(formatRow)].join('\n') +} + +function escapeCsvCell(value: string): string { + if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"` + return value +} + +export function formatListNsfCsv(rows: ListNsfRow[]): string { + const lines = [NSF_LIST_FULL_HEADERS.join(',')] + for (const row of rows) { + lines.push( + [ + row.itemType, + row.uid, + row.title, + row.type, + row.description, + row.parentOrFolder, + ] + .map(escapeCsvCell) + .join(',') + ) + } + return lines.join('\n') +} + +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 + ) +} + +export function formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = ListNsfFormat.Table): string { + switch (format) { + case ListNsfFormat.CSV: + return formatListNsfCsv(rows) + case ListNsfFormat.JSON: + return formatListNsfJson(rows) + default: + return renderListNsfAsciiTable(formatListNsfTable(rows)) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts new file mode 100644 index 00000000..8648a7b0 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -0,0 +1,56 @@ +import { Folder } from '@keeper-security/keeperapi' + +export const ROOT_FOLDER_UID = 'AAAAAAAAAAAAAAAAAPmtNA' + +export const NSF_LEGACY_RECORD_MSG = + "Record '{0}' is a legacy vault record. Nested Share Folder commands operate only on Nested Share Records." + +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_ACCESS_TYPE_LABELS: Record = { + [Folder.AccessType.AT_USER]: 'user', + [Folder.AccessType.AT_TEAM]: 'team', + [Folder.AccessType.AT_OWNER]: 'owner', + [Folder.AccessType.AT_ENTERPRISE]: 'enterprise', + [Folder.AccessType.AT_FOLDER]: 'folder', + [Folder.AccessType.AT_APPLICATION]: 'application', +} + +export const NSF_SENSITIVE_FIELD_TYPES = new Set(['password', 'secret', 'pinCode']) +export const NSF_NOTE_FIELD_TYPES = new Set(['note', 'multiline']) +export const NSF_TOP_LEVEL_FIELD_TYPES = new Set(['login', 'password', 'url', 'note', 'multiline', 'text']) +export const NSF_UNKNOWN_RECORD_TITLES = new Set(['(no data)', '(untitled)', 'Unknown']) +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_FOLDER_SHARE_ADMINS_HEADING = ' Share Administrators:' +export const NSF_RECORD_USER_PERMISSIONS_HEADING = 'User Permissions:' + +export const NSF_LIST_TABLE_HEADERS = ['#', 'Item Type', 'UID', 'Title', 'Type', 'Description'] as const +export const NSF_LIST_FULL_HEADERS = [ + 'Item Type', + 'UID', + 'Title', + 'Type', + 'Description', + 'Parent/Folder', +] as const +export const NSF_LIST_DEFAULT_COLUMN_WIDTH = 40 +export const NSF_LIST_MIN_TRUNCATE_PREFIX = 3 + +export const NSF_MAX_REMOVALS = 500 diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts new file mode 100644 index 00000000..646fd074 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -0,0 +1,326 @@ +import type { + DRecord, + DUser, + DKdFolder, + DKdFolderAccess, + DKdFolderRecord, + DKdRecordAccess, +} from '@keeper-security/keeperapi' +import { Folder, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { VaultObjectKind } from '../folders/folderHelpers' +import { KeeperSdkError, ResultCodes } 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_RECORD_DESCRIPTION_MAX_LENGTH, + NSF_SENSITIVE_FIELD_TYPES, + ROOT_FOLDER_UID, +} from './nsfConstants' + +export { ROOT_FOLDER_UID } from './nsfConstants' + +export enum KeeperDriveKind { + Folder = 'keeper_drive_folder', + FolderAccess = 'keeper_drive_folder_access', + FolderRecord = 'keeper_drive_folder_record', + RecordAccess = 'keeper_drive_record_access', +} + +export enum NsfItemType { + Folder = 'Folder', + Record = 'Record', +} + +export function isNestedShareRecord(storage: InMemoryStorage, recordUid: string): boolean { + return !!getKeeperDriveRecord(storage, recordUid) +} + +export function isNestedShareFolder(storage: InMemoryStorage, folderUid: string): boolean { + if (!folderUid) return false + if (isRootFolderUid(folderUid)) return true + return !!getKeeperDriveFolder(storage, folderUid) +} + +export function ensureNestedShareRecord(storage: InMemoryStorage, recordUid: string, identifier?: string): void { + if (isNestedShareRecord(storage, recordUid)) return + const ident = identifier ?? recordUid + throw new KeeperSdkError(NSF_LEGACY_RECORD_MSG.replace('{0}', ident), ResultCodes.NSF_LEGACY_RECORD) +} + +export function ensureNestedShareFolder(storage: InMemoryStorage, folderUid: string, identifier?: string): void { + if (isNestedShareFolder(storage, folderUid)) return + const ident = identifier ?? folderUid + throw new KeeperSdkError(NSF_LEGACY_FOLDER_MSG.replace('{0}', ident), ResultCodes.NSF_LEGACY_FOLDER) +} + +function resolveByUidOrName( + items: T[], + identifier: string, + getUid: (item: T) => string, + getName: (item: T) => string +): T | undefined { + const trimmed = identifier.trim() + if (!trimmed) return undefined + + const byUid = items.find((item) => getUid(item) === trimmed) + if (byUid) return byUid + + const lower = trimmed.toLowerCase() + const nameMatches = items.filter((item) => getName(item).toLowerCase() === lower) + if (nameMatches.length === 1) return nameMatches[0] + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Multiple matches found for "${identifier}". Use a UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES + ) + } + return undefined +} + +function resolveRecordByTitleSearch(storage: InMemoryStorage, identifier: string): DRecord | undefined { + const lower = identifier.toLowerCase() + const matches = getKeeperDriveRecords(storage).filter((record) => { + const title = getRecordTitle(record) + return title && lower.length > 0 && title.toLowerCase().includes(lower) + }) + if (matches.length === 1) return matches[0] + if (matches.length > 1) { + throw new KeeperSdkError( + `Multiple records matched "${identifier}". Use a UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES + ) + } + return undefined +} + +function resolveFolderByPath(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim().replace(/^\/+/, '') + if (!trimmed) return ROOT_FOLDER_UID + + const targetPath = `/${trimmed.toLowerCase()}` + for (const folder of getKeeperDriveFolders(storage)) { + if (buildFolderPath(storage, folder.uid).toLowerCase() === targetPath) { + return folder.uid + } + } + return undefined +} + +export function resolveNsfRecordIdentifier(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim() + if (!trimmed) return undefined + + const kdRecord = getKeeperDriveRecord(storage, trimmed) + if (kdRecord) return kdRecord.uid + + const anyRecord = storage.getByUid(VaultObjectKind.Record, trimmed) + if (anyRecord) return anyRecord.uid + + return resolveRecordByTitleSearch(storage, trimmed)?.uid +} + +export function resolveNsfFolderIdentifier(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim() + if (!trimmed) return undefined + + if (isRootFolderUid(trimmed) || trimmed.toLowerCase() === 'root') return ROOT_FOLDER_UID + + const byUidOrName = resolveByUidOrName( + getKeeperDriveFolders(storage), + trimmed, + (folder) => folder.uid, + (folder) => folder.data.name || '' + ) + if (byUidOrName) return byUidOrName.uid + + return resolveFolderByPath(storage, trimmed) +} + +export function findNestedShareFoldersForRecord(storage: InMemoryStorage, recordUid: string): string[] { + return storage + .getAll(KeeperDriveKind.FolderRecord) + .filter((entry) => entry.recordUid === recordUid) + .map((entry) => entry.folderUid) +} + +export function checkRecordDeletePermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid?: Uint8Array +): void { + const entries = storage + .getAll(KeeperDriveKind.RecordAccess) + .filter((entry) => entry.recordUid === recordUid) + if (entries.length === 0) return + + const accountUidStr = accountUid?.length ? webSafe64FromBytes(accountUid) : '' + for (const entry of entries) { + const isCurrentUser = + (entry.accessType === Folder.AccessType.AT_USER && + entry.accessTypeUid === accountUidStr) || + (username && + storage.getAll('user').some( + (user) => + user.username === username && + webSafe64FromBytes(user.accountUid) === entry.accessTypeUid + )) + if (!isCurrentUser) continue + if (entry.owner || entry.canDelete) return + throw new KeeperSdkError( + 'You do not have permission to delete this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) + } + throw new KeeperSdkError( + 'You do not have permission to delete this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): string { + if (role == null) return 'unknown' + return NSF_ACCESS_ROLE_LABELS[role] ?? `role-${role}` +} + +export function formatAccessType(type: Folder.AccessType | null | undefined): string { + if (type == null) return 'unknown' + return NSF_ACCESS_TYPE_LABELS[type] ?? `type-${type}` +} + +export function normalizeParentUid(parentUid: string | undefined | null): string { + const value = (parentUid ?? '').trim() + return !value || value === ROOT_FOLDER_UID ? ROOT_FOLDER_UID : value +} + +export function isRootFolderUid(folderUid: string | undefined | null): boolean { + return normalizeParentUid(folderUid) === ROOT_FOLDER_UID +} + +export function getKeeperDriveFolders(storage: InMemoryStorage): DKdFolder[] { + return storage.getAll(KeeperDriveKind.Folder) +} + +export function getKeeperDriveRecords(storage: InMemoryStorage): DRecord[] { + return storage.getRecords().filter((record) => record.isKeeperDriveData) +} + +export function getKeeperDriveFolder(storage: InMemoryStorage, folderUid: string): DKdFolder | undefined { + return storage.getByUid(KeeperDriveKind.Folder, folderUid) +} + +export function getKeeperDriveRecord(storage: InMemoryStorage, recordUid: string): DRecord | undefined { + const record = storage.getByUid('record', recordUid) + return record?.isKeeperDriveData ? record : undefined +} + +export function getFolderAccessEntries(storage: InMemoryStorage, folderUid: string): DKdFolderAccess[] { + return storage + .getAll(KeeperDriveKind.FolderAccess) + .filter((entry) => entry.folderUid === folderUid) +} + +export function getFolderDisplayName(storage: InMemoryStorage, folderUid: string): string { + if (isRootFolderUid(folderUid)) return 'root' + return getKeeperDriveFolder(storage, folderUid)?.data.name ?? folderUid +} + +export function findRecordFolderLocation(storage: InMemoryStorage, recordUid: string): string { + const folderUids = findNestedShareFoldersForRecord(storage, recordUid) + if (folderUids.length === 0) return 'root' + return getFolderDisplayName(storage, folderUids[0]) +} + +export function buildFolderPath(storage: InMemoryStorage, folderUid: string): string { + if (isRootFolderUid(folderUid)) return '/' + + const segments: string[] = [] + let currentUid: string | undefined = folderUid + const seen = new Set() + + while (currentUid && !isRootFolderUid(currentUid) && !seen.has(currentUid)) { + seen.add(currentUid) + const folder = getKeeperDriveFolder(storage, currentUid) + if (!folder) break + segments.unshift(folder.data.name || folder.uid) + currentUid = folder.parentUid + } + + return `/${segments.join('/')}` +} + +export function collectRecordsInFolder(storage: InMemoryStorage, folderUid: string): DRecord[] { + const normalizedFolderUid = normalizeParentUid(folderUid) + const records: DRecord[] = [] + for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { + if (normalizeParentUid(entry.folderUid) !== normalizedFolderUid) continue + const record = getKeeperDriveRecord(storage, entry.recordUid) + if (record) records.push(record) + } + return records +} + +export function getRecordDescription(record: DRecord): string { + const data = record.data + if (!data || typeof data !== 'object') return '' + + const fields = Array.isArray(data.fields) ? data.fields : [] + for (const field of fields) { + if (!NSF_NOTE_FIELD_TYPES.has(field?.type)) continue + const value = Array.isArray(field.value) ? field.value[0] : field.value + if (typeof value === 'string' && value.trim()) { + return value.trim().slice(0, NSF_RECORD_DESCRIPTION_MAX_LENGTH) + } + } + + if (typeof data.notes === 'string' && data.notes.trim()) { + return data.notes.trim().slice(0, NSF_RECORD_DESCRIPTION_MAX_LENGTH) + } + return '' +} + +export function isSensitiveFieldType(fieldType: string): boolean { + return NSF_SENSITIVE_FIELD_TYPES.has(fieldType) +} + +export function resolveAccessUsername( + storage: InMemoryStorage, + accessTypeUid: string, + folder?: DKdFolder +): string { + for (const user of storage.getAll('user')) { + if (webSafe64FromBytes(user.accountUid) === accessTypeUid) { + return user.username + } + } + if (folder?.ownerInfo?.accountUid === accessTypeUid && folder.ownerInfo.username) { + return folder.ownerInfo.username + } + return accessTypeUid +} + +export function folderAccessDisplayRole(entry: DKdFolderAccess): string { + if (entry.accessType === Folder.AccessType.AT_OWNER) return 'owner' + return formatAccessRoleType(entry.accessRoleType) +} + +export function isFolderShareAdministrator(entry: DKdFolderAccess): boolean { + return ( + entry.accessType === Folder.AccessType.AT_OWNER || + entry.accessRoleType === Folder.AccessRoleType.MANAGER || + entry.accessRoleType === Folder.AccessRoleType.CONTENT_SHARE_MANAGER + ) +} + +export function isFolderUserPermission(entry: DKdFolderAccess): boolean { + return ( + entry.accessType === Folder.AccessType.AT_USER || + entry.accessType === Folder.AccessType.AT_OWNER + ) +} + diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts new file mode 100644 index 00000000..2a8dc5f4 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -0,0 +1,237 @@ +import type { Auth, folder as FolderProto } from '@keeper-security/keeperapi' +import { folder, normal64Bytes, removeRecordMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + checkRecordDeletePermission, + ensureNestedShareRecord, + findNestedShareFoldersForRecord, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { NSF_MAX_REMOVALS } from './nsfConstants' + +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, + [NsfRemoveOperation.OwnerTrash]: RecordOperationType.MOVE_TO_OWNER_TRASH, +} + +function normalizeOperation(operation: NsfRemoveOperationInput = NsfRemoveOperation.OwnerTrash): NsfRemoveOperation { + const value = operation as NsfRemoveOperation + if (value in OPERATION_MAP) return value + throw new KeeperSdkError( + `Invalid operation '${operation}'. Use: owner-trash, folder-trash, unlink.`, + ResultCodes.NSF_REMOVE_FAILED + ) +} + +function mapPreviewItem(item: FolderProto.v3.remove.IRemoveResult): NsfRemovePreviewItem { + return { + recordUid: item.itemUid?.length ? webSafe64FromBytes(item.itemUid) : '', + folderUid: item.folderUid?.length ? webSafe64FromBytes(item.folderUid) : '', + 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 hasPreviewErrors(preview: NsfRemovePreviewItem[]): boolean { + return preview.some((item) => item.error != null || item.status !== REMOVE_SUCCESS_STATUS) +} + +type RemovalSpec = { + recordUid: string + folderUid?: string + operation: FolderProto.v3.remove.RecordOperationType +} + +function buildRemovals( + storage: InMemoryStorage, + auth: Auth, + recordIdentifiers: string[], + folderIdentifier: string | undefined, + operation: NsfRemoveOperation +): RemovalSpec[] { + 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 (operation === NsfRemoveOperation.Unlink && !folderIdentifier?.trim()) { + throw new KeeperSdkError( + '--folder is required when operation is "unlink".', + ResultCodes.NSF_FOLDER_REQUIRED + ) + } + + const folderUid = folderIdentifier ? resolveNsfFolderIdentifier(storage, folderIdentifier) : undefined + if (folderIdentifier && !folderUid) { + throw new KeeperSdkError(`Folder '${folderIdentifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + + const removals: RemovalSpec[] = [] + for (const identifier of recordIdentifiers) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + checkRecordDeletePermission(storage, recordUid, auth.username, auth.accountUid) + + let ctxFolder = folderUid + if (!ctxFolder) { + const folders = findNestedShareFoldersForRecord(storage, recordUid) + if (folders.length === 0 && operation !== NsfRemoveOperation.OwnerTrash) { + throw new KeeperSdkError( + `No folder context for record '${identifier}'. Use folder option or owner-trash operation.`, + ResultCodes.NSF_NOT_FOUND + ) + } + ctxFolder = folders[0] + } + + removals.push({ + recordUid, + folderUid: ctxFolder, + operation: OPERATION_MAP[operation], + }) + } + return removals +} + +async function executeRemove( + auth: Auth, + removals: RemovalSpec[], + action: FolderProto.v3.remove.RemoveAction, + confirmationToken?: Uint8Array +): Promise { + return auth.executeRest( + removeRecordMessage({ + action, + records: removals.map((spec) => ({ + recordUid: normal64Bytes(spec.recordUid), + folderUid: spec.folderUid ? normal64Bytes(spec.folderUid) : new Uint8Array(0), + operationType: spec.operation, + })), + confirmationToken, + }) + ) +} + +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.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}`) + } + lines.push('') + } + return lines.join('\n').trimEnd() +} + +export async function removeNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: RemoveNsfRecordInput +): Promise { + const operation = normalizeOperation(input.operation) + const dryRun = input.dryRun ?? false + const removals = buildRemovals(storage, auth, input.records, input.folder, operation) + + try { + const previewResponse = await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_PREVIEW) + const preview = (previewResponse.results ?? []).map(mapPreviewItem) + + if (hasPreviewErrors(preview)) { + throw new KeeperSdkError(formatRemoveNsfPreview(preview) || 'Removal preview failed.', ResultCodes.NSF_REMOVE_FAILED) + } + + if (dryRun || !previewResponse.confirmationToken?.length) { + return { confirmed: false, dryRun, preview } + } + + if (!input.force) { + return { confirmed: false, dryRun: false, preview, message: 'Confirmation required. Set force=true to proceed.' } + } + + await executeRemove(auth, removals, RemoveAction.REMOVE_ACTION_CONFIRM, previewResponse.confirmationToken) + return { + confirmed: true, + dryRun: false, + preview, + message: `Removed ${removals.length} record(s).`, + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to remove nested share record(s): ${extractErrorMessage(err)}`, + ResultCodes.NSF_REMOVE_FAILED + ) + } +} diff --git a/KeeperSdk/src/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 89424b97..4ba42312 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -137,6 +137,7 @@ export class InMemoryStorage implements VaultStorage { token?: string sharedFolderUid?: string recordUid?: string + folderUid?: string accountUid?: string | Uint8Array teamUid?: string } @@ -157,6 +158,9 @@ export class InMemoryStorage implements VaultStorage { if (record.sharedFolderUid && record.teamUid) { return `${record.sharedFolderUid}:${record.teamUid}` } + if (record.folderUid && record.recordUid) { + return `${record.folderUid}:${record.recordUid}` + } if (item.kind === VaultObjectKind.User && accountUidStr) return accountUidStr return '_singleton_' } diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 24d29f8b..dfa22bee 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,8 +1,13 @@ +const DEFAULT_CLIENT_VERSION = 'c18.0.0' +const DEFAULT_DEVICE_NAME = 'JavaScript Keeper SDK' +const DEFAULT_CONFIG_DIR = '.keeper' +const DEFAULT_LOG_FORMAT = '!' + export const SdkDefaults = { - CLIENT_VERSION: 'c17.0.0', - DEVICE_NAME: 'JavaScript Keeper SDK', - CONFIG_DIR: '.keeper', - LOG_FORMAT: '!', + CLIENT_VERSION: DEFAULT_CLIENT_VERSION, + DEVICE_NAME: DEFAULT_DEVICE_NAME, + CONFIG_DIR: DEFAULT_CONFIG_DIR, + LOG_FORMAT: DEFAULT_LOG_FORMAT, } as const export const AuthDefaults = { @@ -49,6 +54,19 @@ export enum RoleErrorCode { RoleEnforcementFailed = 'role_enforcement_failed', } +export enum NsfErrorCode { + NotFound = 'nsf_not_found', + MultipleMatches = 'nsf_multiple_matches', + LegacyRecord = 'nsf_legacy_record', + LegacyFolder = 'nsf_legacy_folder', + PermissionDenied = 'nsf_permission_denied', + LinkFailed = 'nsf_link_failed', + RemoveFailed = 'nsf_remove_failed', + FolderRequired = 'nsf_folder_required', + TooManyRecords = 'nsf_too_many_records', + MissingKey = 'nsf_missing_key', +} + export enum TeamErrorCode { TeamRequired = 'team_required', TeamNotFound = 'team_not_found', @@ -116,6 +134,16 @@ export const ResultCodes = { ROLE_RENAME_MULTI_NOT_ALLOWED: RoleErrorCode.RoleRenameMultiNotAllowed, ROLE_NAME_EMPTY: RoleErrorCode.RoleNameEmpty, ROLE_ENFORCEMENT_FAILED: RoleErrorCode.RoleEnforcementFailed, + NSF_NOT_FOUND: NsfErrorCode.NotFound, + MULTIPLE_NSF_MATCHES: NsfErrorCode.MultipleMatches, + NSF_LEGACY_RECORD: NsfErrorCode.LegacyRecord, + NSF_LEGACY_FOLDER: NsfErrorCode.LegacyFolder, + NSF_PERMISSION_DENIED: NsfErrorCode.PermissionDenied, + NSF_LINK_FAILED: NsfErrorCode.LinkFailed, + NSF_REMOVE_FAILED: NsfErrorCode.RemoveFailed, + NSF_FOLDER_REQUIRED: NsfErrorCode.FolderRequired, + NSF_TOO_MANY_RECORDS: NsfErrorCode.TooManyRecords, + NSF_MISSING_KEY: NsfErrorCode.MissingKey, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index a7a638f0..cef46a3a 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -8,6 +8,7 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + NsfErrorCode, KEEPER_PUBLIC_HOSTS, } from './constants' export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger } from './Logger' diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 52c3055e..80f6e5c0 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -75,6 +75,11 @@ import { type UpdateRoleResult, } from '../roles' 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 type { ListUserRow, ListUsersOptions, @@ -142,6 +147,7 @@ export class KeeperVault { private readonly teamManager: TeamManager private readonly roleManager: RoleManager private readonly userManager: UserManager + private readonly nestedShareFolderManager: NestedShareFolderManager constructor(config?: KeeperVaultConfig) { this.config = { @@ -165,6 +171,11 @@ export class KeeperVault { this.teamManager = new TeamManager(authProvider) this.roleManager = new RoleManager(authProvider) this.userManager = new UserManager(authProvider) + this.nestedShareFolderManager = new NestedShareFolderManager(this.storage, authProvider) + } + + public getNestedShareFolderManager(): NestedShareFolderManager { + return this.nestedShareFolderManager } public getFolderManager(): FolderManager { @@ -696,6 +707,50 @@ export class KeeperVault { return getRecordShareInfoOp(auth, recordUid) } + public listNestedShareFolders(options?: ListNsfOptions): ListNsfRow[] { + this.getAuthOrThrow() + return this.nestedShareFolderManager.listNestedShareFolders(options ?? {}) + } + + public formatListNsfTable(rows: ListNsfRow[], options?: { columnWidth?: number }): FormattedListNsfTable { + return this.nestedShareFolderManager.formatListNsfTable(rows, options ?? {}) + } + + public renderListNsfAsciiTable(table: FormattedListNsfTable, options?: { minColWidth?: number }): string { + return this.nestedShareFolderManager.renderListNsfAsciiTable(table, options ?? {}) + } + + public formatListNsfOutput(rows: ListNsfRow[], format?: ListNsfFormatInput): string { + return this.nestedShareFolderManager.formatListNsfOutput(rows, format) + } + + public async getNestedShareFolder(identifier: string, options?: GetNsfOptions): Promise { + return this.nestedShareFolderManager.getNestedShareFolder(identifier, options ?? {}) + } + + public formatNsfDetail(result: GetNsfResult, verbose?: boolean): string { + return this.nestedShareFolderManager.formatNsfDetail(result, verbose ?? false) + } + + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string + ): Promise { + const result = await this.nestedShareFolderManager.linkNestedShareRecord(recordIdentifier, folderIdentifier) + if (result.success) await this.syncIfNeeded() + return result + } + + public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { + const result = await this.nestedShareFolderManager.removeNestedShareRecords(input) + if (result.confirmed) await this.syncIfNeeded() + return result + } + + public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { + return this.nestedShareFolderManager.formatRemoveNsfPreview(preview) + } + public async shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index f53fc689..165e6c7b 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -29,6 +29,10 @@ "roles:add": "ts-node src/roles/addRole.ts", "roles:update": "ts-node src/roles/updateRole.ts", "roles:delete": "ts-node src/roles/deleteRole.ts", + "nsf:list": "ts-node src/nestedShareFolders/list_nsf.ts", + "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", + "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", + "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts new file mode 100644 index 00000000..a997403b --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts @@ -0,0 +1,51 @@ +import { + cleanup, + extractErrorMessage, + GetNsfFormat, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function getNsf() { + const vault = await login() + + try { + const identifier = (await prompt('Record UID, folder UID, or title: ')).trim() + if (!identifier) { + logger.info('No UID or title given.') + return + } + + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const verbose = isYes(await prompt('Verbose permissions? [y/N]: ')) + const unmask = isYes(await prompt('Unmask secrets? [y/N]: ')) + + const restore = suppressLogs() + let result + try { + result = await vault.getNestedShareFolder(identifier, { + format: asJson ? GetNsfFormat.JSON : GetNsfFormat.Detail, + verbose, + unmask, + }) + } finally { + restore() + } + + logger.info('') + const output = asJson ? JSON.stringify(result.view, null, 2) : vault.formatNsfDetail(result, verbose) + process.stdout.write(`${output}\n`) + logger.info('') + } catch (err) { + logger.error(`Lookup failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(getNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts new file mode 100644 index 00000000..896ed6bb --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts @@ -0,0 +1,44 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function linkNsf() { + const vault = await login() + + try { + const recordIdentifier = (await prompt('Record UID or title: ')).trim() + const folderIdentifier = (await prompt('Destination folder UID or name: ')).trim() + if (!recordIdentifier || !folderIdentifier) { + logger.info('Both record and folder are required.') + return + } + + const restore = suppressLogs() + let result + try { + result = await vault.linkNestedShareRecord(recordIdentifier, folderIdentifier) + } finally { + restore() + } + + logger.info('') + logger.info(result.message) + logger.info(`Record: ${result.recordUid}`) + logger.info(`Folder: ${result.folderUid}`) + logger.info(`Status: ${result.status}`) + logger.info('') + } catch (err) { + logger.error(`Link failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(linkNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts new file mode 100644 index 00000000..20896bbc --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts @@ -0,0 +1,69 @@ +import fs from 'fs/promises' +import { + cleanup, + extractErrorMessage, + ListNsfFormat, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +type ListMode = 'all' | 'folders' | 'records' + +const MODE_BY_INPUT: Record = { + '': 'all', + '1': 'all', + '2': 'folders', + '3': 'records', + all: 'all', + folders: 'folders', + records: 'records', +} + +function parseMode(input: string): ListMode { + return MODE_BY_INPUT[input.trim().toLowerCase()] ?? 'all' +} + +async function listNsf() { + const vault = await login() + + try { + logger.info('Show: 1) all 2) folders only 3) records only') + const mode = parseMode(await prompt('Choose [1]: ')) + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const asCsv = !asJson && isYes(await prompt('Output as CSV? [y/N]: ')) + const outputPath = (await prompt('Output file path (Enter for stdout): ')).trim() + + const format = asJson ? ListNsfFormat.JSON : asCsv ? ListNsfFormat.CSV : ListNsfFormat.Table + const rows = vault.listNestedShareFolders({ + folders: mode !== 'records', + records: mode !== 'folders', + }) + + if (rows.length === 0) { + logger.info('No nested share folder items found.') + return + } + + const output = vault.formatListNsfOutput(rows, format) + if (outputPath && format !== ListNsfFormat.Table) { + await fs.writeFile(outputPath, output, 'utf-8') + logger.info(`Wrote ${rows.length} row(s) to ${outputPath}`) + return + } + + logger.info('') + logger.info(output) + logger.info('') + logger.info(`Total: ${rows.length} item${rows.length === 1 ? '' : 's'}`) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(listNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts new file mode 100644 index 00000000..d4ce1c7d --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts @@ -0,0 +1,119 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfRemoveOperation, + prompt, + suppressLogs, + type RemoveNsfRecordInput, + type RemoveNsfRecordResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveOperation.OwnerTrash, + '1': NsfRemoveOperation.OwnerTrash, + '2': NsfRemoveOperation.FolderTrash, + '3': NsfRemoveOperation.Unlink, + 'owner-trash': NsfRemoveOperation.OwnerTrash, + 'folder-trash': NsfRemoveOperation.FolderTrash, + unlink: NsfRemoveOperation.Unlink, +} + +function parseOperation(input: string): NsfRemoveOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveOperation.OwnerTrash +} + +function printPreview(vault: Awaited>, result: RemoveNsfRecordResult): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(vault.formatRemoveNsfPreview(result.preview)) + logger.info('') +} + +function printPreviewWarnings(result: RemoveNsfRecordResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function removeNestedShareRecords( + vault: Awaited>, + input: RemoveNsfRecordInput +): Promise { + const restore = suppressLogs() + try { + return await vault.removeNestedShareRecords(input) + } finally { + restore() + } +} + +async function removeNsf() { + const vault = await login() + + try { + const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() + const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + logger.info('Operation: 1) owner-trash 2) folder-trash 3) unlink') + const operation = parseOperation(await prompt('Choose [1]: ')) + const folder = + operation === NsfRemoveOperation.Unlink + ? (await prompt('Folder UID or name (required for unlink): ')).trim() + : (await prompt('Folder UID or name (optional): ')).trim() + const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) + const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + + const baseInput: RemoveNsfRecordInput = { + records, + folder: folder || undefined, + operation, + } + + if (dryRun) { + const result = await removeNestedShareRecords(vault, { ...baseInput, dryRun: true }) + printPreview(vault, result) + logger.info('[Dry-run] No records were removed.') + return + } + + if (force) { + const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) + printPreview(vault, result) + if (result.confirmed && result.message) { + logger.info(result.message) + } + return + } + + const preview = await removeNestedShareRecords(vault, { ...baseInput, force: false }) + printPreview(vault, preview) + printPreviewWarnings(preview) + + if (!isYes(await prompt('Do you want to proceed with deletion? [y/n]: '))) { + logger.info('Removal cancelled.') + return + } + + const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) + if (result.confirmed && result.message) { + logger.info(result.message) + } + } catch (err) { + logger.error(`Remove failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(removeNsf) From 51510ea2891006f8bf47720063e702e97baa45af Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 16 Jun 2026 23:18:10 +0530 Subject: [PATCH 2/2] Extra space remove --- KeeperSdk/src/nestedShareFolders/nsfConstants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index 8648a7b0..9cb0514c 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -37,8 +37,8 @@ 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_FOLDER_SHARE_ADMINS_HEADING = ' Share Administrators:' +export const NSF_FOLDER_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_LIST_TABLE_HEADERS = ['#', 'Item Type', 'UID', 'Title', 'Type', 'Description'] as const