From 71a09d4340c1ca71f3cbaf436af31ec73da19789 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 08:33:16 +0000 Subject: [PATCH] fix(team-vault): a failed secret save never passes for a saved one A team secret whose upload failed stayed in the in-memory cache as if the server held it, then vanished at the next vault load. #609 fixed that only for a server too old for the secret's type. The cache now keeps two layers: what the server holds, and over it the values whose upload has not landed. A write shows its value from the second layer and moves it to the first only once the server has accepted it. - Refused (any 4xx except 401, 408 and 429): the value is dropped at once and the editor shows what the server holds again. - Not reached (offline, timeout, 5xx, expired session): the value stays usable, survives vault reloads, and is uploaded again after every load of the team, foreground or background, until it lands or the app closes. Nothing is written to disk, so the memory-only rule of #402 holds. Clearing the secret drops the unsent value, so a later retry cannot bring it back. Connect prompts, import, paste, duplicate, copy to vault, undo and plugin key creation only logged these failures; they now raise one toast per distinct failure. The opportunistic public-key backfill stays log-only. Keys queued by a move into a team keep their on-device copy and their sweep retry, as before. Refs #608 --- src/components/hosts/HostsPage.tsx | 14 +- src/components/keychain/KeychainPage.tsx | 24 ++-- src/i18n/locales/cs/common.json | 2 + src/i18n/locales/en/common.json | 2 + src/i18n/locales/fr/common.json | 2 + src/i18n/locales/ru/common.json | 2 + src/i18n/locales/tr/common.json | 2 + src/i18n/locales/zh/common.json | 2 + src/plugins/domains/objects.ts | 4 +- src/plugins/runtime.ts | 6 +- src/services/clipboard/connections.ts | 8 +- src/services/connectionDuplicate.ts | 4 +- src/services/editorMoveSecrets.test.ts | 2 +- .../import-export/handlers/connections.ts | 4 +- .../import-export/handlers/identities.ts | 4 +- src/services/import-export/handlers/keys.ts | 4 +- src/services/keychainForm.ts | 4 +- src/services/publicKeyStore.ts | 4 +- src/services/secretFailureToast.ts | 5 + src/services/secretRouting.test.ts | 121 +++++++++++++++--- src/services/secretRouting.ts | 69 ++++++++-- src/services/teamSecretCache.test.ts | 43 +++++++ src/services/teamSecretCache.ts | 30 ++++- .../teamVaultSync.credentials.test.ts | 8 ++ src/services/teamVaultSync.ts | 10 +- src/services/vaultObjectSecrets.ts | 17 +-- src/stores/recreateHistory.ts | 4 +- src/stores/sessionStore.ts | 10 +- ...vaultObjectStores.characterisation.test.ts | 2 +- 29 files changed, 315 insertions(+), 98 deletions(-) create mode 100644 src/services/secretFailureToast.ts diff --git a/src/components/hosts/HostsPage.tsx b/src/components/hosts/HostsPage.tsx index 712cd8009..2339a2bbd 100644 --- a/src/components/hosts/HostsPage.tsx +++ b/src/components/hosts/HostsPage.tsx @@ -61,7 +61,7 @@ import { useAllFolders } from "@/hooks/useAllFolders"; import { SnippetPickerPanel } from "./SnippetPickerPanel"; import { getHostDeleteTargetIds, shouldUseBulkHostContextMenu } from "./hostSelection"; import { buildTeamVaultTransferPlan, type TransferOperation } from "@/services/teamVaultPermissions"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveKeyToVault, moveIdentityToVault } from "@/services/vaultObjectSecrets"; import { saveHostFromForm, type HostFormSecrets } from "@/services/hostForm"; import { descendantFolders, foldersOutsideSubtree, itemsInFolderSubtree, newFolderData, folderItemCounter } from "@/utils/folderTree"; @@ -649,15 +649,15 @@ export default function HostsPage() { getSecret(`key:${key.id}:private`), getSecret(`key:${key.id}:public`), ]); - if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("HostsPage: copy key to vault")); - if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("HostsPage: copy key to vault")); + if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("HostsPage: copy key to vault")); + if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("HostsPage: copy key to vault")); newKeyId = newKey.id; } if (identityNeedsCopy) { const newIdentity = await useIdentityStore.getState().saveIdentity(copyingRulesOf({ name: identity.name, username: identity.username, key_id: newKeyId, tags: identity.tags, vault_id: vaultId }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`); - if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(keepCachedOnUploadFailure("HostsPage: copy identity to vault")); + if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(reportUploadFailure("HostsPage: copy identity to vault")); newIdentityId = newIdentity.id; } @@ -772,10 +772,10 @@ export default function HostsPage() { getSecret(`key:${key.id}:public`), ]); if (priv) { - await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("HostsPage: copy folder key")); + await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("HostsPage: copy folder key")); } if (pub) { - await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("HostsPage: copy folder key")); + await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("HostsPage: copy folder key")); } keyIdMap.set(key.id, newKey.id); } @@ -787,7 +787,7 @@ export default function HostsPage() { const newIdentity = await useIdentityStore.getState().saveIdentity(copyingRulesOf({ name: identity.name, username: identity.username, key_id: newKeyId, tags: identity.tags, vault_id: vaultId }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`); if (pwd) { - await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(keepCachedOnUploadFailure("HostsPage: copy folder identity")); + await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(reportUploadFailure("HostsPage: copy folder identity")); } identityIdMap.set(identity.id, newIdentity.id); } diff --git a/src/components/keychain/KeychainPage.tsx b/src/components/keychain/KeychainPage.tsx index 771f42168..6ab390f7b 100644 --- a/src/components/keychain/KeychainPage.tsx +++ b/src/components/keychain/KeychainPage.tsx @@ -43,7 +43,7 @@ import type { Folder, Identity, IdentityFormData, SshKey, SshKeyFormData } from import { SidePanelLayout } from "@/components/shared/SidePanelLayout"; import { useSyncedFormKey } from "@/hooks/useSyncedFormKey"; import { buildTeamVaultTransferPlan, type TransferOperation } from "@/services/teamVaultPermissions"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveKeyToVault, moveIdentityToVault } from "@/services/vaultObjectSecrets"; import { usePageClipboard } from "@/hooks/usePageClipboard"; import { vaultClipboardBase } from "@/utils/vaultClipboardBase"; @@ -558,9 +558,9 @@ export default function KeychainPage() { getSecret(`key:${key.id}:public`), getSecret(`key:${key.id}:passphrase`), ]); - if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("KeychainPage: copy key to vault")); - if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("KeychainPage: copy key to vault")); - if (pass) await storeSecret(`key:${newKey.id}:passphrase`, pass).catch(keepCachedOnUploadFailure("KeychainPage: copy key to vault")); + if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("KeychainPage: copy key to vault")); + if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("KeychainPage: copy key to vault")); + if (pass) await storeSecret(`key:${newKey.id}:passphrase`, pass).catch(reportUploadFailure("KeychainPage: copy key to vault")); } catch (err) { setError(describeError(err, t)); } }; @@ -608,14 +608,14 @@ export default function KeychainPage() { getSecret(`key:${key.id}:private`), getSecret(`key:${key.id}:public`), ]); - if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("KeychainPage: copy identity's key to vault")); - if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("KeychainPage: copy identity's key to vault")); + if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("KeychainPage: copy identity's key to vault")); + if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("KeychainPage: copy identity's key to vault")); newKeyId = newKey.id; } const newIdentity = await saveIdentity(copyingRulesOf({ name: identity.name, username: identity.username, key_id: newKeyId, tags: identity.tags, vault_id: vaultId }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`); - if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(keepCachedOnUploadFailure("KeychainPage: copy identity to vault")); + if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(reportUploadFailure("KeychainPage: copy identity to vault")); } catch (err) { setError(describeError(err, t)); } }, }); @@ -700,15 +700,15 @@ export default function KeychainPage() { getSecret(`key:${key.id}:private`), getSecret(`key:${key.id}:public`), ]); - if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("KeychainPage: copy folder key")); - if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("KeychainPage: copy folder key")); + if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("KeychainPage: copy folder key")); + if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("KeychainPage: copy folder key")); keyIdMap.set(key.id, newKey.id); } for (const identity of treeIdentities) { const newKeyId = identity.key_id ? (keyIdMap.get(identity.key_id) ?? identity.key_id) : undefined; const newIdentity = await useIdentityStore.getState().saveIdentity(copyingRulesOf({ name: identity.name, username: identity.username, key_id: newKeyId, tags: identity.tags, vault_id: vaultId }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`); - if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(keepCachedOnUploadFailure("KeychainPage: copy folder identity")); + if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(reportUploadFailure("KeychainPage: copy folder identity")); } } catch (err) { setError(describeError(err, t)); } }, @@ -741,7 +741,7 @@ export default function KeychainPage() { const value = await getSecret(`key:${key.id}:${part}`); if (!value) continue; const localKey = `key:${newKey.id}:${part}`; - await storeSecret(localKey, value).catch(keepCachedOnUploadFailure("duplicateKeyInto")); + await storeSecret(localKey, value).catch(reportUploadFailure("duplicateKeyInto")); } return newKey; } @@ -765,7 +765,7 @@ export default function KeychainPage() { const pwd = await getSecret(`identity:${identity.id}:password`); if (pwd) { const localKey = `identity:${newIdentity.id}:password`; - await storeSecret(localKey, pwd).catch(keepCachedOnUploadFailure("duplicateIdentityInto")); + await storeSecret(localKey, pwd).catch(reportUploadFailure("duplicateIdentityInto")); } return newIdentity; } diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index 11830784b..608da36c4 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Přesunuto, ale přihlašovací údaje se zatím nepodařilo nahrát. Zůstávají na tomto zařízení a pokus o nahrání se zopakuje při příštím načtení trezoru: {{error}}", "failedToSaveTeamSecret": "Týmové tajemství se nepodařilo uložit: {{status}}", "serverLacksTeamSecretType": "Tento server Voltius je příliš starý na to, aby toto uložil do týmového trezoru. Požádejte jeho správce o aktualizaci serveru a pak uložte znovu.", + "teamSecretRefused": "Týmový trezor tuto hodnotu nepřijal, takže nebyla uložena: {{error}}", + "teamSecretUploadPending": "Zatím nenahráno do týmového trezoru. Hodnota zůstane uložena do zavření aplikace Voltius a pokus o nahrání se zopakuje při příštím načtení trezoru: {{error}}", "failedToLoadServerCapabilities": "Nepodařilo se ověřit, co server podporuje ({{status}})", "keyFetchFailed": "Načtení klíče se nezdařilo: {{error}}", "failedToUploadVaultKeys": "Klíče trezoru se nepodařilo nahrát: {{status}}", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 4d4eada7d..5050114fd 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Moved, but its credentials could not be uploaded yet. They stay on this device and are retried the next time the vault loads: {{error}}", "failedToSaveTeamSecret": "Failed to save team secret: {{status}}", "serverLacksTeamSecretType": "This Voltius server is too old to store this in a team vault. Ask its administrator to update the server, then save again.", + "teamSecretRefused": "The team vault did not accept this, so it was not saved: {{error}}", + "teamSecretUploadPending": "Not uploaded to the team vault yet. It is kept until Voltius closes and retried the next time the vault loads: {{error}}", "failedToLoadServerCapabilities": "Couldn't check what the server supports ({{status}})", "keyFetchFailed": "Key fetch failed: {{error}}", "failedToUploadVaultKeys": "Failed to upload vault keys: {{status}}", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index ef82d2b84..77c0e32e8 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Déplacé, mais ses identifiants n'ont pas encore pu être envoyés. Ils restent sur cet appareil et seront renvoyés au prochain chargement du coffre : {{error}}", "failedToSaveTeamSecret": "Échec de l'enregistrement du secret d'équipe : {{status}}", "serverLacksTeamSecretType": "Ce serveur Voltius est trop ancien pour stocker cet élément dans un coffre d'équipe. Demandez à son administrateur de mettre le serveur à jour, puis enregistrez à nouveau.", + "teamSecretRefused": "Le coffre d'équipe n'a pas accepté cet élément, il n'a donc pas été enregistré : {{error}}", + "teamSecretUploadPending": "Pas encore envoyé au coffre d'équipe. L'élément est conservé jusqu'à la fermeture de Voltius et renvoyé au prochain chargement du coffre : {{error}}", "failedToLoadServerCapabilities": "Impossible de vérifier ce que le serveur prend en charge ({{status}})", "keyFetchFailed": "Échec de la récupération de la clé : {{error}}", "failedToUploadVaultKeys": "Échec de l'envoi des clés de coffre : {{status}}", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index c8b6fd19f..b5cba31eb 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Перемещено, но учётные данные пока не удалось отправить. Они остаются на этом устройстве и будут отправлены повторно при следующей загрузке хранилища: {{error}}", "failedToSaveTeamSecret": "Не удалось сохранить секрет команды: {{status}}", "serverLacksTeamSecretType": "Этот сервер Voltius слишком старый, чтобы хранить это в командном хранилище. Попросите администратора обновить сервер, затем сохраните снова.", + "teamSecretRefused": "Командное хранилище не приняло это значение, поэтому оно не сохранено: {{error}}", + "teamSecretUploadPending": "Пока не отправлено в командное хранилище. Значение хранится до закрытия Voltius и будет отправлено повторно при следующей загрузке хранилища: {{error}}", "failedToLoadServerCapabilities": "Не удалось проверить, что поддерживает сервер ({{status}})", "keyFetchFailed": "Не удалось получить ключ: {{error}}", "failedToUploadVaultKeys": "Не удалось загрузить ключи хранилища: {{status}}", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 6c48f6be4..fc855d3c9 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Taşındı, ancak kimlik bilgileri henüz yüklenemedi. Bu cihazda kalıyor ve kasa bir sonraki yüklendiğinde yeniden denenecek: {{error}}", "failedToSaveTeamSecret": "Ekibin gizli bilgisi kaydedilemedi: {{status}}", "serverLacksTeamSecretType": "Bu Voltius sunucusu, bunu bir ekip kasasında saklamak için çok eski. Yöneticisinden sunucuyu güncellemesini isteyin, ardından yeniden kaydedin.", + "teamSecretRefused": "Ekip kasası bunu kabul etmedi, bu yüzden kaydedilmedi: {{error}}", + "teamSecretUploadPending": "Henüz ekip kasasına yüklenmedi. Voltius kapanana kadar saklanıyor ve kasa bir sonraki yüklendiğinde yeniden denenecek: {{error}}", "failedToLoadServerCapabilities": "Sunucunun neleri desteklediği denetlenemedi ({{status}})", "keyFetchFailed": "Anahtar alınamadı: {{error}}", "failedToUploadVaultKeys": "Kasa anahtarları yüklenemedi: {{status}}", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index c4b4831cb..e29131e66 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "已移动,但其凭据暂时无法上传。它们会保留在此设备上,并在下次加载保险库时重试:{{error}}", "failedToSaveTeamSecret": "保存团队密钥失败:{{status}}", "serverLacksTeamSecretType": "此 Voltius 服务器版本过旧,无法在团队保险库中存储此内容。请让管理员更新服务器,然后重新保存。", + "teamSecretRefused": "团队保险库未接受此内容,因此未保存:{{error}}", + "teamSecretUploadPending": "尚未上传到团队保险库。它会保留到 Voltius 关闭为止,并在下次加载保险库时重试:{{error}}", "failedToLoadServerCapabilities": "无法检查服务器支持的功能({{status}})", "keyFetchFailed": "获取密钥失败:{{error}}", "failedToUploadVaultKeys": "上传保险库密钥失败:{{status}}", diff --git a/src/plugins/domains/objects.ts b/src/plugins/domains/objects.ts index ef57b6157..1155cab22 100644 --- a/src/plugins/domains/objects.ts +++ b/src/plugins/domains/objects.ts @@ -33,7 +33,7 @@ import { moveFolderTreeToVault } from "@/utils/folderMove"; import { snippetToForm } from "@/utils/snippetForm"; import { ruleToForm } from "@/utils/portForwardingForm"; import { getSecret, storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveKeyToVault, moveIdentityToVault } from "@/services/vaultObjectSecrets"; import { duplicateConnection, moveConnectionToVault } from "@/services/connectionDuplicate"; import { copyingRulesOf } from "@/services/ruleSetIntent"; @@ -203,7 +203,7 @@ function resolveTab( const copySecret = async (from: string, to: string): Promise => { const value = await getSecret(from).catch(() => null); - if (value) await storeSecret(to, value).catch(keepCachedOnUploadFailure("copySecret")); + if (value) await storeSecret(to, value).catch(reportUploadFailure("copySecret")); }; interface DuplicateOpts { diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index ad90ee5b7..00416ed0b 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -51,7 +51,7 @@ import { addKeyToHost } from "@/services/keyExport"; import { isValidSshPublicKey } from "@/services/sshPublicKey"; import type { Connection, TerminalSession } from "@/types"; import { storePluginSecret, getPluginSecret, deletePluginSecret, storeSecret, deleteLocalSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { appFetch } from "@/services/http"; import { sseFetch } from "@/services/sseFetch"; import { registerLxcExecSession } from "@/services/proxmox"; @@ -1019,8 +1019,8 @@ function createPluginAPI(manifest: PluginManifest): PluginAPI { throw new Error("publicKey is not a valid SSH public key"); } const key = await keyService.saveKey({ name: data.name, key_type: data.key_type, tags: data.tags ?? [] }); - await storeSecret(`key:${key.id}:private`, privateKey).catch(keepCachedOnUploadFailure("plugin: keys.create")); - if (publicKey) await storeSecret(`key:${key.id}:public`, publicKey).catch(keepCachedOnUploadFailure("plugin: keys.create")); + await storeSecret(`key:${key.id}:private`, privateKey).catch(reportUploadFailure("plugin: keys.create")); + if (publicKey) await storeSecret(`key:${key.id}:public`, publicKey).catch(reportUploadFailure("plugin: keys.create")); return key as PluginKey; }, async delete(keyId) { diff --git a/src/services/clipboard/connections.ts b/src/services/clipboard/connections.ts index 64bc6685f..9575a2205 100644 --- a/src/services/clipboard/connections.ts +++ b/src/services/clipboard/connections.ts @@ -2,7 +2,7 @@ import type { Connection, ConnectionFormData, Identity, IdentityFormData, SshKey import type { VaultClipboardKind } from "@/stores/vaultClipboardStore"; import type { CascadeEntry } from "@/services/vaultClipboard"; import { getSecret, storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveWithSecrets, moveKeyToVault, moveIdentityToVault } from "@/services/vaultObjectSecrets"; import { connectionToFormData } from "@/stores/connectionStore"; import { nameIsFree } from "@/utils/cloneName"; @@ -171,8 +171,8 @@ export function connectionsClipboardHalf( getSecret(`key:${key.id}:private`).catch(() => null), getSecret(`key:${key.id}:public`).catch(() => null), ]); - if (priv) await storeSecret(`key:${created.id}:private`, priv).catch(keepCachedOnUploadFailure("clipboard: paste key")); - if (pub) await storeSecret(`key:${created.id}:public`, pub).catch(keepCachedOnUploadFailure("clipboard: paste key")); + if (priv) await storeSecret(`key:${created.id}:private`, priv).catch(reportUploadFailure("clipboard: paste key")); + if (pub) await storeSecret(`key:${created.id}:public`, pub).catch(reportUploadFailure("clipboard: paste key")); cascadeRemap.keys.set(key.id, created.id); } @@ -193,7 +193,7 @@ export function connectionsClipboardHalf( tags: identity.tags, vault_id: destination, }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`).catch(() => null); - if (pwd) await storeSecret(`identity:${created.id}:password`, pwd).catch(keepCachedOnUploadFailure("clipboard: paste identity")); + if (pwd) await storeSecret(`identity:${created.id}:password`, pwd).catch(reportUploadFailure("clipboard: paste identity")); cascadeRemap.identities.set(identity.id, created.id); } }, diff --git a/src/services/connectionDuplicate.ts b/src/services/connectionDuplicate.ts index 9879a5959..35833e89c 100644 --- a/src/services/connectionDuplicate.ts +++ b/src/services/connectionDuplicate.ts @@ -1,7 +1,7 @@ import type { Connection, ConnectionFormData } from "@/types"; import { connectionToFormData } from "@/stores/connectionStore"; import { getSecret, storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveWithSecrets } from "@/services/vaultObjectSecrets"; import { secretKeysFor } from "@/services/teamVaultSecretKeys"; import { copyingRulesOf } from "@/services/ruleSetIntent"; @@ -31,7 +31,7 @@ export async function copyConnectionSecrets( const pending = getSecret(fromKey); const value = opts.swallowFetchErrors ? await pending.catch(() => null) : await pending; if (!value) continue; - await storeSecret(targets[i], value).catch(keepCachedOnUploadFailure("copyConnectionSecrets")); + await storeSecret(targets[i], value).catch(reportUploadFailure("copyConnectionSecrets")); } } diff --git a/src/services/editorMoveSecrets.test.ts b/src/services/editorMoveSecrets.test.ts index 3f43304d0..a31e7d085 100644 --- a/src/services/editorMoveSecrets.test.ts +++ b/src/services/editorMoveSecrets.test.ts @@ -56,7 +56,7 @@ vi.mock("@/stores/pendingTeamSecretUploadStore", () => ({ vi.mock("@/stores/teamVaultStateStore", () => ({ useTeamVaultStateStore: { getState: () => ({ credentialsUnavailableByTeamId: h.unavailable }) }, })); -vi.mock("@/stores/notificationStore", () => ({ useNotificationStore: { getState: () => ({ addToast: h.addToast }) } })); +vi.mock("@/stores/notificationStore", () => ({ useNotificationStore: { getState: () => ({ addToast: h.addToast, toasts: [] }) } })); vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); vi.mock("@/lib/logger", () => ({ logFailure: () => () => {} })); diff --git a/src/services/import-export/handlers/connections.ts b/src/services/import-export/handlers/connections.ts index 36b487f1a..7257607e5 100644 --- a/src/services/import-export/handlers/connections.ts +++ b/src/services/import-export/handlers/connections.ts @@ -4,7 +4,7 @@ import type { DataTypeHandler } from "../handler"; import type { ConnectionExport, JumpHostExport, ExportBundle } from "../formats"; import type { ExportCtx, ImportCtx, ReloadFns } from "../context"; import { dupesOf, selectionMethods, skipItem } from "../context"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { omitSecrets, fetchConnectionSecrets, storeConnectionSecrets, resolveConnectionKeyEid, resolveConnectionKeyId } from "../secretsLogic"; export const connectionsHandler: DataTypeHandler = { @@ -104,7 +104,7 @@ export const connectionsHandler: DataTypeHandler = { }); if (conn._eid) ctx.connectionEidMap.set(conn._eid, saved.id); await storeConnectionSecrets(conn, saved.id, (key, value) => - storeSecret(key, value).catch(keepCachedOnUploadFailure("import")), + storeSecret(key, value).catch(reportUploadFailure("import")), ); imported++; } catch { errors++; } diff --git a/src/services/import-export/handlers/identities.ts b/src/services/import-export/handlers/identities.ts index 79b09cb3d..3475c4ff3 100644 --- a/src/services/import-export/handlers/identities.ts +++ b/src/services/import-export/handlers/identities.ts @@ -1,6 +1,6 @@ import type { Identity } from "@/types"; import { storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import type { DataTypeHandler } from "../handler"; import type { ExportBundle, IdentityExport } from "../formats"; import type { ExportCtx, ImportCtx, ReloadFns } from "../context"; @@ -57,7 +57,7 @@ export const identitiesHandler: DataTypeHandler = { }); if (identity._eid) ctx.identityEidMap.set(identity._eid, saved.id); await storeIdentitySecrets(identity, saved.id, (key, value) => - storeSecret(key, value).catch(keepCachedOnUploadFailure("import")), + storeSecret(key, value).catch(reportUploadFailure("import")), ); imported++; } catch { errors++; } diff --git a/src/services/import-export/handlers/keys.ts b/src/services/import-export/handlers/keys.ts index d75c4d3e1..34f4f3d32 100644 --- a/src/services/import-export/handlers/keys.ts +++ b/src/services/import-export/handlers/keys.ts @@ -4,7 +4,7 @@ import type { DataTypeHandler } from "../handler"; import type { ExportBundle, KeyExport, KeyRefExport } from "../formats"; import type { ExportCtx, ImportCtx, ReloadFns } from "../context"; import { dupesOf, resolveRefs, selectionMethods, skipItem } from "../context"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { fetchKeySecrets, storeKeySecrets } from "../secretsLogic"; export const keysHandler: DataTypeHandler = { @@ -47,7 +47,7 @@ export const keysHandler: DataTypeHandler = { vault_id: ctx.vault_id, }); await storeKeySecrets(key, saved.id, (k, value) => - storeSecret(k, value).catch(keepCachedOnUploadFailure("import")), + storeSecret(k, value).catch(reportUploadFailure("import")), ); if (key._eid) ctx.keyEidMap.set(key._eid, saved.id); imported++; diff --git a/src/services/keychainForm.ts b/src/services/keychainForm.ts index 303e3aa7d..d80b02826 100644 --- a/src/services/keychainForm.ts +++ b/src/services/keychainForm.ts @@ -2,7 +2,7 @@ import { useKeyStore } from "@/stores/keyStore"; import { useIdentityStore } from "@/stores/identityStore"; import { connectionToFormData } from "@/stores/connectionStore"; import { storeSecret, getSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveWithSecrets, storeNewSecrets, type SecretEdit } from "@/services/vaultObjectSecrets"; import type { AuthType, Connection, ConnectionFormData, Identity, IdentityFormData, SshKey, SshKeyFormData } from "@/types"; @@ -80,7 +80,7 @@ export async function unlinkIdentityFromHost( identity_id: undefined, key_id: undefined, }); - const keepCached = keepCachedOnUploadFailure("IdentityForm unlink"); + const keepCached = reportUploadFailure("IdentityForm unlink"); if (password) await storeSecret(`password:${conn.id}`, password).catch(keepCached); if (privateKey) await storeSecret(`key:${conn.id}`, privateKey).catch(keepCached); } diff --git a/src/services/publicKeyStore.ts b/src/services/publicKeyStore.ts index b3bd458ed..2d00764bb 100644 --- a/src/services/publicKeyStore.ts +++ b/src/services/publicKeyStore.ts @@ -1,6 +1,6 @@ import { invoke } from "@/lib/invoke"; import { getSecret, storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { isValidSshPublicKey } from "@/services/sshPublicKey"; import type { SshKey } from "@/types"; @@ -50,7 +50,7 @@ export async function ensurePublicKey(sshKey: SshKey): Promise { if ("error" in result) return null; if (!isValidSshPublicKey(result.publicKey)) return null; - await storeSecret(localKey, result.publicKey).catch(keepCachedOnUploadFailure("ensurePublicKey")); + await storeSecret(localKey, result.publicKey).catch(reportUploadFailure("ensurePublicKey", { toast: false })); return result.publicKey; } diff --git a/src/services/secretFailureToast.ts b/src/services/secretFailureToast.ts new file mode 100644 index 000000000..740f08baa --- /dev/null +++ b/src/services/secretFailureToast.ts @@ -0,0 +1,5 @@ +import { useNotificationStore } from "@/stores/notificationStore"; + +export function toastSecretFailure(message: string, severity: "warning" | "error" = "error"): void { + useNotificationStore.getState().addToast({ source: { kind: "plugin", id: "system", name: "Voltius" }, type: "toast", message, severity, duration: 10000 }); +} diff --git a/src/services/secretRouting.test.ts b/src/services/secretRouting.test.ts index 14bba46ce..65cd475b9 100644 --- a/src/services/secretRouting.test.ts +++ b/src/services/secretRouting.test.ts @@ -26,8 +26,10 @@ vi.mock("@/services/clientHeaders", () => ({ clientVersion: async () => "9.9.9" vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); import { - readSecretAt, writeSecretAt, removeSecretAt, keepCachedOnUploadFailure, TeamSecretUploadError, TeamSecretUnsupportedError, + readSecretAt, writeSecretAt, removeSecretAt, retryUnsentTeamSecrets, reportUploadFailure, + TeamSecretUploadError, TeamSecretUnsupportedError, } from "./secretRouting"; +import { useNotificationStore } from "@/stores/notificationStore"; import { teamSecretCache } from "./teamSecretCache"; import { resetServerCapabilities, serverLacksSecretType } from "./serverCapabilities"; import { usePendingTeamSecretUploadStore } from "@/stores/pendingTeamSecretUploadStore"; @@ -37,6 +39,7 @@ beforeEach(() => { teamSecretCache.clearAll(); usePendingTeamSecretUploadStore.getState().clearAll(); resetServerCapabilities(); + useNotificationStore.setState({ toasts: [] }); h.fetchServerMeta.mockResolvedValue({}); }); @@ -61,12 +64,91 @@ test("a team write caches, uploads, and never touches the local store", async () expect(h.storeLocalSecret).not.toHaveBeenCalled(); }); -test("a failed upload throws TeamSecretUploadError and keeps the value usable this session", async () => { - h.saveTeamVaultSecret.mockRejectedValue(Object.assign(new Error("403"), { status: 403 })); +const failing = (status?: number) => Object.assign(new Error(`upload failed ${status ?? "offline"}`), status ? { status } : {}); + +test.each([undefined, 500, 503, 401, 408, 429])("an upload the server never answered (%s) stays usable and unsent", async (status) => { + teamSecretCache.set("t1", "password:c1", "served"); + h.saveTeamVaultSecret.mockRejectedValue(failing(status)); + const err = await writeSecretAt("t1", "password:c1", "pw").catch((e) => e); + expect(err).toBeInstanceOf(TeamSecretUploadError); expect(err.localKey).toBe("password:c1"); + expect(err.pending).toBe(true); + expect(err.message).toBe("common.error.teamSecretUploadPending"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("pw"); + expect([...teamSecretCache.unsent("t1")]).toEqual([["password:c1", "pw"]]); + expect(h.storeLocalSecret).not.toHaveBeenCalled(); + expect(h.warned.mock.calls[0].join(" ")).toContain("password:c1"); +}); + +test.each([400, 402, 403, 404, 413, 426])("an upload the server refused (%s) shows what the server holds again", async (status) => { + teamSecretCache.set("t1", "password:c1", "served"); + h.saveTeamVaultSecret.mockRejectedValue(failing(status)); + + const err = await writeSecretAt("t1", "password:c1", "pw").catch((e) => e); + + expect(err).toBeInstanceOf(TeamSecretUploadError); + expect(err.pending).toBe(false); + expect(err.message).toBe("common.error.teamSecretRefused"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("served"); + expect(teamSecretCache.unsent("t1").size).toBe(0); +}); + +test("a refused value the server never held is gone", async () => { + h.saveTeamVaultSecret.mockRejectedValue(failing(403)); + await expect(writeSecretAt("t1", "password:c1", "pw")).rejects.toBeInstanceOf(TeamSecretUploadError); + expect(teamSecretCache.get("t1", "password:c1")).toBeUndefined(); +}); + +test("a reload of the team keeps an unsent value, and the retry uploads it", async () => { + h.saveTeamVaultSecret.mockRejectedValueOnce(failing()); + await writeSecretAt("t1", "password:c1", "pw").catch(() => {}); + teamSecretCache.replaceTeam("t1", new Map([["password:c1", "served"]])); expect(teamSecretCache.get("t1", "password:c1")).toBe("pw"); + + h.saveTeamVaultSecret.mockResolvedValue(undefined); + await retryUnsentTeamSecrets("t1"); + + expect(h.saveTeamVaultSecret).toHaveBeenLastCalledWith("t1", "password:c1", "pw"); + expect(teamSecretCache.unsent("t1").size).toBe(0); + expect(teamSecretCache.entries("t1").get("password:c1")).toBe("pw"); +}); + +test("the retry drops a value the server now refuses and stops once the server is out of reach", async () => { + h.saveTeamVaultSecret.mockRejectedValue(failing()); + for (const key of ["password:c1", "password:c2", "password:c3"]) await writeSecretAt("t1", key, "pw").catch(() => {}); + h.saveTeamVaultSecret.mockReset(); + h.saveTeamVaultSecret.mockRejectedValueOnce(failing(404)).mockRejectedValueOnce(failing(503)); + + await retryUnsentTeamSecrets("t1"); + + expect(h.saveTeamVaultSecret).toHaveBeenCalledTimes(2); + expect([...teamSecretCache.unsent("t1").keys()]).toEqual(["password:c2", "password:c3"]); +}); + +test("the retry leaves a key queued by a move to the sweep that owns its local copy", async () => { + usePendingTeamSecretUploadStore.getState().enqueue("t1", ["password:c1"]); + h.saveTeamVaultSecret.mockRejectedValue(failing()); + h.storeLocalSecret.mockResolvedValue(undefined); + await writeSecretAt("t1", "password:c1", "pw").catch(() => {}); + h.saveTeamVaultSecret.mockClear(); + + await retryUnsentTeamSecrets("t1"); + + expect(h.saveTeamVaultSecret).not.toHaveBeenCalled(); +}); + +test("a value cleared while unsent is never uploaded later, even when the delete fails", async () => { + teamSecretCache.set("t1", "password:c1", "served"); + h.saveTeamVaultSecret.mockRejectedValue(failing()); + await writeSecretAt("t1", "password:c1", "pw").catch(() => {}); + h.deleteTeamSecret.mockRejectedValue(new Error("offline")); + + await expect(removeSecretAt("t1", "password:c1")).rejects.toThrow("offline"); + + expect(teamSecretCache.unsent("t1").size).toBe(0); + expect(teamSecretCache.get("t1", "password:c1")).toBe("served"); }); test("a personal write goes to the local store only", async () => { @@ -96,13 +178,24 @@ test("a personal delete goes to the local store", async () => { expect(h.deleteLocalSecret).toHaveBeenCalledWith("password:c1"); }); -test("keepCachedOnUploadFailure swallows only upload failures", () => { - const handler = keepCachedOnUploadFailure("ctx"); - expect(() => handler(new TeamSecretUploadError("password:c1", new Error("x")))).not.toThrow(); - expect(h.logged).toHaveBeenCalled(); +test("reportUploadFailure swallows only upload failures, and says so once", () => { + const handler = reportUploadFailure("ctx"); + const failure = new TeamSecretUploadError("password:c1", new Error("x"), "not uploaded"); + expect(() => handler(failure)).not.toThrow(); + handler(new TeamSecretUploadError("password:c2", new Error("x"), "not uploaded")); + expect(h.logged).toHaveBeenCalledTimes(2); + expect(useNotificationStore.getState().toasts.map((toast) => [toast.message, toast.severity])).toEqual([["not uploaded", "error"]]); expect(() => handler(new Error("vault locked"))).toThrow("vault locked"); }); +test("reportUploadFailure warns about a pending upload and can stay quiet", () => { + const pending = Object.assign(new TeamSecretUploadError("password:c1", new Error("x"), "later"), { pending: true }); + reportUploadFailure("ctx", { toast: false })(pending); + expect(useNotificationStore.getState().toasts).toEqual([]); + reportUploadFailure("ctx")(pending); + expect(useNotificationStore.getState().toasts[0].severity).toBe("warning"); +}); + const pendingIn = (teamId: string) => usePendingTeamSecretUploadStore.getState().keysByTeamId[teamId] ?? []; test("a successful upload of a key queued for retry settles it, so the retry never re-uploads an older copy", async () => { @@ -128,15 +221,6 @@ test("a failed upload of a key queued for retry refreshes the local copy the ret expect(pendingIn("t1")).toEqual(["password:c1"]); }); -test("a failed upload of a key not queued for retry leaves the local store alone", async () => { - usePendingTeamSecretUploadStore.getState().enqueue("t2", ["password:c1"]); - h.saveTeamVaultSecret.mockRejectedValue(new Error("503")); - - await expect(writeSecretAt("t1", "password:c1", "newest")).rejects.toBeInstanceOf(TeamSecretUploadError); - - expect(h.storeLocalSecret).not.toHaveBeenCalled(); -}); - const refused400 = () => Object.assign(new Error("Failed to save team secret: 400"), { status: 400 }); test("a type the server does not advertise is never uploaded, and the cache keeps what the server holds", async () => { @@ -163,14 +247,15 @@ test("a 400 from a server too old to advertise its types drops the unsaved value expect(serverLacksSecretType("connection_knock_sequence")).toBe(true); }); -test("a 400 for a type every server stores stays an ordinary upload failure", async () => { +test("a 400 for a type every server stores is an ordinary refusal", async () => { h.saveTeamVaultSecret.mockRejectedValue(refused400()); const err = await writeSecretAt("t1", "password:c1", "pw").catch((e) => e); expect(err).toBeInstanceOf(TeamSecretUploadError); expect(err).not.toBeInstanceOf(TeamSecretUnsupportedError); - expect(teamSecretCache.get("t1", "password:c1")).toBe("pw"); + expect(serverLacksSecretType("connection_password")).toBe(false); + expect(teamSecretCache.get("t1", "password:c1")).toBeUndefined(); }); test("an unsupported type queued for retry stays readable here and keeps its local copy current", async () => { diff --git a/src/services/secretRouting.ts b/src/services/secretRouting.ts index 99a8c44cb..99f82a177 100644 --- a/src/services/secretRouting.ts +++ b/src/services/secretRouting.ts @@ -8,12 +8,19 @@ import { refreshServerCapabilities, serverLacksSecretType, noteRefusedSecretType import { clientVersion } from "@/services/clientHeaders"; import { log, logFailure } from "@/lib/logger"; import { usePendingTeamSecretUploadStore } from "@/stores/pendingTeamSecretUploadStore"; +import { toastSecretFailure } from "@/services/secretFailureToast"; +import { useNotificationStore } from "@/stores/notificationStore"; + +const reasonOf = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason)); export class TeamSecretUploadError extends Error { + /** Kept in memory and uploaded again at the vault's next load, until the app closes. */ + pending = false; + constructor( readonly localKey: string, readonly reason: unknown, - message = `team secret upload failed for ${localKey}: ${reason instanceof Error ? reason.message : String(reason)}`, + message = `team secret upload failed for ${localKey}: ${reasonOf(reason)}`, ) { super(message); this.name = "TeamSecretUploadError"; @@ -50,36 +57,72 @@ async function uploadSupported(teamId: string, localKey: string, value: string): throw new TeamSecretUnsupportedError(localKey, secretType, refusal); } +const uploads = () => usePendingTeamSecretUploadStore.getState(); +const isQueued = (teamId: string, localKey: string): boolean => uploads().keysByTeamId[teamId]?.includes(localKey) ?? false; + +// These pass with time; any other 4xx is the server's final answer for the value. +const RETRYABLE_STATUSES = new Set([401, 408, 429]); + +function isRefusal(e: unknown): boolean { + if (e instanceof TeamSecretUnsupportedError) return true; + const status = (e as { status?: number } | null)?.status; + return status !== undefined && status >= 400 && status < 500 && !RETRYABLE_STATUSES.has(status); +} + +async function failedUpload(teamId: string, localKey: string, value: string, e: unknown): Promise { + const queued = isQueued(teamId, localKey); + // A queued retry uploads the local copy, so it must hold the newest value. + if (queued) await storeLocalSecret(localKey, value).catch(logFailure(`refresh queued team secret ${localKey}`)); + // A queued key came from a move that withdrew its source, so here is the only place left to read it. + const refused = isRefusal(e) && !queued; + if (refused) teamSecretCache.release(teamId, localKey, value); + if (e instanceof TeamSecretUploadError) return e; + log.warn(`team secret upload ${refused ? "refused" : "pending"} key=${localKey}: ${reasonOf(e)}`); + const error = new TeamSecretUploadError( + localKey, + e, + i18n.t(refused ? "common.error.teamSecretRefused" : "common.error.teamSecretUploadPending", { error: reasonOf(e) }), + ); + error.pending = !refused; + return error; +} + export async function writeSecretAt(teamId: string | null, localKey: string, value: string): Promise { if (!teamId) return storeLocalSecret(localKey, value); - const held = teamSecretCache.get(teamId, localKey); - teamSecretCache.set(teamId, localKey, value); - const uploads = () => usePendingTeamSecretUploadStore.getState(); + teamSecretCache.hold(teamId, localKey, value); try { await uploadSupported(teamId, localKey, value); } catch (e) { - // A queued retry uploads the local copy, so it must hold the newest value. - if (uploads().keysByTeamId[teamId]?.includes(localKey)) { - await storeLocalSecret(localKey, value).catch(logFailure(`refresh queued team secret ${localKey}`)); - } else if (e instanceof TeamSecretUnsupportedError) { - if (held === undefined) teamSecretCache.delete(teamId, localKey); - else teamSecretCache.set(teamId, localKey, held); - } - throw e instanceof TeamSecretUploadError ? e : new TeamSecretUploadError(localKey, e); + throw await failedUpload(teamId, localKey, value, e); } + teamSecretCache.set(teamId, localKey, value); uploads().resolve(teamId, [localKey]); } +/** Uploads what earlier writes could not, stopping at the first value the server still cannot take. */ +export async function retryUnsentTeamSecrets(teamId: string): Promise { + for (const [localKey, value] of teamSecretCache.unsent(teamId)) { + if (isQueued(teamId, localKey)) continue; + const failure = await writeSecretAt(teamId, localKey, value).then(() => null, (e: unknown) => e); + if (failure instanceof TeamSecretUploadError && failure.pending) return; + } +} + export async function removeSecretAt(teamId: string | null, localKey: string): Promise { if (!teamId) return deleteLocalSecret(localKey); + const unsent = teamSecretCache.unsent(teamId).get(localKey); + if (unsent !== undefined) teamSecretCache.release(teamId, localKey, unsent); const parts = teamSecretFromLocalKey(localKey); if (parts) await deleteTeamSecret(teamId, parts.secretId); teamSecretCache.delete(teamId, localKey); } -export function keepCachedOnUploadFailure(context: string): (e: unknown) => void { +export function reportUploadFailure(context: string, { toast = true } = {}): (e: unknown) => void { return (e) => { if (!(e instanceof TeamSecretUploadError)) throw e; logFailure(context)(e); + // A bulk import or paste fails the same way for every secret it carries. + if (!toast || useNotificationStore.getState().toasts.some((shown) => shown.message === e.message)) return; + toastSecretFailure(e.message, e.pending ? "warning" : "error"); }; } diff --git a/src/services/teamSecretCache.test.ts b/src/services/teamSecretCache.test.ts index 8adba3496..d5d52fdd0 100644 --- a/src/services/teamSecretCache.test.ts +++ b/src/services/teamSecretCache.test.ts @@ -58,3 +58,46 @@ test("a team counts as hydrated from replaceTeam until it is cleared", () => { teamSecretCache.clearAll(); expect(teamSecretCache.isHydrated("t2")).toBe(false); }); + +test("an unsent value shows over the server's and survives a reload of the team", () => { + teamSecretCache.set("t1", "password:c1", "served"); + teamSecretCache.hold("t1", "password:c1", "typed"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("typed"); + expect(teamSecretCache.entries("t1").get("password:c1")).toBe("served"); + + teamSecretCache.replaceTeam("t1", new Map([["password:c1", "served again"]])); + + expect(teamSecretCache.get("t1", "password:c1")).toBe("typed"); + expect([...teamSecretCache.unsent("t1")]).toEqual([["password:c1", "typed"]]); +}); + +test("releasing an unsent value shows what the server holds again", () => { + teamSecretCache.set("t1", "password:c1", "served"); + teamSecretCache.hold("t1", "password:c1", "typed"); + teamSecretCache.release("t1", "password:c1", "typed"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("served"); + expect(teamSecretCache.unsent("t1").size).toBe(0); +}); + +test("settling an older write leaves a newer unsent value showing", () => { + teamSecretCache.hold("t1", "password:c1", "newer"); + teamSecretCache.release("t1", "password:c1", "older"); + teamSecretCache.set("t1", "password:c1", "older"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("newer"); + + teamSecretCache.set("t1", "password:c1", "newer"); + expect(teamSecretCache.unsent("t1").size).toBe(0); + expect(teamSecretCache.get("t1", "password:c1")).toBe("newer"); +}); + +test("delete, clearTeam and clearAll drop unsent values too", () => { + teamSecretCache.hold("t1", "a", "1"); + teamSecretCache.delete("t1", "a"); + expect(teamSecretCache.get("t1", "a")).toBeUndefined(); + teamSecretCache.hold("t1", "b", "2"); + teamSecretCache.clearTeam("t1"); + expect(teamSecretCache.get("t1", "b")).toBeUndefined(); + teamSecretCache.hold("t2", "c", "3"); + teamSecretCache.clearAll(); + expect(teamSecretCache.get("t2", "c")).toBeUndefined(); +}); diff --git a/src/services/teamSecretCache.ts b/src/services/teamSecretCache.ts index 70de4b337..0390b3b2e 100644 --- a/src/services/teamSecretCache.ts +++ b/src/services/teamSecretCache.ts @@ -1,6 +1,8 @@ import { create } from "zustand"; +// What the server holds, and over it the values whose upload has not landed yet. const byTeam = new Map>(); +const unsent = new Map>(); export const useTeamSecretsHydrated = create<{ byTeam: Record }>(() => ({ byTeam: {} })); @@ -14,29 +16,49 @@ function markTeam(teamId: string, change: "hydrated" | "changed" | "cleared"): v }); } +const layerOf = (layers: Map>, teamId: string): Map => { + const layer = layers.get(teamId) ?? new Map(); + layers.set(teamId, layer); + return layer; +}; + export const teamSecretCache = { - get: (teamId: string, key: string): string | undefined => byTeam.get(teamId)?.get(key), + get: (teamId: string, key: string): string | undefined => unsent.get(teamId)?.get(key) ?? byTeam.get(teamId)?.get(key), + /** Records a value the server holds; an unsent value for the key survives unless it is this one. */ set(teamId: string, key: string, value: string): void { - const entries = byTeam.get(teamId) ?? new Map(); - entries.set(key, value); - byTeam.set(teamId, entries); + layerOf(byTeam, teamId).set(key, value); + if (unsent.get(teamId)?.get(key) === value) unsent.get(teamId)?.delete(key); + markTeam(teamId, "changed"); + }, + hold(teamId: string, key: string, value: string): void { + layerOf(unsent, teamId).set(key, value); + markTeam(teamId, "changed"); + }, + /** Stops showing `value` as unsent, unless a newer write replaced it meanwhile. */ + release(teamId: string, key: string, value: string): void { + if (unsent.get(teamId)?.get(key) !== value) return; + unsent.get(teamId)?.delete(key); markTeam(teamId, "changed"); }, delete: (teamId: string, key: string): void => { byTeam.get(teamId)?.delete(key); + unsent.get(teamId)?.delete(key); markTeam(teamId, "changed"); }, entries: (teamId: string): Map => new Map(byTeam.get(teamId) ?? []), + unsent: (teamId: string): Map => new Map(unsent.get(teamId) ?? []), replaceTeam: (teamId: string, entries: Map): void => { byTeam.set(teamId, new Map(entries)); markTeam(teamId, "hydrated"); }, clearTeam: (teamId: string): void => { byTeam.delete(teamId); + unsent.delete(teamId); markTeam(teamId, "cleared"); }, clearAll: (): void => { byTeam.clear(); + unsent.clear(); useTeamSecretsHydrated.setState({ byTeam: {} }); }, isHydrated: (teamId: string): boolean => teamId in useTeamSecretsHydrated.getState().byTeam, diff --git a/src/services/teamVaultSync.credentials.test.ts b/src/services/teamVaultSync.credentials.test.ts index f33edbf06..fdbcb8fb0 100644 --- a/src/services/teamVaultSync.credentials.test.ts +++ b/src/services/teamVaultSync.credentials.test.ts @@ -8,7 +8,9 @@ const h = vi.hoisted(() => ({ hydrateTeamVaultSecrets: vi.fn(), checkAndRotateTeamKey: vi.fn(), purge: vi.fn(), + retryUnsent: vi.fn(), })); +vi.mock("@/services/secretRouting", () => ({ retryUnsentTeamSecrets: h.retryUnsent })); vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); vi.mock("@/services/http", () => ({ appFetch: h.appFetch })); vi.mock("@/services/teamObjects", () => ({ listTeamObjects: h.listTeamObjects })); @@ -51,6 +53,7 @@ beforeEach(() => { h.hydrateTeamVaultSecrets.mockReset().mockResolvedValue(undefined); h.checkAndRotateTeamKey.mockReset().mockResolvedValue(undefined); h.purge.mockReset().mockImplementation(async (keys: string[]) => keys); + h.retryUnsent.mockReset().mockResolvedValue(undefined); useTeamVaultStateStore.getState().clearAll(); teamSecretCache.clearAll(); usePendingSecretWipeStore.getState().clearAll(); @@ -133,6 +136,11 @@ test("a background refresh never sweeps", async () => { expect(h.purge).not.toHaveBeenCalled(); }); +test.each([{ background: false }, { background: true }])("a load ($background) retries the secrets earlier saves could not upload", async (options) => { + await fetchTeamData("t1", options); + expect(h.retryUnsent).toHaveBeenCalledWith("t1"); +}); + test("a failed sweep is queued for the next login", async () => { h.purge.mockRejectedValue(new Error("locked")); await fetchTeamData("t1"); diff --git a/src/services/teamVaultSync.ts b/src/services/teamVaultSync.ts index 1dc755e93..2486e43a9 100644 --- a/src/services/teamVaultSync.ts +++ b/src/services/teamVaultSync.ts @@ -486,7 +486,7 @@ async function _fetchTeamData(teamId: string, options: TeamVaultRefreshOptions): : false; } stateStore.setCredentialsUnavailable(teamId, !credentialsOk); - if (!options.background) await sweepLocalTeamSecrets(teamId); + await settleLoadedTeamSecrets(teamId, options); stateStore.setStatus(teamId, "loaded"); return; } @@ -582,7 +582,7 @@ async function _fetchTeamData(teamId: string, options: TeamVaultRefreshOptions): usePortForwardingStore.getState().setTeamRules(teamId, slices.portForwardingRules as PortForwardingRule[]); teamSecretCache.replaceTeam(teamId, new Map(Object.entries(blobPayload.secrets ?? {}))); - if (!options.background) await sweepLocalTeamSecrets(teamId); + await settleLoadedTeamSecrets(teamId, options); stateStore.setStatus(teamId, "loaded"); } @@ -766,6 +766,12 @@ export async function sweepLocalTeamSecrets(teamId: string): Promise { await purgeTeamObjectSecrets(teamId, stillPending, ownedKeys); } +async function settleLoadedTeamSecrets(teamId: string, options: TeamVaultRefreshOptions): Promise { + if (!options.background) await sweepLocalTeamSecrets(teamId); + const { retryUnsentTeamSecrets } = await import("@/services/secretRouting"); + await retryUnsentTeamSecrets(teamId); +} + /** Retries failed local-store purges, keeping queued any key now owned locally or still waiting to upload. */ export async function drainPendingSecretWipes(): Promise { const { usePendingSecretWipeStore } = await import("@/stores/pendingSecretWipeStore"); diff --git a/src/services/vaultObjectSecrets.ts b/src/services/vaultObjectSecrets.ts index 4d6699127..5c6f10781 100644 --- a/src/services/vaultObjectSecrets.ts +++ b/src/services/vaultObjectSecrets.ts @@ -1,22 +1,15 @@ import i18n from "@/i18n"; import type { SshKey, SshKeyFormData, Identity, IdentityFormData } from "@/types"; -import { useNotificationStore } from "@/stores/notificationStore"; import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; import { usePendingTeamSecretUploadStore } from "@/stores/pendingTeamSecretUploadStore"; import { storeSecret, deleteSecret } from "@/services/vault"; -import { readSecretAt, writeSecretAt, removeSecretAt, teamIdOfVault, keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { readSecretAt, writeSecretAt, removeSecretAt, teamIdOfVault, reportUploadFailure } from "@/services/secretRouting"; +import { toastSecretFailure } from "@/services/secretFailureToast"; import { secretKeysFor, type SecretObjectKind } from "@/services/teamVaultSecretKeys"; import { logFailure } from "@/lib/logger"; -function toastSecretError(messageKey: string, error: unknown): void { - useNotificationStore.getState().addToast({ - source: { kind: "plugin", id: "system", name: "Voltius" }, - type: "toast", - message: i18n.t(messageKey, { error: error instanceof Error ? error.message : String(error) }), - severity: "error", - duration: 10000, - }); -} +const toastSecretError = (messageKey: string, error: unknown): void => + toastSecretFailure(i18n.t(messageKey, { error: error instanceof Error ? error.message : String(error) })); /** Reports a failed withdrawal instead of throwing: the object has already moved, but the material is still readable where it was. */ async function withdrawOrWarn(withdrawal: Promise): Promise { @@ -121,7 +114,7 @@ export async function moveWithSecrets( if (editsAtSource) { await applyEdits( edits, - (k, v) => writeSecretAt(from, k, v).catch(keepCachedOnUploadFailure(`secret edit before move ${k}`)), + (k, v) => writeSecretAt(from, k, v).catch(reportUploadFailure(`secret edit before move ${k}`)), (k) => removeSecretAt(from, k), ); } diff --git a/src/stores/recreateHistory.ts b/src/stores/recreateHistory.ts index dd18d44ad..991fd6325 100644 --- a/src/stores/recreateHistory.ts +++ b/src/stores/recreateHistory.ts @@ -70,9 +70,9 @@ export function pushTeamDeleteHistory(opts: Tea await saveTeamVaultObject(teamId, type, item); opts.putBack(item); if (secrets.length === 0) return; - const { writeSecretAt, keepCachedOnUploadFailure } = await import("@/services/secretRouting"); + const { writeSecretAt, reportUploadFailure } = await import("@/services/secretRouting"); for (const [key, value] of secrets) { - await writeSecretAt(teamId, key, value).catch(keepCachedOnUploadFailure(`restore team secret ${key}`)); + await writeSecretAt(teamId, key, value).catch(reportUploadFailure(`restore team secret ${key}`)); } }, redo: () => opts.remove(item.id), diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 8d59b8824..35aae5aae 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -15,7 +15,7 @@ import { setEphemeralCredentials, clearEphemeralCredentials } from "@/services/e import { storeSecret, getSecret } from "@/services/vault"; import { identityPickIssueOf, type IdentityPickIssue } from "@/services/credentialPlan"; import { backendErrorCode, describeError, type BackendErrorCode } from "@/services/backendErrors"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { useIdentityPickStore } from "@/stores/identityPickStore"; import { findLoadedIdentity, isOwnLoadedIdentity } from "@/services/loadedIdentities"; import { resolveTeamIdForVaultId } from "@/services/teamVaultSecrets"; @@ -492,15 +492,15 @@ async function persistConnectAuth(connection: Connection, override: ConnectRetry data.identity_id = undefined; data.key_id = undefined; data.auth_type = "key"; - await storeSecret(`key:${connection.id}`, override.privateKey.trim()).catch(keepCachedOnUploadFailure("persistConnectAuth")); + await storeSecret(`key:${connection.id}`, override.privateKey.trim()).catch(reportUploadFailure("persistConnectAuth")); if (override.passphrase) { - await storeSecret(`passphrase:${connection.id}`, override.passphrase).catch(keepCachedOnUploadFailure("persistConnectAuth")); + await storeSecret(`passphrase:${connection.id}`, override.passphrase).catch(reportUploadFailure("persistConnectAuth")); } } else if (override.password) { data.identity_id = undefined; data.key_id = undefined; data.auth_type = "password"; - await storeSecret(`password:${connection.id}`, override.password).catch(keepCachedOnUploadFailure("persistConnectAuth")); + await storeSecret(`password:${connection.id}`, override.password).catch(reportUploadFailure("persistConnectAuth")); } await useConnectionStore.getState().updateConnection(connection.id, data); @@ -1049,7 +1049,7 @@ export const useSessionStore = create((set, get) => ({ credentials = await resolveSessionCredentials(connection, sessionId); const target = save ? passphraseSecretKey(connection, credentials) : null; - if (target) await storeSecret(target, passphrase).catch(keepCachedOnUploadFailure("reconnectWithPassphrase")); + if (target) await storeSecret(target, passphrase).catch(reportUploadFailure("reconnectWithPassphrase")); const opts = await buildSshConnectOptions(connection, sessionId); await sshConnect({ diff --git a/src/stores/vaultObjectStores.characterisation.test.ts b/src/stores/vaultObjectStores.characterisation.test.ts index 154554e90..c3d5093ec 100644 --- a/src/stores/vaultObjectStores.characterisation.test.ts +++ b/src/stores/vaultObjectStores.characterisation.test.ts @@ -33,7 +33,7 @@ vi.mock("@/services/teamObjectPersistence", () => ({ })); vi.mock("@/services/secretRouting", () => ({ writeSecretAt: h.writeSecretAt, - keepCachedOnUploadFailure: () => () => {}, + reportUploadFailure: () => () => {}, })); vi.mock("@/services/auditMutations", () => ({ reportAuditMutation: h.reportAuditMutation })); vi.mock("@/services/sync", () => ({ scheduleSync: h.scheduleSync }));