diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 5e93ce18..ca5c06ce 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,50 @@ 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, + NestedShareFolderManager, +} from './nestedShareFolders' +export type { + ListNsfFormatInput, + ListNsfOptions, + ListNsfRow, + FormattedListNsfTable, + GetNsfFormatInput, + GetNsfOptions, + GetNsfResult, + NsfFolderView, + NsfRecordView, + NsfFolderPermission, + NsfFolderAccessRow, + NsfRecordPermission, +} 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..bf1169bb --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -0,0 +1,75 @@ +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' + +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) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts new file mode 100644 index 00000000..cf92712e --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -0,0 +1,527 @@ +import type { Auth, DRecord, DKdFolder, DKdFolderAccess } from '@keeper-security/keeperapi' +import { + Enterprise, + Folder, + Records, + getRecordsDetailsMessage, + 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, + getKeeperDriveFolders, + getKeeperDriveRecord, + getKeeperDriveRecords, + isFolderShareAdministrator, + isFolderUserPermission, + isSensitiveFieldType, + normalizeParentUid, + resolveAccessUsername, +} from './nsfHelpers' + +const MASKED_VALUE = '********' +const FOLDER_LABEL_WIDTH = 22 +const RECORD_LABEL_WIDTH = 17 +const TOP_LEVEL_FIELD_TYPES = new Set(['login', 'password', 'url', 'note', 'multiline', 'text']) +const UNKNOWN_RECORD_TITLES = new Set(['(no data)', '(untitled)', 'Unknown']) +const FOLDER_USER_PERMISSIONS_HEADING = ' User Permissions:' +const FOLDER_SHARE_ADMINS_HEADING = ' Share Administrators:' +const RECORD_USER_PERMISSIONS_HEADING = 'User Permissions:' +const SHARING_ADMINS_API_PATH = 'enterprise/get_sharing_admins' + +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(FOLDER_LABEL_WIDTH)}: ${value}` +} + +function recordDetailRow(label: string, value: string): string { + return `${label.padStart(RECORD_LABEL_WIDTH)}: ${value}` +} + +function recordDetailsMessage(recordUid: string, include: Records.RecordDetailsInclude) { + return getRecordsDetailsMessage({ + clientTime: Date.now(), + recordUid: [normal64Bytes(recordUid)], + recordDetailsInclude: include, + }) +} + +function recordSharingAdminsMessage(recordUid: string) { + const uid = normal64Bytes(recordUid) + return { + path: SHARING_ADMINS_API_PATH, + toBytes(): Uint8Array { + return Enterprise.GetSharingAdminsRequest.encode({ recordUid: uid }).finish() + }, + fromBytes(data: Uint8Array): Enterprise.IGetSharingAdminsResponse { + return Enterprise.GetSharingAdminsResponse.decode(data) + }, + } +} + +function bytesToUid(bytes: Uint8Array | null | undefined): string | undefined { + return bytes?.length ? webSafe64FromBytes(bytes) : undefined +} + +function longToNumber(value: number | { toNumber: () => number } | null | undefined): number | undefined { + if (value == null) return undefined + return typeof value === 'number' ? value : value.toNumber() +} + +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 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) + return { + userPermissions: entries + .filter(isFolderUserPermission) + .map((entry) => buildFolderAccessRow(storage, folder, entry)), + shareAdmins: entries + .filter(isFolderShareAdministrator) + .map((entry) => buildFolderAccessRow(storage, folder, entry)), + teamPermissions: entries + .filter((entry) => entry.accessType === Folder.AccessType.AT_TEAM) + .map(mapFolderPermission), + } +} + +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).flatMap((field) => { + if (TOP_LEVEL_FIELD_TYPES.has(field.type)) return [] + const rawValue = Array.isArray(field.value) ? field.value[0] : field.value + if (rawValue == null || rawValue === '') return [] + const value = !unmask && isSensitiveFieldType(field.type) ? MASKED_VALUE : String(rawValue) + return [{ type: field.type, label: field.label, value }] + }) +} + +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(recordSharingAdminsMessage(recordUid)) + return (response.userProfileExts ?? []) + .map((ext) => ext.email || '') + .filter((email) => email.length > 0) + .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 (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 : MASKED_VALUE) : undefined, + url: getRecordUrl(record) || undefined, + notes, + fields: buildRecordFields(record, unmask), + userPermissions, + shareAdmins, + } +} + +export function resolveNsfFolder(storage: InMemoryStorage, identifier: string): string | undefined { + return resolveByUidOrName( + getKeeperDriveFolders(storage), + identifier, + (folder) => folder.uid, + (folder) => folder.data.name || '' + )?.uid +} + +export function resolveNsfRecord(storage: InMemoryStorage, identifier: string): string | undefined { + const trimmed = identifier.trim() + if (!trimmed) return undefined + return getKeeperDriveRecord(storage, trimmed)?.uid ?? resolveRecordByTitleSearch(storage, trimmed)?.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), + '', + FOLDER_USER_PERMISSIONS_HEADING, + ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), + '', + 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('', 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..d03d09fa --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -0,0 +1,54 @@ +export { + ROOT_FOLDER_UID, + KeeperDriveKind, + NsfItemType, + formatAccessRoleType, + formatAccessType, + normalizeParentUid, + isRootFolderUid, + getKeeperDriveFolders, + getKeeperDriveRecords, + findRecordFolderLocation, + buildFolderPath, + isSensitiveFieldType, + resolveAccessUsername, + folderAccessDisplayRole, +} 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 { NestedShareFolderManager } from './NestedShareFolderManager' diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts new file mode 100644 index 00000000..ef7da4d8 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -0,0 +1,172 @@ +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { + NsfItemType, + findRecordFolderLocation, + getKeeperDriveFolders, + getKeeperDriveRecords, + getRecordDescription, + normalizeParentUid, +} from './nsfHelpers' +import { getRecordTitle, getRecordType } from '../records/RecordUtils' + +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[][] +} + +const TABLE_HEADERS = ['#', 'Item Type', 'UID', 'Title', 'Type', 'Description'] as const +const FULL_HEADERS = ['Item Type', 'UID', 'Title', 'Type', 'Description', 'Parent/Folder'] as const +const DEFAULT_COLUMN_WIDTH = 40 +const MIN_TRUNCATE_PREFIX = 3 + +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 <= MIN_TRUNCATE_PREFIX) return text.slice(0, maxLength) + return `${text.slice(0, maxLength - MIN_TRUNCATE_PREFIX)}...` +} + +export function formatListNsfTable( + rows: ListNsfRow[], + options: { columnWidth?: number } = {} +): FormattedListNsfTable { + const columnWidth = options.columnWidth ?? 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: [...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 = [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/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts new file mode 100644 index 00000000..a071c71d --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -0,0 +1,194 @@ +import type { + DRecord, + DUser, + DKdFolder, + DKdFolderAccess, + Dependency, +} from '@keeper-security/keeperapi' +import { Folder, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' + +const 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', +} + +const 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', +} + +const SENSITIVE_FIELD_TYPES = new Set(['password', 'secret', 'pinCode']) +const NOTE_FIELD_TYPES = new Set(['note', 'multiline']) +const RECORD_DESCRIPTION_MAX_LENGTH = 120 + +export const ROOT_FOLDER_UID = 'AAAAAAAAAAAAAAAAAPmtNA' + +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 formatAccessRoleType(role: Folder.AccessRoleType | null | undefined): string { + if (role == null) return 'unknown' + return ACCESS_ROLE_LABELS[role] ?? `role-${role}` +} + +export function formatAccessType(type: Folder.AccessType | null | undefined): string { + if (type == null) return 'unknown' + return 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 { + for (const folder of getKeeperDriveFolders(storage)) { + const children = storage.getDependenciesSync(folder.uid) + if (children?.some((child: Dependency) => child.uid === recordUid && child.kind === 'record')) { + return isRootFolderUid(folder.uid) ? 'root' : folder.data.name || folder.uid + } + } + return 'root' +} + +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 children = storage.getDependenciesSync(folderUid) + if (!children?.length) return [] + + const records: DRecord[] = [] + for (const child of children) { + if (child.kind !== 'record') continue + const record = getKeeperDriveRecord(storage, child.uid) + 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 (!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, RECORD_DESCRIPTION_MAX_LENGTH) + } + } + + if (typeof data.notes === 'string' && data.notes.trim()) { + return data.notes.trim().slice(0, RECORD_DESCRIPTION_MAX_LENGTH) + } + return '' +} + +export function isSensitiveFieldType(fieldType: string): boolean { + return 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/sharing/Sharing.ts b/KeeperSdk/src/sharing/Sharing.ts index 9046f369..4dcc2074 100644 --- a/KeeperSdk/src/sharing/Sharing.ts +++ b/KeeperSdk/src/sharing/Sharing.ts @@ -4,7 +4,7 @@ import { Authentication, platform, getPublicKeysMessage, - getRecordsDetailsMessage, + keeperDriveRecordAccessByUid, webSafe64FromBytes, recordsShareUpdateMessage, normal64Bytes, @@ -241,15 +241,9 @@ function longToNumber(value: number | { toNumber: () => number } | null | undefi } export async function getRecordShareInfo(auth: Auth, recordUid: string): Promise { - const msg = getRecordsDetailsMessage({ - clientTime: Date.now(), - recordUid: [normal64Bytes(recordUid)], - recordDetailsInclude: Records.RecordDetailsInclude.SHARE_ONLY, - }) - - let response: Records.IGetRecordDataWithAccessInfoResponse + let response try { - response = await auth.executeRest(msg) + response = await auth.executeRest(keeperDriveRecordAccessByUid(recordUid)) } catch (err) { throw new KeeperSdkError( `Failed to fetch share info for ${recordUid}: ${extractErrorMessage(err)}` diff --git a/KeeperSdk/src/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 89424b97..dc1ba492 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -74,6 +74,10 @@ export class InMemoryStorage implements VaultStorage { return this.dependenciesByParent.get(uid) } + public getDependenciesSync(uid: string): Dependency[] | undefined { + return this.dependenciesByParent.get(uid) + } + public async addDependencies(dependencies: Dependencies): Promise { for (const [parentUid, children] of Object.entries(dependencies)) { if (!this.dependenciesByParent.has(parentUid)) { @@ -137,6 +141,7 @@ export class InMemoryStorage implements VaultStorage { token?: string sharedFolderUid?: string recordUid?: string + folderUid?: string accountUid?: string | Uint8Array teamUid?: string } @@ -157,6 +162,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 b33a1b8c..2aca283d 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -54,6 +54,11 @@ export enum RoleErrorCode { RoleEnforcementFailed = 'role_enforcement_failed', } +export enum NsfErrorCode { + NotFound = 'nsf_not_found', + MultipleMatches = 'nsf_multiple_matches', +} + export enum TeamErrorCode { TeamRequired = 'team_required', TeamNotFound = 'team_not_found', @@ -121,6 +126,8 @@ 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, 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..aa1976cf 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -75,6 +75,9 @@ 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 { ListUserRow, ListUsersOptions, @@ -142,6 +145,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 +169,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 +705,31 @@ 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 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..268c9f58 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -29,6 +29,8 @@ "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", "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..33a4c7ba --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts @@ -0,0 +1,54 @@ +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('') + if (asJson) { + logger.info(JSON.stringify(result.view, null, 2)) + } else { + logger.info(vault.formatNsfDetail(result, verbose)) + } + 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/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/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 2a36bef4..c1d6cfe4 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -451,6 +451,36 @@ export const getRecordsDetailsMessage = ( Records.GetRecordDataWithAccessInfoResponse ) +export const keeperDriveRecordAccessMessage = ( + recordUids: Uint8Array[], + clientTime: number = Date.now() +): RestMessage => + getRecordsDetailsMessage({ + clientTime, + recordUid: recordUids, + recordDetailsInclude: Records.RecordDetailsInclude.SHARE_ONLY, + }) + +export const keeperDriveRecordDataMessage = ( + recordUids: Uint8Array[], + clientTime: number = Date.now() +): RestMessage => + getRecordsDetailsMessage({ + clientTime, + recordUid: recordUids, + recordDetailsInclude: Records.RecordDetailsInclude.DATA_ONLY, + }) + +export const getSharingAdminsMessage = ( + data: Enterprise.IGetSharingAdminsRequest +): RestMessage => + createMessage( + data, + 'enterprise/get_sharing_admins', + Enterprise.GetSharingAdminsRequest, + Enterprise.GetSharingAdminsResponse + ) + export const recordsAddMessage = ( data: Records.IRecordsAddRequest ): RestMessage =>