v0.2.6-pre.009

This commit is contained in:
2026-08-21 12:35:01 +02:00
parent 7d03825ec6
commit 2b7afab663
17 changed files with 970 additions and 41 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 176 # version: 177
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
[workspace.package] [workspace.package]
version = "0.2.6-pre.8.fix.1" version = "0.2.6-pre.9"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

File diff suppressed because one or more lines are too long

View File

@@ -252,6 +252,40 @@
<div id="balanceStatus" class="alert alert-secondary mb-0" role="status">Unlock VIEW ou OWNER pour interroger getBalance.</div> <div id="balanceStatus" class="alert alert-secondary mb-0" role="status">Unlock VIEW ou OWNER pour interroger getBalance.</div>
</div> </div>
</div> </div>
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold">Administration metadata OWNER</div>
<div class="card-body">
<p class="text-body-secondary">Alias et notes sont protégés dans le wallet. Les contrôles restent désactivés tant quune session OWNER nest pas ouverte.</p>
<div class="row g-3">
<div class="col-lg-6">
<label class="form-label" for="ownerWalletAlias">Alias interne</label>
<div class="input-group">
<input id="ownerWalletAlias" class="form-control" type="text" autocomplete="off" data-owner-metadata-control disabled>
<button id="saveOwnerAlias" class="btn btn-outline-primary" type="button" data-owner-metadata-control disabled>Save</button>
<button id="clearOwnerAlias" class="btn btn-outline-secondary" type="button" data-owner-metadata-control disabled>Clear</button>
</div>
<div class="form-text">Limite V1 : 256 octets UTF-8. La validation autoritative reste dans ksp-wallet-lib.</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="ownerNewNote">Nouvelle note</label>
<div class="input-group">
<textarea id="ownerNewNote" class="form-control" rows="2" autocomplete="off" data-owner-metadata-control disabled></textarea>
<button id="addOwnerNote" class="btn btn-outline-primary" type="button" data-owner-metadata-control disabled>Add note</button>
</div>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-semibold">Notes autorisées</span>
<small class="text-body-secondary">64 notes maximum en V1</small>
</div>
<div id="ownerNotesList" class="vstack gap-2"><span class="text-body-secondary">Ouvrir OWNER pour administrer les notes.</span></div>
</div>
<div class="col-12">
<div id="ownerMetadataStatus" class="alert alert-secondary mb-0" role="status">Session OWNER requise.</div>
</div>
</div>
</div>
</div>
</section> </section>
<section data-view-panel="security" hidden> <section data-view-panel="security" hidden>
<div class="row g-3"> <div class="row g-3">
@@ -310,8 +344,23 @@
</section> </section>
</div> </div>
</main> </main>
<div class="modal fade" id="deleteWalletNoteModal" tabindex="-1" aria-labelledby="deleteWalletNoteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="deleteWalletNoteModalLabel">Supprimer la note protégée ?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">Cette mutation OWNER est définitive pour la note sélectionnée. Le contenu protégé nest pas recopié dans la confirmation.</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button id="confirmDeleteWalletNote" type="button" class="btn btn-danger">Delete note</button>
</div>
</div>
</div>
</div>
<footer class="app-footer bg-dark text-white-50 d-flex align-items-center px-4"> <footer class="app-footer bg-dark text-white-50 d-flex align-items-center px-4">
<span>KSP Wallet Desk · native transfer import pre.008</span> <span>KSP Wallet Desk · OWNER metadata pre.009</span>
</footer> </footer>
<script type="module" src="./ts/main.ts"></script> <script type="module" src="./ts/main.ts"></script>
</body> </body>

View File

@@ -1,13 +1,14 @@
// file: crates/ksp-app-wallet-desk/frontend/ts/main.ts // file: crates/ksp-app-wallet-desk/frontend/ts/main.ts
// version: 8 // version: 9
import "bootstrap"; import { Modal } from "bootstrap";
import DataTable from "datatables.net-bs5"; import DataTable from "datatables.net-bs5";
import "datatables.net-select-bs5"; import "datatables.net-select-bs5";
import ResizeObserver from "resize-observer-polyfill"; import ResizeObserver from "resize-observer-polyfill";
import "simplebar"; import "simplebar";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import type { WalletBalanceDto } from "./bindings/ksp_app_wallet_desk/wallet_balance/WalletBalanceDto.ts"; import type { WalletBalanceDto } from "./bindings/ksp_app_wallet_desk/wallet_balance/WalletBalanceDto.ts";
import type { CommandErrorDto } from "./bindings/ksp_app_wallet_desk/dto_common/CommandErrorDto.ts";
import type { RuntimeStatusDto } from "./bindings/ksp_app_wallet_desk/dto_common/RuntimeStatusDto.ts"; import type { RuntimeStatusDto } from "./bindings/ksp_app_wallet_desk/dto_common/RuntimeStatusDto.ts";
import type { WalletImportRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_import/WalletImportRequestDto.ts"; import type { WalletImportRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_import/WalletImportRequestDto.ts";
import type { WalletImportSourceRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_import/WalletImportSourceRequestDto.ts"; import type { WalletImportSourceRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_import/WalletImportSourceRequestDto.ts";
@@ -16,6 +17,10 @@ import type { WalletTransferInspectionDto } from "./bindings/ksp_app_wallet_desk
import type { LockedWalletDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/LockedWalletDto.ts"; import type { LockedWalletDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/LockedWalletDto.ts";
import type { WalletInventoryEntryDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/WalletInventoryEntryDto.ts"; import type { WalletInventoryEntryDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/WalletInventoryEntryDto.ts";
import type { WalletSelectionRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/WalletSelectionRequestDto.ts"; import type { WalletSelectionRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/WalletSelectionRequestDto.ts";
import type { WalletAliasUpdateRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_metadata/WalletAliasUpdateRequestDto.ts";
import type { WalletNoteAddRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteAddRequestDto.ts";
import type { WalletNoteDeleteRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteDeleteRequestDto.ts";
import type { WalletNoteUpdateRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteUpdateRequestDto.ts";
import type { WalletAuthorizedDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletAuthorizedDto.ts"; import type { WalletAuthorizedDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletAuthorizedDto.ts";
import type { WalletCreateRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletCreateRequestDto.ts"; import type { WalletCreateRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletCreateRequestDto.ts";
import type { WalletSessionDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletSessionDto.ts"; import type { WalletSessionDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletSessionDto.ts";
@@ -43,7 +48,10 @@ const viewTitles: Record<ViewId, string> = {
let activeWalletId: string | null = null; let activeWalletId: string | null = null;
let activeSessionState: ActiveSessionState = "no_selection"; let activeSessionState: ActiveSessionState = "no_selection";
let activeViewEnabled = false; let activeViewEnabled = false;
let authorizedWalletProjection: WalletAuthorizedDto | null = null;
let configuredSecretCandidateCount = 0; let configuredSecretCandidateCount = 0;
let deleteNoteModal: Modal | null = null;
let pendingDeleteNoteId: string | null = null;
let stagedImportSource: WalletTransferInspectionDto | null = null; let stagedImportSource: WalletTransferInspectionDto | null = null;
function isViewId(value: string): value is ViewId { function isViewId(value: string): value is ViewId {
@@ -212,6 +220,14 @@ function updateSessionActions(): void {
refreshBalance.disabled = activeSessionState !== "view_open" && activeSessionState !== "owner_open"; refreshBalance.disabled = activeSessionState !== "view_open" && activeSessionState !== "owner_open";
} }
updateUnlockActions(); updateUnlockActions();
updateOwnerMetadataActions();
}
function updateOwnerMetadataActions(): void {
const ownerOpen = activeSessionState === "owner_open";
document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLButtonElement>("[data-owner-metadata-control]").forEach(control => {
control.disabled = !ownerOpen;
});
} }
function updateUnlockActions(): void { function updateUnlockActions(): void {
@@ -314,6 +330,8 @@ function clearBalanceProjection(): void {
} }
function clearAuthorizedProjection(): void { function clearAuthorizedProjection(): void {
authorizedWalletProjection = null;
clearOwnerMetadataProjection();
setText("#currentWalletPubkey", "—"); setText("#currentWalletPubkey", "—");
setText("#currentWalletAlias", "—"); setText("#currentWalletAlias", "—");
setText("#currentWalletNotes", "—"); setText("#currentWalletNotes", "—");
@@ -371,6 +389,7 @@ function renderLockedWallet(wallet: LockedWalletDto): void {
function renderAuthorizedWallet(wallet: WalletAuthorizedDto): void { function renderAuthorizedWallet(wallet: WalletAuthorizedDto): void {
activeWalletId = wallet.walletId; activeWalletId = wallet.walletId;
authorizedWalletProjection = wallet;
activeSessionState = wallet.capability === "view" ? "view_open" : "owner_open"; activeSessionState = wallet.capability === "view" ? "view_open" : "owner_open";
activeViewEnabled = wallet.viewEnabled; activeViewEnabled = wallet.viewEnabled;
configuredSecretCandidateCount = wallet.configuredSecretCandidateCount; configuredSecretCandidateCount = wallet.configuredSecretCandidateCount;
@@ -392,6 +411,7 @@ function renderAuthorizedWallet(wallet: WalletAuthorizedDto): void {
setText("#detailsWalletPubkey", wallet.pubkey); setText("#detailsWalletPubkey", wallet.pubkey);
setText("#detailsWalletAlias", wallet.alias ?? "—"); setText("#detailsWalletAlias", wallet.alias ?? "—");
setText("#detailsWalletNotes", notesText); setText("#detailsWalletNotes", notesText);
renderOwnerMetadataProjection(wallet);
clearBalanceProjection(); clearBalanceProjection();
setText("#balanceStatus", "Session autorisée. Refresh balance appelle getBalance avec la Pubkey détenue par Rust."); setText("#balanceStatus", "Session autorisée. Refresh balance appelle getBalance avec la Pubkey détenue par Rust.");
setText("#unlockStatus", `${capabilityLabel} ouvert. Lock pour purger le handle autorisé.`); setText("#unlockStatus", `${capabilityLabel} ouvert. Lock pour purger le handle autorisé.`);
@@ -405,6 +425,195 @@ function renderAuthorizedWallet(wallet: WalletAuthorizedDto): void {
}); });
} }
function clearOwnerMetadataProjection(): void {
pendingDeleteNoteId = null;
const alias = document.querySelector<HTMLInputElement>("#ownerWalletAlias");
const newNote = document.querySelector<HTMLTextAreaElement>("#ownerNewNote");
const list = document.querySelector<HTMLElement>("#ownerNotesList");
if (alias) {
alias.value = "";
}
if (newNote) {
newNote.value = "";
}
if (list) {
const empty = document.createElement("span");
empty.className = "text-body-secondary";
empty.textContent = "Ouvrir OWNER pour administrer les notes.";
list.replaceChildren(empty);
}
setText("#ownerMetadataStatus", "Session OWNER requise.");
}
function renderOwnerMetadataProjection(wallet: WalletAuthorizedDto): void {
const alias = document.querySelector<HTMLInputElement>("#ownerWalletAlias");
const list = document.querySelector<HTMLElement>("#ownerNotesList");
if (alias) {
alias.value = wallet.alias ?? "";
}
if (list) {
if (wallet.notes.length === 0) {
const empty = document.createElement("span");
empty.className = "text-body-secondary";
empty.textContent = wallet.capability === "owner" ? "Aucune note protégée." : "Les notes sont visibles en VIEW mais modifiables uniquement en OWNER.";
list.replaceChildren(empty);
} else {
const rows = wallet.notes.map(note => {
const row = document.createElement("div");
row.className = "border rounded p-2";
const textarea = document.createElement("textarea");
textarea.className = "form-control mb-2";
textarea.rows = 2;
textarea.value = note.text;
textarea.disabled = wallet.capability !== "owner";
textarea.autocomplete = "off";
textarea.dataset.ownerMetadataControl = "true";
const actions = document.createElement("div");
actions.className = "d-flex gap-2 justify-content-end";
const save = document.createElement("button");
save.className = "btn btn-outline-primary btn-sm";
save.type = "button";
save.textContent = "Save note";
save.disabled = wallet.capability !== "owner";
save.dataset.ownerMetadataControl = "true";
save.addEventListener("click", () => {
const request: WalletNoteUpdateRequestDto = { noteId: note.id, text: textarea.value };
void runOwnerMetadataMutation("update_wallet_note", { request }, "Mise à jour de la note…", "Note mise à jour.");
});
const remove = document.createElement("button");
remove.className = "btn btn-outline-danger btn-sm";
remove.type = "button";
remove.textContent = "Delete";
remove.disabled = wallet.capability !== "owner";
remove.dataset.ownerMetadataControl = "true";
remove.addEventListener("click", () => {
pendingDeleteNoteId = note.id;
deleteNoteModal?.show();
frontendDebug("main", "OWNER note delete confirmation opened", { walletId: wallet.walletId });
});
actions.append(save, remove);
row.append(textarea, actions);
return row;
});
list.replaceChildren(...rows);
}
}
setText("#ownerMetadataStatus", wallet.capability === "owner" ? "OWNER ouvert : metadata modifiable." : "VIEW ouvert : metadata en lecture seule.");
updateOwnerMetadataActions();
}
function isWalletStateConflict(caughtError: unknown): boolean {
if (typeof caughtError !== "object" || caughtError === null) {
return false;
}
const error = caughtError as Partial<CommandErrorDto>;
return error.domain === "wallet" && error.code === "state_conflict";
}
async function recoverOwnerMetadataStateConflict(walletId: string): Promise<void> {
clearAuthorizedProjection();
clearBalanceProjection();
const request: WalletSelectionRequestDto = { walletId };
try {
const wallet = await invokeKsp<LockedWalletDto>("main", "select_wallet", { request });
renderLockedWallet(wallet);
setText("#ownerMetadataStatus", "Le fichier wallet a changé hors de cette session. Le handle OWNER stale a été purgé ; unlock OWNER requis avant toute nouvelle mutation.");
setText("#unlockStatus", "Conflit détat détecté : wallet réinspecté et reverrouillé. Réautoriser OWNER explicitement.");
await loadWalletInventory("list_wallets", false);
activateView("security", "user");
frontendWarn("main", "Wallet state conflict forced OWNER reauthorization", { walletId });
} catch {
clearSelectedWallet();
setText("#ownerMetadataStatus", "Conflit détat détecté et réinspection impossible. Resélectionner le wallet depuis linventaire.");
frontendWarn("main", "Wallet state conflict recovery failed", { walletId });
}
}
async function runOwnerMetadataMutation(
command: "add_wallet_note" | "delete_wallet_note" | "update_wallet_alias" | "update_wallet_note",
args: Record<string, unknown>,
pendingMessage: string,
successMessage: string,
): Promise<boolean> {
if (activeSessionState !== "owner_open" || !activeWalletId || authorizedWalletProjection?.capability !== "owner") {
setText("#ownerMetadataStatus", "Session OWNER requise.");
return false;
}
const walletId = activeWalletId;
activeSessionState = "privileged_operation";
setText("#currentWalletState", "OWNER mutation…");
setText("#ownerMetadataStatus", pendingMessage);
updateSessionActions();
frontendDebug("main", "OWNER metadata mutation requested", { command, walletId });
try {
const wallet = await invokeKsp<WalletAuthorizedDto>("main", command, args);
if (activeWalletId !== walletId) {
frontendWarn("main", "Stale OWNER metadata response ignored", { command, walletId });
return false;
}
renderAuthorizedWallet(wallet);
setText("#ownerMetadataStatus", successMessage);
await loadWalletInventory("list_wallets", false);
return true;
} catch (caughtError) {
if (isWalletStateConflict(caughtError)) {
await recoverOwnerMetadataStateConflict(walletId);
return false;
}
if (activeWalletId === walletId) {
activeSessionState = "owner_open";
setText("#currentWalletState", "OWNER open");
setText("#ownerMetadataStatus", "Mutation refusée ; la session OWNER reste ouverte.");
updateSessionActions();
}
frontendWarn("main", "OWNER metadata mutation failed", { command, walletId });
return false;
}
}
function bindOwnerMetadataActions(): void {
const modalElement = document.querySelector<HTMLElement>("#deleteWalletNoteModal");
if (modalElement) {
deleteNoteModal = new Modal(modalElement);
}
document.querySelector<HTMLButtonElement>("#saveOwnerAlias")?.addEventListener("click", () => {
const alias = document.querySelector<HTMLInputElement>("#ownerWalletAlias");
if (!alias) {
return;
}
const request: WalletAliasUpdateRequestDto = { alias: alias.value.length === 0 ? null : alias.value };
void runOwnerMetadataMutation("update_wallet_alias", { request }, "Mise à jour de lalias…", "Alias mis à jour.");
});
document.querySelector<HTMLButtonElement>("#clearOwnerAlias")?.addEventListener("click", () => {
const request: WalletAliasUpdateRequestDto = { alias: null };
void runOwnerMetadataMutation("update_wallet_alias", { request }, "Suppression de lalias…", "Alias supprimé.");
});
document.querySelector<HTMLButtonElement>("#addOwnerNote")?.addEventListener("click", () => {
const text = document.querySelector<HTMLTextAreaElement>("#ownerNewNote");
if (!text || text.value.length === 0) {
setText("#ownerMetadataStatus", "Texte de note requis.");
return;
}
const request: WalletNoteAddRequestDto = { text: text.value };
void runOwnerMetadataMutation("add_wallet_note", { request }, "Ajout de la note…", "Note ajoutée.").then(succeeded => {
if (succeeded && activeSessionState === "owner_open") {
text.value = "";
}
});
});
document.querySelector<HTMLButtonElement>("#confirmDeleteWalletNote")?.addEventListener("click", () => {
const noteId = pendingDeleteNoteId;
pendingDeleteNoteId = null;
deleteNoteModal?.hide();
if (!noteId) {
return;
}
const request: WalletNoteDeleteRequestDto = { noteId };
void runOwnerMetadataMutation("delete_wallet_note", { request }, "Suppression de la note…", "Note supprimée.");
});
frontendTrace("main", "OWNER metadata action handlers installed");
}
async function selectWallet(walletId: string): Promise<void> { async function selectWallet(walletId: string): Promise<void> {
clearSelectedWallet(); clearSelectedWallet();
const request: WalletSelectionRequestDto = { walletId }; const request: WalletSelectionRequestDto = { walletId };
@@ -824,7 +1033,7 @@ function renderRuntimeStatus(status: RuntimeStatusDto): void {
setText("#runtimeEffectiveWalletsDirectory", status.effectiveWalletsDirectory); setText("#runtimeEffectiveWalletsDirectory", status.effectiveWalletsDirectory);
setText("#runtimeWalletDirectoryCreated", status.effectiveWalletsDirectoryCreatedOnStartup ? "oui" : "non, déjà présent"); setText("#runtimeWalletDirectoryCreated", status.effectiveWalletsDirectoryCreatedOnStartup ? "oui" : "non, déjà présent");
setText("#runtimeShellPhase", status.shellPhase); setText("#runtimeShellPhase", status.shellPhase);
setText("#shellStatus", "Config/Transport résolus ; import natif, VIEW/OWNER et getBalance prêts."); setText("#shellStatus", "Config/Transport résolus ; import, VIEW/OWNER, metadata OWNER et getBalance prêts.");
frontendTrace("main", "Wallet Desk runtime status rendered", { frontendTrace("main", "Wallet Desk runtime status rendered", {
compositeProfile: status.activeCompositeProfile, compositeProfile: status.activeCompositeProfile,
fallbackLoggingActive: status.fallbackLoggingActive, fallbackLoggingActive: status.fallbackLoggingActive,
@@ -875,6 +1084,7 @@ async function initializeMain(): Promise<void> {
bindImportWalletForm(); bindImportWalletForm();
bindUnlockActions(); bindUnlockActions();
bindBalanceActions(); bindBalanceActions();
bindOwnerMetadataActions();
bindShellActions(); bindShellActions();
clearSelectedWallet(); clearSelectedWallet();
activateView("dashboard", "startup"); activateView("dashboard", "startup");

View File

@@ -1,7 +1,7 @@
{ {
"name": "ksp-app-wallet-desk", "name": "ksp-app-wallet-desk",
"private": true, "private": true,
"version": "0.2.6-pre.8.fix.1", "version": "0.2.6-pre.9",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/app_state.rs // file: crates/ksp-app-wallet-desk/src/app_state.rs
// version: 10 // version: 11
//! Shared backend state owned by the Wallet Desk Tauri application. //! Shared backend state owned by the Wallet Desk Tauri application.
@@ -126,7 +126,7 @@ impl AppState {
effective_wallets_directory_created_on_startup: self.wallet_config_startup.effective_directory_created_on_startup(), effective_wallets_directory_created_on_startup: self.wallet_config_startup.effective_directory_created_on_startup(),
fallback_logging_active: runtime.fallback_active, fallback_logging_active: runtime.fallback_active,
root_wallets_directory_created_on_startup: self.wallet_config_startup.root_directory_created_on_startup(), root_wallets_directory_created_on_startup: self.wallet_config_startup.root_directory_created_on_startup(),
shell_phase: "pre.008-wallet-import".to_owned(), shell_phase: "pre.009-owner-metadata".to_owned(),
startup_diagnostic: runtime.startup_diagnostic.clone(), startup_diagnostic: runtime.startup_diagnostic.clone(),
transport_available_endpoint_count, transport_available_endpoint_count,
transport_clusters: self.transport_runtime.clusters(), transport_clusters: self.transport_runtime.clusters(),
@@ -217,7 +217,7 @@ impl AppState {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()), std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
}; };
*session = crate::WalletSession::Owner { wallet_id: filename.clone(), path: destination, wallet: std::boxed::Box::new(owner) }; *session = crate::WalletSession::Owner { wallet_id: filename.clone(), path: destination, view_enabled, wallet: std::boxed::Box::new(owner) };
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = filename.as_str(), view_enabled, "Wallet created and OWNER session opened"); ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = filename.as_str(), view_enabled, "Wallet created and OWNER session opened");
return std::result::Result::Ok(dto); return std::result::Result::Ok(dto);
} }
@@ -313,7 +313,7 @@ impl AppState {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()), std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
}; };
*session = crate::WalletSession::Owner { wallet_id: filename.clone(), path: destination, wallet: std::boxed::Box::new(owner) }; *session = crate::WalletSession::Owner { wallet_id: filename.clone(), path: destination, view_enabled, wallet: std::boxed::Box::new(owner) };
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_IMPORT, wallet_id = filename.as_str(), transfer_format = transfer_format.code(), view_enabled, "Wallet transfer imported and OWNER session opened"); ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_IMPORT, wallet_id = filename.as_str(), transfer_format = transfer_format.code(), view_enabled, "Wallet transfer imported and OWNER session opened");
return std::result::Result::Ok(dto); return std::result::Result::Ok(dto);
} }
@@ -483,6 +483,18 @@ impl AppState {
"Wallet unlock operation is already in progress", "Wallet unlock operation is already in progress",
)); ));
}, },
crate::WalletSession::OwnerOperation { wallet_id, path, pubkey, view_enabled } => {
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
};
*session = crate::WalletSession::OwnerOperation { wallet_id, path, pubkey, view_enabled };
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_SESSION_INVALID,
"Wallet OWNER metadata operation is already in progress",
));
},
crate::WalletSession::View { wallet_id, path, wallet, .. } => { crate::WalletSession::View { wallet_id, path, wallet, .. } => {
drop(wallet); drop(wallet);
(wallet_id, path) (wallet_id, path)
@@ -595,7 +607,7 @@ impl AppState {
}, },
}; };
let dto = crate::view_projection(wallet_id.as_str(), view_enabled, configured_secret_candidate_count, &view); let dto = crate::view_projection(wallet_id.as_str(), view_enabled, configured_secret_candidate_count, &view);
*session = crate::WalletSession::View { wallet_id: wallet_id.clone(), path, wallet: std::boxed::Box::new(view) }; *session = crate::WalletSession::View { wallet_id: wallet_id.clone(), path, view_enabled, wallet: std::boxed::Box::new(view) };
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), capability = "view", "Wallet VIEW session opened"); ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), capability = "view", "Wallet VIEW session opened");
return std::result::Result::Ok(dto); return std::result::Result::Ok(dto);
} }
@@ -633,7 +645,7 @@ impl AppState {
}, },
}; };
let dto = crate::owner_projection(wallet_id.as_str(), view_enabled, configured_secret_candidate_count, &owner); let dto = crate::owner_projection(wallet_id.as_str(), view_enabled, configured_secret_candidate_count, &owner);
*session = crate::WalletSession::Owner { wallet_id: wallet_id.clone(), path, wallet: std::boxed::Box::new(owner) }; *session = crate::WalletSession::Owner { wallet_id: wallet_id.clone(), path, view_enabled, wallet: std::boxed::Box::new(owner) };
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), capability = "owner", "Wallet OWNER session opened"); ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), capability = "owner", "Wallet OWNER session opened");
return std::result::Result::Ok(dto); return std::result::Result::Ok(dto);
} }
@@ -649,6 +661,178 @@ impl AppState {
}; };
} }
/// Updates or clears the protected alias through the current OWNER handle.
pub(crate) async fn update_wallet_alias(&self, request: crate::WalletAliasUpdateRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_metadata_operation("alias_update");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let alias = normalize_optional_text(request.alias);
let result = context.owner.update_alias(context.path.as_path(), alias).await;
return self.finish_owner_metadata_operation(context, result).await;
}
/// Appends one protected note through the current OWNER handle.
pub(crate) async fn add_wallet_note(&self, request: crate::WalletNoteAddRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_metadata_operation("note_add");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = context.owner.add_note(context.path.as_path(), request.text).await;
let result = match result {
std::result::Result::Ok(_) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
return self.finish_owner_metadata_operation(context, result).await;
}
/// Updates one protected note through the current OWNER handle.
pub(crate) async fn update_wallet_note(&self, request: crate::WalletNoteUpdateRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_metadata_operation("note_update");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = context.owner.update_note(context.path.as_path(), request.note_id.as_str(), request.text).await;
return self.finish_owner_metadata_operation(context, result).await;
}
/// Deletes one protected note through the current OWNER handle.
pub(crate) async fn delete_wallet_note(&self, request: crate::WalletNoteDeleteRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_metadata_operation("note_delete");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = context.owner.delete_note(context.path.as_path(), request.note_id.as_str()).await;
return self.finish_owner_metadata_operation(context, result).await;
}
fn begin_owner_metadata_operation(&self, operation: &'static str) -> ksp_core_lib::Result<OwnerMetadataContext> {
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
};
let previous = std::mem::replace(&mut *session, crate::WalletSession::no_selection());
let (wallet_id, path, view_enabled, owner) = match previous {
crate::WalletSession::Owner { wallet_id, path, view_enabled, wallet } => (wallet_id, path, view_enabled, wallet),
other => {
*session = other;
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED,
"Wallet metadata administration requires an authorized OWNER session",
));
},
};
let pubkey = owner.pubkey().to_owned();
let context = OwnerMetadataContext { operation, owner, path: path.clone(), pubkey, view_enabled, wallet_id: wallet_id.clone() };
*session = crate::WalletSession::OwnerOperation { wallet_id, path, pubkey, view_enabled };
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = context.wallet_id.as_str(), operation, "Wallet OWNER metadata operation started");
return std::result::Result::Ok(context);
}
async fn finish_owner_metadata_operation(
&self,
context: OwnerMetadataContext,
operation_result: ksp_core_lib::Result<()>,
) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
if let std::result::Result::Err(error) = operation_result {
if error.code() == ksp_wallet_lib::ERROR_CODE_STATE_CONFLICT {
self.recover_owner_metadata_state_conflict(context).await;
return std::result::Result::Err(error);
}
self.restore_owner_after_metadata_failure(context);
return std::result::Result::Err(error);
}
return self.install_owner_after_metadata_success(context);
}
fn install_owner_after_metadata_success(&self, context: OwnerMetadataContext) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let OwnerMetadataContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
let configured_secret_candidate_count = self.secret_candidate_count_or_zero(wallet_id.as_str());
let dto = crate::owner_projection(wallet_id.as_str(), view_enabled, configured_secret_candidate_count, &owner);
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
};
let previous = std::mem::replace(&mut *session, crate::WalletSession::no_selection());
match previous {
crate::WalletSession::OwnerOperation {
wallet_id: reserved_wallet_id,
path: reserved_path,
pubkey: reserved_pubkey,
view_enabled: reserved_view_enabled,
} if reserved_wallet_id == wallet_id && reserved_path == path && reserved_pubkey == pubkey && reserved_view_enabled == view_enabled => {
*session = crate::WalletSession::Owner { wallet_id: wallet_id.clone(), path, view_enabled, wallet: owner };
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER metadata operation completed");
return std::result::Result::Ok(dto);
},
other => {
*session = other;
drop(owner);
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_SESSION_INVALID,
"Wallet metadata completion no longer owns the selected OWNER session",
));
},
}
}
fn restore_owner_after_metadata_failure(&self, context: OwnerMetadataContext) {
let OwnerMetadataContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let previous = std::mem::replace(&mut *session, crate::WalletSession::no_selection());
match previous {
crate::WalletSession::OwnerOperation {
wallet_id: reserved_wallet_id,
path: reserved_path,
pubkey: reserved_pubkey,
view_enabled: reserved_view_enabled,
} if reserved_wallet_id == wallet_id && reserved_path == path && reserved_pubkey == pubkey && reserved_view_enabled == view_enabled => {
*session = crate::WalletSession::Owner { wallet_id: wallet_id.clone(), path, view_enabled, wallet: owner };
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER metadata operation failed without invalidating the authenticated handle");
},
other => {
*session = other;
drop(owner);
},
}
}
async fn recover_owner_metadata_state_conflict(&self, context: OwnerMetadataContext) {
let OwnerMetadataContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
drop(owner);
let inspected = ksp_wallet_lib::inspect_locked_wallet_file_v1(path.as_path()).await;
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let previous = std::mem::replace(&mut *session, crate::WalletSession::no_selection());
match previous {
crate::WalletSession::OwnerOperation {
wallet_id: reserved_wallet_id,
path: reserved_path,
pubkey: reserved_pubkey,
view_enabled: reserved_view_enabled,
} if reserved_wallet_id == wallet_id && reserved_path == path && reserved_pubkey == pubkey && reserved_view_enabled == view_enabled => {
if let std::result::Result::Ok(locked_info) = inspected {
*session = crate::WalletSession::Locked { wallet_id: wallet_id.clone(), path, locked_info };
}
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER metadata state conflict purged the stale handle and forced reauthorization");
},
other => *session = other,
}
}
/// Refreshes the native SOL balance for the currently authorized VIEW/OWNER Wallet. /// Refreshes the native SOL balance for the currently authorized VIEW/OWNER Wallet.
pub(crate) async fn refresh_wallet_balance(&self) -> ksp_core_lib::Result<crate::WalletBalanceDto> { pub(crate) async fn refresh_wallet_balance(&self) -> ksp_core_lib::Result<crate::WalletBalanceDto> {
let context = self.authorized_balance_context(); let context = self.authorized_balance_context();
@@ -702,10 +886,12 @@ impl AppState {
crate::WalletSession::Owner { wallet_id, wallet, .. } => { crate::WalletSession::Owner { wallet_id, wallet, .. } => {
std::result::Result::Ok(WalletBalanceContext { capability: "owner", pubkey: wallet.pubkey().to_owned(), wallet_id: wallet_id.clone() }) std::result::Result::Ok(WalletBalanceContext { capability: "owner", pubkey: wallet.pubkey().to_owned(), wallet_id: wallet_id.clone() })
}, },
crate::WalletSession::PrivilegedOperation { .. } => std::result::Result::Err(ksp_core_lib::Error::new( crate::WalletSession::OwnerOperation { .. } | crate::WalletSession::PrivilegedOperation { .. } => {
crate::ERROR_CODE_WALLET_SESSION_INVALID, std::result::Result::Err(ksp_core_lib::Error::new(
"Wallet balance is unavailable while a privileged Wallet operation is running", crate::ERROR_CODE_WALLET_SESSION_INVALID,
)), "Wallet balance is unavailable while a privileged Wallet operation is running",
))
},
crate::WalletSession::Locked { .. } | crate::WalletSession::NoSelection => std::result::Result::Err(ksp_core_lib::Error::new( crate::WalletSession::Locked { .. } | crate::WalletSession::NoSelection => std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED, crate::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED,
"Wallet balance requires an authorized VIEW or OWNER session", "Wallet balance requires an authorized VIEW or OWNER session",
@@ -726,7 +912,10 @@ impl AppState {
crate::WalletSession::Owner { wallet_id, wallet, .. } => { crate::WalletSession::Owner { wallet_id, wallet, .. } => {
context.capability == "owner" && wallet_id == &context.wallet_id && wallet.pubkey() == &context.pubkey context.capability == "owner" && wallet_id == &context.wallet_id && wallet.pubkey() == &context.pubkey
}, },
crate::WalletSession::NoSelection | crate::WalletSession::Locked { .. } | crate::WalletSession::PrivilegedOperation { .. } => false, crate::WalletSession::NoSelection
| crate::WalletSession::Locked { .. }
| crate::WalletSession::OwnerOperation { .. }
| crate::WalletSession::PrivilegedOperation { .. } => false,
}; };
} }
@@ -772,6 +961,15 @@ fn configured_secret_missing_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_WALLET_SECRET_CANDIDATES_MISSING, "No effective Config-owned Wallet password candidate is available"); return ksp_core_lib::Error::new(crate::ERROR_CODE_WALLET_SECRET_CANDIDATES_MISSING, "No effective Config-owned Wallet password candidate is available");
} }
struct OwnerMetadataContext {
operation: &'static str,
owner: std::boxed::Box<ksp_wallet_lib::WalletOwner>,
path: std::path::PathBuf,
pubkey: ksp_core_lib::Pubkey,
view_enabled: bool,
wallet_id: String,
}
struct WalletBalanceContext { struct WalletBalanceContext {
capability: &'static str, capability: &'static str,
pubkey: ksp_core_lib::Pubkey, pubkey: ksp_core_lib::Pubkey,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/lib.rs // file: crates/ksp-app-wallet-desk/src/lib.rs
// version: 8 // version: 9
//! Tauri desktop application shell for KSP Wallet management and inspection. //! Tauri desktop application shell for KSP Wallet management and inspection.
@@ -23,6 +23,7 @@ mod wallet_balance;
mod wallet_config; mod wallet_config;
mod wallet_import; mod wallet_import;
mod wallet_inventory; mod wallet_inventory;
mod wallet_metadata;
mod wallet_secrets; mod wallet_secrets;
mod wallet_session; mod wallet_session;
@@ -186,6 +187,14 @@ pub(crate) use self::wallet_inventory::resolve_locked_wallet;
/// Re-inspects one root-scoped Wallet selection in crate unit tests. /// Re-inspects one root-scoped Wallet selection in crate unit tests.
#[cfg(test)] #[cfg(test)]
pub(crate) use self::wallet_inventory::select_locked_wallet; pub(crate) use self::wallet_inventory::select_locked_wallet;
/// OWNER alias update request carrying protected metadata only frontend -> Rust.
pub(crate) use self::wallet_metadata::WalletAliasUpdateRequestDto;
/// OWNER note append request carrying protected metadata only frontend -> Rust.
pub(crate) use self::wallet_metadata::WalletNoteAddRequestDto;
/// OWNER note delete request selecting one authorized stable note identifier.
pub(crate) use self::wallet_metadata::WalletNoteDeleteRequestDto;
/// OWNER note update request carrying protected metadata only frontend -> Rust.
pub(crate) use self::wallet_metadata::WalletNoteUpdateRequestDto;
/// Discovers Config-owned Wallet password candidates without exposing their names or values. /// Discovers Config-owned Wallet password candidates without exposing their names or values.
pub(crate) use self::wallet_secrets::discover_wallet_secret_candidates; pub(crate) use self::wallet_secrets::discover_wallet_secret_candidates;
/// Returns the configured Wallet password candidate count without exposing their names or values. /// Returns the configured Wallet password candidate count without exposing their names or values.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/tauri.rs // file: crates/ksp-app-wallet-desk/src/tauri.rs
// version: 6 // version: 7
//! Tauri runtime assembly for the KSP wallet desktop application. //! Tauri runtime assembly for the KSP wallet desktop application.
@@ -40,8 +40,10 @@ fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<taur
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch. #[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> { fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.invoke_handler(tauri::generate_handler![ return builder.invoke_handler(tauri::generate_handler![
add_wallet_note,
clear_import_source, clear_import_source,
create_wallet, create_wallet,
delete_wallet_note,
deselect_wallet, deselect_wallet,
emit_frontend_log, emit_frontend_log,
get_runtime_status, get_runtime_status,
@@ -57,6 +59,8 @@ fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tau
unlock_wallet_owner_manual, unlock_wallet_owner_manual,
unlock_wallet_view_configured, unlock_wallet_view_configured,
unlock_wallet_view_manual, unlock_wallet_view_manual,
update_wallet_alias,
update_wallet_note,
]); ]);
} }
@@ -75,6 +79,18 @@ fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri:
}); });
} }
#[tauri::command]
async fn add_wallet_note(
request: crate::WalletNoteAddRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::WalletAuthorizedDto, crate::CommandErrorDto> {
let result = state.add_wallet_note(request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command] #[tauri::command]
fn clear_import_source(state: tauri::State<'_, crate::AppState>) -> std::result::Result<(), crate::CommandErrorDto> { fn clear_import_source(state: tauri::State<'_, crate::AppState>) -> std::result::Result<(), crate::CommandErrorDto> {
let result = state.clear_import_source(); let result = state.clear_import_source();
@@ -96,6 +112,18 @@ async fn create_wallet(
}; };
} }
#[tauri::command]
async fn delete_wallet_note(
request: crate::WalletNoteDeleteRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::WalletAuthorizedDto, crate::CommandErrorDto> {
let result = state.delete_wallet_note(request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command] #[tauri::command]
fn deselect_wallet(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::WalletSessionDto, crate::CommandErrorDto> { fn deselect_wallet(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::WalletSessionDto, crate::CommandErrorDto> {
let result = state.deselect_wallet(); let result = state.deselect_wallet();
@@ -286,3 +314,27 @@ async fn unlock_wallet_view_manual(
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)), std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
}; };
} }
#[tauri::command]
async fn update_wallet_alias(
request: crate::WalletAliasUpdateRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::WalletAuthorizedDto, crate::CommandErrorDto> {
let result = state.update_wallet_alias(request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
async fn update_wallet_note(
request: crate::WalletNoteUpdateRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::WalletAuthorizedDto, crate::CommandErrorDto> {
let result = state.update_wallet_note(request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}

View File

@@ -0,0 +1,48 @@
// file: crates/ksp-app-wallet-desk/src/wallet_metadata.rs
// version: 1
//! OWNER-only protected metadata administration contracts for Wallet Desk.
use ts_rs::TS; // rust-rules: trait-import
/// OWNER request that updates or clears the protected internal Wallet alias.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_metadata/WalletAliasUpdateRequestDto.ts")]
pub(crate) struct WalletAliasUpdateRequestDto {
/// New protected alias, or `None` to clear it.
pub(crate) alias: std::option::Option<String>,
}
/// OWNER request that appends one protected Wallet note.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteAddRequestDto.ts")]
pub(crate) struct WalletNoteAddRequestDto {
/// Protected note text transported only for the explicit mutation call.
pub(crate) text: String,
}
/// OWNER request that updates one protected Wallet note by stable identifier.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteUpdateRequestDto.ts")]
pub(crate) struct WalletNoteUpdateRequestDto {
/// Stable note identifier returned by the authorized Wallet projection.
pub(crate) note_id: String,
/// Replacement protected note text transported only for the explicit mutation call.
pub(crate) text: String,
}
/// OWNER request that deletes one protected Wallet note by stable identifier.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteDeleteRequestDto.ts")]
pub(crate) struct WalletNoteDeleteRequestDto {
/// Stable note identifier returned by the authorized Wallet projection.
pub(crate) note_id: String,
}
#[cfg(test)]
#[path = "../unit_tests/wallet_metadata.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/wallet_session.rs // file: crates/ksp-app-wallet-desk/src/wallet_session.rs
// version: 3 // version: 4
//! Durable root-scoped Wallet session lifecycle for Wallet Desk. //! Durable root-scoped Wallet session lifecycle for Wallet Desk.
@@ -149,6 +149,8 @@ pub(crate) enum WalletSession {
path: std::path::PathBuf, path: std::path::PathBuf,
/// Authorized VIEW handle retaining metadata capability only in Rust. /// Authorized VIEW handle retaining metadata capability only in Rust.
wallet: std::boxed::Box<ksp_wallet_lib::WalletView>, wallet: std::boxed::Box<ksp_wallet_lib::WalletView>,
/// Whether the authenticated Wallet currently exposes a VIEW slot.
view_enabled: bool,
}, },
/// One Wallet kept open with OWNER capability. /// One Wallet kept open with OWNER capability.
Owner { Owner {
@@ -158,6 +160,19 @@ pub(crate) enum WalletSession {
path: std::path::PathBuf, path: std::path::PathBuf,
/// Authorized OWNER handle retaining secret material only in Rust. /// Authorized OWNER handle retaining secret material only in Rust.
wallet: std::boxed::Box<ksp_wallet_lib::WalletOwner>, wallet: std::boxed::Box<ksp_wallet_lib::WalletOwner>,
/// Whether the authenticated Wallet currently exposes a VIEW slot.
view_enabled: bool,
},
/// One OWNER metadata mutation temporarily owns the authorized handle outside the session mutex.
OwnerOperation {
/// Native Wallet filename / root-scoped identifier.
wallet_id: String,
/// Full root-scoped filesystem path retained only in Rust.
path: std::path::PathBuf,
/// Authorized Solana identity used only to reject stale completion.
pubkey: ksp_core_lib::Pubkey,
/// Whether the authenticated Wallet currently exposes a VIEW slot.
view_enabled: bool,
}, },
} }
@@ -200,6 +215,11 @@ impl WalletSession {
state: WalletSessionStateDto::OwnerOpen, state: WalletSessionStateDto::OwnerOpen,
wallet_id: std::option::Option::Some(wallet_id.clone()), wallet_id: std::option::Option::Some(wallet_id.clone()),
}, },
Self::OwnerOperation { wallet_id, .. } => WalletSessionDto {
filename: std::option::Option::Some(wallet_id.clone()),
state: WalletSessionStateDto::PrivilegedOperation,
wallet_id: std::option::Option::Some(wallet_id.clone()),
},
}; };
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KSP Wallet Desk", "productName": "KSP Wallet Desk",
"version": "0.2.6-pre.8.fix.1", "version": "0.2.6-pre.9",
"identifier": "com.sasedev.ksp-app-wallet-desk", "identifier": "com.sasedev.ksp-app-wallet-desk",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs // file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
// version: 8 // version: 9
//! Desktop build, shell and Config-status contract audits for Wallet Desk. //! Desktop build, shell and Config-status contract audits for Wallet Desk.
@@ -223,3 +223,33 @@ fn pre_008_import_uses_rust_native_picker_and_wallet_transfer_adapters() {
assert!(main.contains("WalletTransferInspectionDto")); assert!(main.contains("WalletTransferInspectionDto"));
assert!(main.contains("bindImportWalletForm")); assert!(main.contains("bindImportWalletForm"));
} }
#[test]
fn pre_009_owner_metadata_administration_uses_wallet_owner_and_refreshes_projection() {
let root = app_root();
let state = read_text(root.join("src/app_state.rs").as_path());
let metadata = read_text(root.join("src/wallet_metadata.rs").as_path());
let session = read_text(root.join("src/wallet_session.rs").as_path());
let tauri = read_text(root.join("src/tauri.rs").as_path());
let html = read_text(root.join("frontend/main.html").as_path());
let main = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(state.contains("owner.update_alias"));
assert!(state.contains("owner.add_note"));
assert!(state.contains("owner.update_note"));
assert!(state.contains("owner.delete_note"));
assert!(state.contains("ERROR_CODE_STATE_CONFLICT"));
assert!(session.contains("OwnerOperation"));
assert!(metadata.contains("WalletAliasUpdateRequestDto"));
assert!(metadata.contains("WalletNoteAddRequestDto"));
assert!(metadata.contains("WalletNoteUpdateRequestDto"));
assert!(metadata.contains("WalletNoteDeleteRequestDto"));
assert!(tauri.contains("update_wallet_alias"));
assert!(tauri.contains("add_wallet_note"));
assert!(tauri.contains("update_wallet_note"));
assert!(tauri.contains("delete_wallet_note"));
assert!(html.contains("id=\"ownerWalletAlias\""));
assert!(html.contains("id=\"ownerNotesList\""));
assert!(html.contains("id=\"deleteWalletNoteModal\""));
assert!(main.contains("runOwnerMetadataMutation"));
assert!(main.contains("recoverOwnerMetadataStateConflict"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_security.rs // file: crates/ksp-app-wallet-desk/tests/desktop_security.rs
// version: 8 // version: 9
//! Static desktop security contracts for the Wallet Desk pre.002 shell. //! Static desktop security contracts for the Wallet Desk pre.002 shell.
@@ -162,3 +162,24 @@ fn import_picker_path_and_transfer_secret_never_cross_into_frontend() {
assert!(command.contains("WalletImportSourceRequestDto")); assert!(command.contains("WalletImportSourceRequestDto"));
assert!(!command.contains("source_path")); assert!(!command.contains("source_path"));
} }
#[test]
fn owner_metadata_values_are_request_only_and_state_conflict_forces_reauthorization() {
let root = app_root();
let metadata = read_text(root.join("src/wallet_metadata.rs").as_path());
let state = read_text(root.join("src/app_state.rs").as_path());
let main = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(!metadata.contains("derive(Clone"));
assert!(!metadata.contains("derive(Debug"));
assert!(state.contains("recover_owner_metadata_state_conflict"));
assert!(state.contains("inspect_locked_wallet_file_v1"));
assert!(state.contains("ERROR_CODE_STATE_CONFLICT"));
assert!(main.contains("isWalletStateConflict"));
assert!(main.contains("select_wallet"));
assert!(main.contains("clearAuthorizedProjection"));
assert!(!main.contains("frontendDebug(\"main\", request.text"));
assert!(!main.contains("frontendTrace(\"main\", request.text"));
assert!(!main.contains("frontendDebug(\"main\", request.alias"));
assert!(!main.contains("window.confirm"));
assert!(main.contains("new Modal(modalElement)"));
}

View File

@@ -0,0 +1,23 @@
// file: crates/ksp-app-wallet-desk/unit_tests/wallet_metadata.rs
// version: 1
#[test]
fn owner_metadata_requests_keep_protected_text_request_only() {
fn assert_deserializable<T>()
where
T: serde::de::DeserializeOwned,
{
return;
}
assert_deserializable::<crate::WalletAliasUpdateRequestDto>();
assert_deserializable::<crate::WalletNoteAddRequestDto>();
assert_deserializable::<crate::WalletNoteDeleteRequestDto>();
assert_deserializable::<crate::WalletNoteUpdateRequestDto>();
}
#[test]
fn wallet_metadata_limits_are_owned_by_wallet_library() {
assert_eq!(ksp_wallet_lib::KSPWALLET_V1_MAX_ALIAS_BYTES, 256);
assert_eq!(ksp_wallet_lib::KSPWALLET_V1_MAX_NOTES, 64);
assert_eq!(ksp_wallet_lib::KSPWALLET_V1_MAX_NOTE_TEXT_BYTES, 8 * 1024);
}

231
deltas/0.2.6/pre.009.md Normal file
View File

@@ -0,0 +1,231 @@
<!-- file: deltas/0.2.6/pre.009.md -->
<!-- version: 1 -->
# Delta `0.2.6-pre.009` — administration metadata OWNER + recovery `wallet.state_conflict`
## Base requise
```text
0.2.6-pre.008-fix.001 appliquée
workspace.package.version = 0.2.6-pre.8.fix.1
```
La base opérateur est propre : `fmt`, audit, `check`, Clippy sans warning, tests Wallet Desk et workspace complet sont verts. Le runtime d'import natif a déjà été validé sur la tranche précédente.
## Type de livraison
```text
ksp-general-0.2.6-pre.009.zip
```
Le signal technique devient :
```text
workspace.package.version = 0.2.6-pre.9
commit = v0.2.6-pre.009
```
`ksp-app-wallet-desk/package.json` et `tauri.conf.json` suivent `0.2.6-pre.9`.
## Objectif
Cette tranche ouvre l'administration des metadata protégées du wallet exclusivement pour une session OWNER :
```text
alias update / clear
note add
note update
note delete
projection autorisée rafraîchie après succès
wallet.state_conflict => purge du handle stale + réinspection Locked + réautorisation obligatoire
```
VIEW continue d'exposer Pubkey/alias/notes en lecture seule. Locked ne reçoit toujours aucune identité protégée.
## Contrats frontend -> Rust
Nouveaux DTOs request-only :
```text
WalletAliasUpdateRequestDto
WalletNoteAddRequestDto
WalletNoteUpdateRequestDto
WalletNoteDeleteRequestDto
```
Ils transportent alias, texte ou identifiant de note uniquement pendant la commande explicite. Ils ne dérivent ni `Debug` ni `Clone` et aucune valeur de metadata n'est journalisée.
## Délégation Wallet
Wallet Desk ne réimplémente aucune mutation. Les commandes appellent directement :
```text
WalletOwner::update_alias
WalletOwner::add_note
WalletOwner::update_note
WalletOwner::delete_note
```
Les bornes V1 restent autoritativement validées dans `ksp-wallet-lib` : alias 256 octets UTF-8, 64 notes maximum, 8 KiB par texte de note.
## Lifecycle OWNER async
Pendant une mutation persistante, le `WalletOwner` est déplacé hors du mutex de session avant l'`await` et remplacé par une réservation Rust-only :
```text
Owner
-> OwnerOperation
-> Owner succès ou erreur non conflictuelle encore compatible
-> Locked wallet.state_conflict + réinspection valide
-> NoSelection conflit + réinspection impossible
```
La réservation conserve uniquement `wallet_id`, chemin Rust-only, Pubkey et état VIEW afin de détecter une complétion stale. Une sélection/désélection concurrente empêche la réinstallation de l'ancien handle.
## Recovery `wallet.state_conflict`
Un conflit d'état ne déclenche jamais de retry aveugle avec l'OWNER authentifié sur une version devenue ancienne.
Le backend :
```text
reçoit wallet.state_conflict
-> détruit WalletOwner stale
-> inspect_locked_wallet_file_v1 sur le fichier courant
-> revient au plus à Locked
```
Le frontend :
```text
purge Pubkey / alias / notes / balance
-> réinspecte explicitement le wallet sélectionné
-> revient à Locked
-> ouvre la vue Security
-> impose un nouvel unlock OWNER
```
## UI
La vue Details reçoit une carte `Administration metadata OWNER` :
```text
alias Save / Clear
nouvelle note / Add note
édition Save note
Delete note
```
Les contrôles sont désactivés hors OWNER. La suppression utilise un modal Bootstrap intégré ; aucun `window.confirm`, `window.alert` ou `window.prompt` n'est introduit. Le contenu de la note n'est pas recopié dans le modal de confirmation.
## Correction du workflow documentaire
Cette tranche applique également la séparation documentaire demandée :
- `ROADMAP.md` est ramené à une ligne synthétique globale pour `0.2.6` et ne sera plus modifié à chaque prerelease ;
- `CHANGELOG.md` reste inchangé et réservé à la clôture ;
- `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md` est restructuré : état acquis condensé + trajectoire restante lisible, au lieu d'une accumulation sur une seule ligne ;
- `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md` reste le plan détaillé de la série.
La trajectoire est étendue ainsi :
```text
pre.013 intégration / compliance / sécurité / smoke
pre.014 polish Bootstrap + splashscreen, détails opérateur à préciser
pre.015 documentation finale / prompt 0.2.7 / cargo tauri build en dernière opération
rel.001
```
`pre.014` inclura notamment le défaut de scrollbars occasionnelles du splashscreen, sans préjuger des autres corrections visuelles qui seront précisées au moment de l'ouverture de la tranche.
## Fichiers ajoutés
```text
crates/ksp-app-wallet-desk/src/wallet_metadata.rs
crates/ksp-app-wallet-desk/unit_tests/wallet_metadata.rs
deltas/0.2.6/pre.009.md
```
## Fichiers modifiés
```text
Cargo.toml
ROADMAP.md
crates/ksp-app-wallet-desk/frontend/main.html
crates/ksp-app-wallet-desk/frontend/ts/main.ts
crates/ksp-app-wallet-desk/package.json
crates/ksp-app-wallet-desk/src/app_state.rs
crates/ksp-app-wallet-desk/src/lib.rs
crates/ksp-app-wallet-desk/src/tauri.rs
crates/ksp-app-wallet-desk/src/wallet_session.rs
crates/ksp-app-wallet-desk/tauri.conf.json
crates/ksp-app-wallet-desk/tests/desktop_contract.rs
crates/ksp-app-wallet-desk/tests/desktop_security.rs
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md
```
## Fichiers supprimés
```text
aucun
```
## Hors scope
Cette tranche ne réalise pas encore :
```text
rotation OWNER / VIEW pre.010
disable/recreate VIEW fort pre.011
export OWNER fichier pre.012
polish final Bootstrap / splash pre.014
build Tauri final pre.015, dernière opération
```
Aucune nouvelle dépendance Cargo ou npm n'est ajoutée.
## Contrôles assistant
```text
General Rust rule audit clean
Rust export completeness audit clean
KSP workspace Rust rule audit clean
headers de fichiers modifiés +1 exact
TOML / JSON parse clean
CHANGELOG / README / RULES inchangés
frontend : aucun dialogue navigateur natif clean
frontend : diagnostics TypeScript locaux aucun diagnostic hors résolution de modules sans node_modules
```
Cargo n'est pas disponible dans l'environnement de génération ; compilation, Clippy et tests restent autoritatifs côté opérateur.
## Validation opérateur requise
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-app-wallet-desk
cargo test --workspace
KSP_WALLETS_DIRECTORY=var/wallet-desk-pre009 \
cargo tauri dev -c crates/ksp-app-wallet-desk/tauri.conf.json
```
Parcours runtime conseillé :
```text
OWNER ouvert -> Details
alias Save puis Clear
Add note
Save note
Delete note via modal Bootstrap
Lock puis VIEW unlock -> metadata visible mais contrôles OWNER désactivés
Deselect/Lock -> projection protégée purgée
```
Canari de conflit recommandé : ouvrir OWNER, modifier/remplacer le fichier wallet par un état authentifié plus récent hors de ce handle, puis tenter une mutation. Le résultat attendu est `wallet.state_conflict`, purge de l'OWNER stale, retour Locked et nouvel unlock OWNER obligatoire.
Aucune commande npm directe n'est nécessaire. `cargo tauri build -c crates/ksp-app-wallet-desk/tauri.conf.json` reste réservé à `pre.015` et doit rester la toute dernière opération de validation de la release.

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md --> <!-- file: docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md -->
<!-- version: 21 --> <!-- version: 22 -->
# Plan `0.2.6` — Wallet Desk # Plan `0.2.6` — Wallet Desk
@@ -1487,17 +1487,25 @@ Aucun changement nest apporté au picker natif, à `Zeroizing<Vec<u8>>`, aux
### `pre.009` — metadata OWNER ### `pre.009` — metadata OWNER
Objectifs : Statut : **implémenté dans `0.2.6-pre.009`, validations opérateur à exécuter**.
Objectifs matérialisés :
```text ```text
update alias update alias / clear alias
notes add/update/delete notes add/update/delete
controls OWNER controls strictement OWNER
projection refresh projection autorisée rafraîchie après mutation
wallet.state_conflict UX aucun mutex std::sync tenu pendant l'I/O async Wallet
DataTable/details coherence après mutation wallet.state_conflict => purge handle stale + réinspection Locked + re-unlock obligatoire
DataTable/details cohérents après mutation
confirmation Bootstrap pour suppression de note
``` ```
Les mutations délèguent directement à `WalletOwner::{update_alias, add_note, update_note, delete_note}`. Pendant la persistence async, le handle OWNER est déplacé hors du mutex `AppState` et remplacé par un état de réservation `OwnerOperation`; une sélection/désélection concurrente empêche donc la réinstallation d'un handle devenu obsolète. Les alias/textes de note ne sont jamais journalisés.
Un `wallet.state_conflict` n'est jamais retenté avec le même handle : Wallet Desk détruit l'OWNER stale, réinspecte le fichier courant sans KDF, revient au plus à `Locked`, puis le frontend purge Pubkey/alias/notes/balance et impose une nouvelle autorisation OWNER.
### `pre.010` — rotations credentials ### `pre.010` — rotations credentials
Objectifs : Objectifs :
@@ -1556,7 +1564,21 @@ workspace checkpoint complet
Cette tranche doit produire la preuve que les surfaces ajoutées fonctionnent ensemble avant la documentation de clôture. Cette tranche doit produire la preuve que les surfaces ajoutées fonctionnent ensemble avant la documentation de clôture.
### `pre.014` — documentation finale, candidate build et prompt suivant ### `pre.014` — polish gabarit Bootstrap et splashscreen
Tranche explicitement réservée au retour visuel/opérateur avant la clôture. Le détail sera borné au moment de l'ouverture de cette tranche ; la liste actuelle est volontairement indicative :
```text
corrections du templating Bootstrap Wallet Desk
cohérence sizing/overflow des conteneurs desktop
splashscreen : suppression des scrollbars occasionnelles
régression visuelle splash -> main
réutilisation du gabarit commun sans déplacer de logique métier
```
Cette tranche n'est pas un prétexte pour refondre l'UX métier. Les détails précis des défauts et critères d'acceptation seront fournis par l'opérateur lorsqu'elle sera ouverte.
### `pre.015` — documentation finale, candidate build et prompt suivant
Dernière tranche prévue avant `rel.001` : Dernière tranche prévue avant `rel.001` :
@@ -1574,7 +1596,7 @@ parcours fonctionnel cargo tauri dev final
cargo tauri build en toute dernière opération cargo tauri build en toute dernière opération
``` ```
Si le build final ou la documentation révèle une correction technique, utiliser `pre.014-fix.NNN` ou insérer une tranche supplémentaire. `pre.014` n'est pas une obligation de clôture artificielle. Si le build final ou la documentation révèle une correction technique, utiliser `pre.015-fix.NNN` ou insérer une tranche supplémentaire. `pre.015` n'est pas une obligation de clôture artificielle.
### `rel.001` — publication stable ### `rel.001` — publication stable