secret storage more changes

This commit is contained in:
EthanShoeDev
2025-09-10 23:14:21 -04:00
parent 37785698f8
commit 92882e276e
5 changed files with 616 additions and 100 deletions

View File

@@ -3,8 +3,10 @@ import { Picker } from '@react-native-picker/picker';
import { useStore } from '@tanstack/react-form';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useAppForm, withFieldGroup } from '../components/form-components';
import { KeyManagerModal } from '../components/key-manager-modal';
import {
type ConnectionDetails,
connectionDetailsSchema,
@@ -48,15 +50,14 @@ export default function Index() {
value.security.password,
);
}
const privateKey =
await secretsManager.keys.utils.betterKeyStorage.getEntry(
value.security.keyId,
);
const privateKey = await secretsManager.keys.utils.getPrivateKey(
value.security.keyId,
);
return await SSHClient.connectWithKey(
value.host,
value.port,
value.username,
privateKey,
privateKey.value,
);
})();
@@ -218,47 +219,54 @@ const KeyPairSection = withFieldGroup({
props: {},
render: function Render({ group }) {
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
const [showManager, setShowManager] = React.useState(false);
return (
<group.AppField name="keyId">
{(field) =>
listPrivateKeysQuery.isLoading ? (
<Text style={styles.mutedText}>Loading keys...</Text>
) : listPrivateKeysQuery.isError ? (
<Text style={styles.errorText}>
Error: {listPrivateKeysQuery.error.message}
</Text>
) : (
{(field) => {
if (listPrivateKeysQuery.isLoading) {
return <Text style={styles.mutedText}>Loading keys...</Text>;
}
if (listPrivateKeysQuery.isError) {
return (
<Text style={styles.errorText}>
Error: {listPrivateKeysQuery.error.message}
</Text>
);
}
return (
<>
<field.PickerField label="Key">
{listPrivateKeysQuery.data?.map((key) => (
<Picker.Item key={key.id} label={key.id} value={key.id} />
))}
{listPrivateKeysQuery.data?.map((key) => {
const label = `${key.metadata?.label ?? key.id}${
key.metadata?.isDefault ? ' • Default' : ''
}`;
return (
<Picker.Item key={key.id} label={label} value={key.id} />
);
})}
</field.PickerField>
<Pressable
style={styles.secondaryButton}
onPress={async () => {
const newKeyPair =
await secretsManager.keys.utils.generateKeyPair({
type: 'rsa',
keySize: 4096,
});
await secretsManager.keys.utils.upsertPrivateKey({
keyId: 'default',
privateKey: newKeyPair.privateKey,
priority: 0,
});
field.handleChange('default');
console.log('New key pair generated and saved');
}}
onPress={() => setShowManager(true)}
>
<Text style={styles.secondaryButtonText}>
Generate New Key Pair
</Text>
<Text style={styles.secondaryButtonText}>Manage Keys</Text>
</Pressable>
<KeyManagerModal
visible={showManager}
onClose={() => {
setShowManager(false);
if (!field.state.value && listPrivateKeysQuery.data) {
const def = listPrivateKeysQuery.data.find(
(k) => k.metadata?.isDefault,
);
if (def) field.handleChange(def.id);
}
}}
/>
</>
)
}
);
}}
</group.AppField>
);
},
@@ -298,7 +306,7 @@ function ConnectionRow(props: {
onSelect: (connection: ConnectionDetails) => void;
}) {
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
const details = detailsQuery.data;
const details = detailsQuery.data?.value;
return (
<Pressable

View File

@@ -0,0 +1,325 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import React from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
TextInput,
View,
ActivityIndicator,
} from 'react-native';
import { secretsManager } from '../lib/secrets-manager';
export function KeyManagerModal(props: {
visible: boolean;
onClose: () => void;
}) {
const listKeysQuery = useQuery(secretsManager.keys.query.list);
const generateMutation = useMutation({
mutationFn: async () => {
const id = `key_${Date.now()}`;
const pair = await secretsManager.keys.utils.generateKeyPair({
type: 'rsa',
keySize: 4096,
});
await secretsManager.keys.utils.upsertPrivateKey({
keyId: id,
metadata: { priority: 0, label: 'New Key', isDefault: false },
value: pair.privateKey,
});
},
});
async function handleDelete(keyId: string) {
await secretsManager.keys.utils.deletePrivateKey(keyId);
}
async function handleSetDefault(keyId: string) {
const entries = await secretsManager.keys.utils.listEntriesWithValues();
await Promise.all(
entries.map((e) =>
secretsManager.keys.utils.upsertPrivateKey({
keyId: e.id,
value: e.value,
metadata: {
priority: e.metadata.priority,
label: e.metadata.label,
isDefault: e.id === keyId,
},
}),
),
);
}
async function handleGenerate() {
await generateMutation.mutateAsync();
}
return (
<Modal visible={props.visible} transparent animationType="slide">
<View style={styles.overlay}>
<View style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>Manage Keys</Text>
<Pressable style={styles.closeBtn} onPress={props.onClose}>
<Text style={styles.closeText}>Close</Text>
</Pressable>
</View>
<Pressable
style={[
styles.primaryButton,
generateMutation.isPending && { opacity: 0.7 },
]}
disabled={generateMutation.isPending}
onPress={handleGenerate}
>
<Text style={styles.primaryButtonText}>
{generateMutation.isPending
? 'Generating…'
: 'Generate New RSA 4096 Key'}
</Text>
</Pressable>
{listKeysQuery.isLoading ? (
<View style={styles.centerRow}>
<ActivityIndicator color="#9AA0A6" />
<Text style={styles.muted}> Loading keys</Text>
</View>
) : listKeysQuery.isError ? (
<Text style={styles.error}>Error loading keys</Text>
) : listKeysQuery.data?.length ? (
<View>
{listKeysQuery.data.map((k) => (
<KeyRow
key={k.id}
entry={k}
onDelete={() => handleDelete(k.id)}
onSetDefault={() => handleSetDefault(k.id)}
/>
))}
</View>
) : (
<Text style={styles.muted}>No keys yet</Text>
)}
</View>
</View>
</Modal>
);
}
function KeyRow(props: {
entry: Awaited<
ReturnType<typeof secretsManager.keys.utils.listEntriesWithValues>
>[number];
onDelete: () => void;
onSetDefault: () => void;
}) {
const [isEditing, setIsEditing] = React.useState(false);
const [label, setLabel] = React.useState(props.entry.metadata?.label ?? '');
const isDefault = props.entry.metadata?.isDefault;
const renameMutation = useMutation({
mutationFn: async (newLabel: string) => {
await secretsManager.keys.utils.upsertPrivateKey({
keyId: props.entry.id,
value: props.entry.value,
metadata: {
priority: props.entry.metadata.priority,
label: newLabel,
isDefault: props.entry.metadata.isDefault,
},
});
},
onSuccess: () => setIsEditing(false),
});
async function saveLabel() {
await renameMutation.mutateAsync(label);
}
return (
<View style={styles.row}>
<View style={{ flex: 1, marginRight: 8 }}>
<Text style={styles.rowTitle}>
{(props.entry.metadata?.label ?? props.entry.id) +
(isDefault ? ' • Default' : '')}
</Text>
<Text style={styles.rowSub}>ID: {props.entry.id}</Text>
{isEditing ? (
<TextInput
style={styles.input}
placeholder="Display name"
placeholderTextColor="#9AA0A6"
value={label}
onChangeText={setLabel}
/>
) : null}
</View>
<View style={styles.rowActions}>
{!isDefault ? (
<Pressable
style={styles.secondaryButton}
onPress={props.onSetDefault}
>
<Text style={styles.secondaryButtonText}>Set Default</Text>
</Pressable>
) : null}
{isEditing ? (
<Pressable
style={[
styles.secondaryButton,
renameMutation.isPending && { opacity: 0.6 },
]}
onPress={saveLabel}
disabled={renameMutation.isPending}
>
<Text style={styles.secondaryButtonText}>
{renameMutation.isPending ? 'Saving…' : 'Save'}
</Text>
</Pressable>
) : (
<Pressable
style={styles.secondaryButton}
onPress={() => setIsEditing(true)}
>
<Text style={styles.secondaryButtonText}>Rename</Text>
</Pressable>
)}
<Pressable style={styles.dangerButton} onPress={props.onDelete}>
<Text style={styles.dangerButtonText}>Delete</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: '#0B1324',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 16,
borderColor: '#1E293B',
borderWidth: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
title: {
color: '#E5E7EB',
fontSize: 18,
fontWeight: '700',
},
closeBtn: {
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
borderWidth: 1,
borderColor: '#2A3655',
},
closeText: {
color: '#C6CBD3',
fontWeight: '600',
},
input: {
borderWidth: 1,
borderColor: '#2A3655',
backgroundColor: '#0E172B',
color: '#E5E7EB',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
marginTop: 8,
},
primaryButton: {
backgroundColor: '#2563EB',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
marginBottom: 12,
},
primaryButtonText: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 14,
},
centerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
muted: {
color: '#9AA0A6',
},
error: {
color: '#FCA5A5',
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
backgroundColor: '#0E172B',
borderWidth: 1,
borderColor: '#2A3655',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
marginBottom: 10,
},
rowTitle: {
color: '#E5E7EB',
fontSize: 15,
fontWeight: '600',
},
rowSub: {
color: '#9AA0A6',
fontSize: 12,
marginTop: 2,
},
rowActions: {
gap: 6,
alignItems: 'flex-end',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#2A3655',
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 10,
alignItems: 'center',
},
secondaryButtonText: {
color: '#C6CBD3',
fontWeight: '600',
fontSize: 12,
},
dangerButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#7F1D1D',
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 10,
alignItems: 'center',
},
dangerButtonText: {
color: '#FCA5A5',
fontWeight: '700',
fontSize: 12,
},
});
export default KeyManagerModal;

View File

@@ -1,8 +1,9 @@
import SSHClient from '@dylankenneally/react-native-ssh-sftp';
import { queryOptions } from '@tanstack/react-query';
import * as SecureStore from 'expo-secure-store';
import uuid from 'react-native-uuid';
import * as z from 'zod';
import { queryClient } from './utils';
import { queryClient, type StrictOmit } from './utils';
function splitIntoChunks(data: string, chunkSize: number): string[] {
const chunks: string[] = [];
@@ -22,7 +23,7 @@ function splitIntoChunks(data: string, chunkSize: number): string[] {
function makeBetterSecureStore<
T extends object = object,
Value = string,
>(params: {
>(storeParams: {
storagePrefix: string;
parseValue: (value: string) => Value;
extraManifestFieldsSchema?: z.ZodType<T>;
@@ -32,11 +33,11 @@ function makeBetterSecureStore<
const rootManifestVersion = 1;
const manifestChunkVersion = 1;
const rootManifestKey = `${params.storagePrefix}rootManifest`;
const rootManifestKey = [storeParams.storagePrefix, 'rootManifest'].join('-');
const manifestChunkKey = (manifestChunkId: string) =>
`${params.storagePrefix}manifestChunk_${manifestChunkId}`;
[storeParams.storagePrefix, 'manifestChunk', manifestChunkId].join('-');
const entryKey = (entryId: string, chunkIdx: number) =>
`${params.storagePrefix}entry_${entryId}_chunk_${chunkIdx}`;
[storeParams.storagePrefix, 'entry', entryId, 'chunk', chunkIdx].join('-');
const rootManifestSchema = z.looseObject({
manifestVersion: z.number().default(rootManifestVersion),
@@ -47,7 +48,7 @@ function makeBetterSecureStore<
const entrySchema = z.object({
id: z.string(),
chunkCount: z.number().default(1),
metadata: params.extraManifestFieldsSchema ?? z.object({}),
metadata: storeParams.extraManifestFieldsSchema ?? z.object({}),
});
// type Entry = {
// id: string;
@@ -66,8 +67,10 @@ function makeBetterSecureStore<
const rawRootManifestString =
await SecureStore.getItemAsync(rootManifestKey);
console.log('DEBUG rawRootManifestString', rawRootManifestString);
console.log(
`Root manifest for ${params.storagePrefix} is ${rawRootManifestString?.length} bytes`,
`Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`,
);
const unsafedRootManifest = rawRootManifestString
? JSON.parse(rawRootManifestString)
@@ -78,13 +81,14 @@ function makeBetterSecureStore<
const rootManifest = rootManifestSchema.parse(unsafedRootManifest);
const manifestChunks = await Promise.all(
rootManifest.manifestChunksIds.map(async (manifestChunkId) => {
const manifestChunkKeyString = manifestChunkKey(manifestChunkId);
const rawManifestChunkString = await SecureStore.getItemAsync(
manifestChunkKey(manifestChunkId),
manifestChunkKeyString,
);
if (!rawManifestChunkString)
throw new Error('Manifest chunk not found');
console.log(
`Manifest chunk for ${params.storagePrefix} ${manifestChunkId} is ${rawManifestChunkString?.length} bytes`,
`Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString?.length} bytes`,
);
const unsafedManifestChunk = JSON.parse(rawManifestChunkString);
return {
@@ -100,20 +104,22 @@ function makeBetterSecureStore<
};
}
async function _getEntryValueFromManifestEntry(manifestEntry: Entry) {
async function _getEntryValueFromManifestEntry(
manifestEntry: Entry,
): Promise<Value> {
const rawEntryChunks = await Promise.all(
Array.from({ length: manifestEntry.chunkCount }, async (_, chunkIdx) => {
const rawEntryChunk = await SecureStore.getItemAsync(
entryKey(manifestEntry.id, chunkIdx),
);
const entryKeyString = entryKey(manifestEntry.id, chunkIdx);
const rawEntryChunk = await SecureStore.getItemAsync(entryKeyString);
console.log(
`Entry chunk for ${params.storagePrefix} ${manifestEntry.id} ${chunkIdx} is ${rawEntryChunk?.length} bytes`,
`Entry chunk for ${entryKeyString} is ${rawEntryChunk?.length} bytes`,
);
if (!rawEntryChunk) throw new Error('Entry chunk not found');
return rawEntryChunk;
}),
);
const entry = rawEntryChunks.join('');
return params.parseValue(entry);
return storeParams.parseValue(entry);
}
async function getEntry(id: string) {
@@ -125,7 +131,10 @@ function makeBetterSecureStore<
);
if (!manifestEntry) throw new Error('Entry not found');
return _getEntryValueFromManifestEntry(manifestEntry);
return {
value: await _getEntryValueFromManifestEntry(manifestEntry),
manifestEntry,
};
}
async function listEntries() {
@@ -136,20 +145,22 @@ function makeBetterSecureStore<
return manifestEntries;
}
async function listEntriesWithValues() {
async function listEntriesWithValues(): Promise<
(Entry & { value: Value })[]
> {
const manifestEntries = await listEntries();
return await Promise.all(
manifestEntries.map(async (entry) => {
return {
...entry,
value: await _getEntryValueFromManifestEntry(entry),
};
} as Entry & { value: Value };
}),
);
}
async function deleteEntry(id: string) {
const manifest = await getManifest();
let manifest = await getManifest();
const manifestChunkContainingEntry = manifest.manifestChunks.find(
(mChunk) => mChunk.manifestChunk.entries.some((entry) => entry.id === id),
);
@@ -161,27 +172,54 @@ function makeBetterSecureStore<
);
if (!manifestEntry) throw new Error('Entry not found');
const deleteEntryChunksPromise = Array.from(
{ length: manifestEntry.chunkCount },
async (_, chunkIdx) => {
await SecureStore.deleteItemAsync(entryKey(id, chunkIdx));
},
);
const updateManifestChunkPromise = SecureStore.setItemAsync(
manifestChunkKey(manifestChunkContainingEntry.manifestChunkId),
JSON.stringify({
...manifestChunkContainingEntry,
entries: manifestChunkContainingEntry.manifestChunk.entries.filter(
(entry) => entry.id !== id,
),
}),
);
await Promise.all([
...deleteEntryChunksPromise,
updateManifestChunkPromise,
...Array.from(
{ length: manifestEntry.chunkCount },
async (_, chunkIdx) => {
await SecureStore.deleteItemAsync(entryKey(id, chunkIdx));
},
),
SecureStore.setItemAsync(
manifestChunkKey(manifestChunkContainingEntry.manifestChunkId),
JSON.stringify({
...manifestChunkContainingEntry.manifestChunk,
entries: manifestChunkContainingEntry.manifestChunk.entries.filter(
(entry) => entry.id !== id,
),
}),
),
]);
manifest = await getManifest();
// check for empty manifest chunks
const emptyManifestChunks = manifest.manifestChunks.filter(
(mChunk) => mChunk.manifestChunk.entries.length === 0,
);
if (emptyManifestChunks.length > 0) {
console.log(
'DEBUG: removing empty manifest chunks',
emptyManifestChunks.length,
);
manifest.rootManifest.manifestChunksIds =
manifest.rootManifest.manifestChunksIds.filter(
(mChunkId) =>
!emptyManifestChunks.some(
(mChunk) => mChunk.manifestChunkId === mChunkId,
),
);
await Promise.all([
...emptyManifestChunks.map(async (mChunk) => {
await SecureStore.deleteItemAsync(
manifestChunkKey(mChunk.manifestChunkId),
);
}),
SecureStore.setItemAsync(
rootManifestKey,
JSON.stringify(manifest.rootManifest),
),
]);
}
}
async function upsertEntry(params: {
@@ -197,8 +235,8 @@ function makeBetterSecureStore<
const newManifestEntry = entrySchema.parse({
id: params.id,
chunkCount: valueChunks.length,
...params.metadata,
});
metadata: params.metadata,
} satisfies Entry);
const newManifestEntrySize = JSON.stringify(newManifestEntry).length;
if (newManifestEntrySize > sizeLimit / 2)
throw new Error('Manifest entry size is too large');
@@ -207,6 +245,10 @@ function makeBetterSecureStore<
const existingManifestChunkWithRoom = manifest.manifestChunks.find(
(mChunk) => sizeLimit > mChunk.manifestChunkSize + newManifestEntrySize,
);
console.log(
'DEBUG existingManifestChunkWithRoom',
existingManifestChunkWithRoom,
);
const manifestChunkWithRoom =
existingManifestChunkWithRoom ??
(await (async () => {
@@ -215,28 +257,43 @@ function makeBetterSecureStore<
entries: [],
manifestChunkVersion: manifestChunkVersion,
},
manifestChunkId: crypto.randomUUID(),
manifestChunkId: String(uuid.v4()),
manifestChunkSize: 0,
} satisfies NonNullable<(typeof manifest.manifestChunks)[number]>;
console.log(
`Adding new manifest chunk ${newManifestChunk.manifestChunkId}`,
);
manifest.rootManifest.manifestChunksIds.push(
newManifestChunk.manifestChunkId,
);
await SecureStore.setItemAsync(
rootManifestKey,
JSON.stringify(manifest.rootManifest),
);
console.log('DEBUG: newRootManifest', manifest.rootManifest);
return newManifestChunk;
})());
manifestChunkWithRoom.manifestChunk.entries.push(newManifestEntry);
const manifestChunkKeyString = manifestChunkKey(
manifestChunkWithRoom.manifestChunkId,
);
await Promise.all([
SecureStore.setItemAsync(
manifestChunkKey(manifestChunkWithRoom.manifestChunkId),
manifestChunkKeyString,
JSON.stringify(manifestChunkWithRoom.manifestChunk),
),
...valueChunks.map((vChunk, chunkIdx) =>
SecureStore.setItemAsync(
entryKey(newManifestEntry.id, chunkIdx),
vChunk,
),
),
).then(() => {
console.log(
`Set manifest chunk for ${manifestChunkKeyString} to ${JSON.stringify(manifestChunkWithRoom.manifestChunk).length} bytes`,
);
}),
...valueChunks.map(async (vChunk, chunkIdx) => {
const entryKeyString = entryKey(newManifestEntry.id, chunkIdx);
await SecureStore.setItemAsync(entryKeyString, vChunk);
console.log(
`Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`,
);
}),
]);
}
@@ -250,28 +307,44 @@ function makeBetterSecureStore<
};
}
const betterKeyStorage = makeBetterSecureStore({
storagePrefix: 'privateKey_',
extraManifestFieldsSchema: z.object({
priority: z.number(),
createdAtMs: z.int(),
}),
const keyMetadataSchema = z.object({
priority: z.number(),
createdAtMs: z.int(),
// Optional display name for the key
label: z.string().optional(),
// Optional default flag
isDefault: z.boolean().optional(),
});
export type KeyMetadata = z.infer<typeof keyMetadataSchema>;
const betterKeyStorage = makeBetterSecureStore<KeyMetadata, string>({
storagePrefix: 'privateKey',
extraManifestFieldsSchema: keyMetadataSchema,
parseValue: (value) => value,
});
async function upsertPrivateKey(params: {
keyId: string;
privateKey: string;
priority: number;
metadata: StrictOmit<KeyMetadata, 'createdAtMs'>;
value: string;
}) {
console.log(`Upserting private key ${params.keyId}`);
// Preserve createdAtMs if the entry already exists
const existing = await betterKeyStorage
.getEntry(params.keyId)
.catch(() => undefined);
const createdAtMs =
existing?.manifestEntry.metadata.createdAtMs ?? Date.now();
await betterKeyStorage.upsertEntry({
id: params.keyId,
metadata: {
priority: params.priority,
createdAtMs: Date.now(),
...params.metadata,
createdAtMs,
},
value: params.privateKey,
value: params.value,
});
console.log('DEBUG: invalidating key query');
await queryClient.invalidateQueries({ queryKey: [keyQueryKey] });
}
@@ -284,7 +357,11 @@ const keyQueryKey = 'keys';
const listKeysQueryOptions = queryOptions({
queryKey: [keyQueryKey],
queryFn: async () => await betterKeyStorage.listEntries(),
queryFn: async () => {
const results = await betterKeyStorage.listEntriesWithValues();
console.log(`Listed ${results.length} private keys`);
return results;
},
});
const getKeyQueryOptions = (keyId: string) =>
@@ -310,7 +387,7 @@ export const connectionDetailsSchema = z.object({
});
const betterConnectionStorage = makeBetterSecureStore({
storagePrefix: 'connection_',
storagePrefix: 'connection',
extraManifestFieldsSchema: z.object({
priority: z.number(),
createdAtMs: z.int(),
@@ -335,6 +412,7 @@ async function upsertConnection(params: {
},
value: JSON.stringify(params.details),
});
console.log('DEBUG: invalidating connection query');
await queryClient.invalidateQueries({ queryKey: [connectionQueryKey] });
return params.details;
}
@@ -377,10 +455,12 @@ async function generateKeyPair(params: {
export const secretsManager = {
keys: {
utils: {
betterKeyStorage,
upsertPrivateKey,
deletePrivateKey,
generateKeyPair,
listEntriesWithValues: betterKeyStorage.listEntriesWithValues,
getPrivateKey: (keyId: string) => betterKeyStorage.getEntry(keyId),
// Intentionally no specialized setters; use upsertPrivateKey instead.
},
query: {
list: listKeysQueryOptions,
@@ -389,7 +469,6 @@ export const secretsManager = {
},
connections: {
utils: {
betterConnectionStorage,
upsertConnection,
deleteConnection,
},

View File

@@ -1,3 +1,5 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient();
export type StrictOmit<T, K extends keyof T> = Omit<T, K>;