From e2e603ca6dd5537af2f5ec6ea175496b3760f454 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Thu, 3 Sep 2026 21:36:34 +0200 Subject: [PATCH] v0.3.8-pre.010 --- Cargo.toml | 4 +- crates/ksp-app-store-desk/frontend/main.html | 40 ++- crates/ksp-app-store-desk/frontend/ts/main.ts | 226 ++++++++++++++++- crates/ksp-app-store-desk/src/app_state.rs | 20 +- .../ksp-app-store-desk/src/dto_observation.rs | 125 ++++++++++ crates/ksp-app-store-desk/src/lib.rs | 17 +- .../ksp-app-store-desk/src/store_runtime.rs | 228 ++++++++++++++++-- crates/ksp-app-store-desk/src/tauri.rs | 32 ++- .../tests/dependency_boundary.rs | 33 ++- .../tests/desktop_contract.rs | 57 ++++- .../tests/desktop_security.rs | 36 ++- .../unit_tests/dto_observation.rs | 47 ++++ deltas/0.3.8/pre.010.md | 52 ++++ docs/plans/029-V0_3_8_STORE_DESK_PLAN.md | 8 +- docs/validation/025-V0_3_8_STORE_DESK.md | 14 +- 15 files changed, 904 insertions(+), 35 deletions(-) create mode 100644 crates/ksp-app-store-desk/src/dto_observation.rs create mode 100644 crates/ksp-app-store-desk/unit_tests/dto_observation.rs create mode 100644 deltas/0.3.8/pre.010.md diff --git a/Cargo.toml b/Cargo.toml index a0b9c7a..e9f575f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 465 +# version: 466 [workspace] resolver = "3" members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.3.8-pre.9.fix.1" +version = "0.3.8-pre.10" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-app-store-desk/frontend/main.html b/crates/ksp-app-store-desk/frontend/main.html index 7490e0a..acd8d58 100644 --- a/crates/ksp-app-store-desk/frontend/main.html +++ b/crates/ksp-app-store-desk/frontend/main.html @@ -1,5 +1,5 @@ - + @@ -249,6 +249,25 @@
+
+
+
+

Observations d’acquisition

+

Provenance sûre uniquement ; aucun payload source n’est chargé.

+
+
+ +
+ + + + + + + +
ReceivedObservedProviderProtocolMethodOriginEndpointCommitmentSessionFilterSource hashSource bytesObservation key
+
+
@@ -284,6 +303,25 @@
+
+
+
+

Observations d’acquisition

+

Provenance sûre et métadonnées Yellowstone optionnelles ; aucun byte Account n’est chargé.

+
+
+ +
+ + + + + + + +
ReceivedObservedProviderProtocolMethodOriginEndpointCommitmentSessionFilterSource hashSource bytesObservation keyStartupWrite versionTx signature
+
+
diff --git a/crates/ksp-app-store-desk/frontend/ts/main.ts b/crates/ksp-app-store-desk/frontend/ts/main.ts index 9d21e24..defaf24 100644 --- a/crates/ksp-app-store-desk/frontend/ts/main.ts +++ b/crates/ksp-app-store-desk/frontend/ts/main.ts @@ -1,5 +1,5 @@ // file: crates/ksp-app-store-desk/frontend/ts/main.ts -// version: 7 +// version: 8 import DataTable from "datatables.net-bs5"; import "datatables.net-bs5/css/dataTables.bootstrap5.css"; @@ -13,6 +13,12 @@ import type { StoreAccountQueryResponseDto } from "./bindings/ksp_app_store_desk import type { StoreAccountRowDto } from "./bindings/ksp_app_store_desk/dto_account/StoreAccountRowDto.ts"; import type { ShellStatusDto } from "./bindings/ksp_app_store_desk/dto_common/ShellStatusDto.ts"; import type { StoreRuntimeStatusDto } from "./bindings/ksp_app_store_desk/dto_common/StoreRuntimeStatusDto.ts"; +import type { StoreAccountObservationQueryRequestDto } from "./bindings/ksp_app_store_desk/dto_observation/StoreAccountObservationQueryRequestDto.ts"; +import type { StoreAccountObservationQueryResponseDto } from "./bindings/ksp_app_store_desk/dto_observation/StoreAccountObservationQueryResponseDto.ts"; +import type { StoreAccountObservationRowDto } from "./bindings/ksp_app_store_desk/dto_observation/StoreAccountObservationRowDto.ts"; +import type { StoreTransactionObservationQueryRequestDto } from "./bindings/ksp_app_store_desk/dto_observation/StoreTransactionObservationQueryRequestDto.ts"; +import type { StoreTransactionObservationQueryResponseDto } from "./bindings/ksp_app_store_desk/dto_observation/StoreTransactionObservationQueryResponseDto.ts"; +import type { StoreTransactionObservationRowDto } from "./bindings/ksp_app_store_desk/dto_observation/StoreTransactionObservationRowDto.ts"; import type { StoreTransactionDetailDto } from "./bindings/ksp_app_store_desk/dto_transaction/StoreTransactionDetailDto.ts"; import type { StoreTransactionDetailRequestDto } from "./bindings/ksp_app_store_desk/dto_transaction/StoreTransactionDetailRequestDto.ts"; import type { StoreTransactionQueryRequestDto } from "./bindings/ksp_app_store_desk/dto_transaction/StoreTransactionQueryRequestDto.ts"; @@ -54,6 +60,10 @@ const viewTitles: Record = { let transactionTable: StoreDataTable | null = null; let accountTable: StoreDataTable | null = null; +let transactionObservationTable: StoreDataTable | null = null; +let accountObservationTable: StoreDataTable | null = null; +let currentTransactionObservationSignature: string | null = null; +let currentAccountObservationIdentity: { pubkey: string; slot: string; stateHash: string } | null = null; const COPY_FEEDBACK_MILLISECONDS = 1200; const LONG_TEXT_HEAD_CHARACTERS = 12; @@ -211,6 +221,32 @@ function decimalCountToSafeNumber(value: string): number | null { return Number(exact); } +function optionalLongText(value: string | null, fieldId: string): string { + return value === null ? "—" : renderCopyableLongText(value, fieldId); +} + +function observationPageRequest(page: DataTablesPageRequest): { draw: number; limit: number; offset: string } | null { + if (!Number.isSafeInteger(page.draw) || page.draw < 0 || !Number.isSafeInteger(page.start) || page.start < 0 || !Number.isSafeInteger(page.length) || ![25, 50, 100].includes(page.length)) { + return null; + } + return { draw: page.draw, limit: page.length, offset: page.start.toString() }; +} + +function renderObservationCode(value: string | null, fieldId: string): string { + return optionalLongText(value, fieldId); +} + +function renderOrigin(origin: string): string { + return `${escapeHtml(origin)}`; +} + +function renderOptionalBoolean(value: boolean | null): string { + if (value === null) { + return "—"; + } + return value ? 'oui' : 'non'; +} + function currentTransactionQuery(page: DataTablesPageRequest): StoreTransactionQueryRequestDto | null { if (!Number.isSafeInteger(page.start) || page.start < 0 || !Number.isSafeInteger(page.length) || ![25, 50, 100].includes(page.length)) { return null; @@ -461,6 +497,176 @@ function initializeAccountTable(): void { frontendTrace("main", "Store Desk account DataTable initialized", { pagingOwner: "datatables", serverSide: true }); } +function emptyTransactionObservationPage(draw: number): DataTablesPageResponse { + return { data: [], draw, recordsFiltered: 0, recordsTotal: 0 }; +} + +function emptyAccountObservationPage(draw: number): DataTablesPageResponse { + return { data: [], draw, recordsFiltered: 0, recordsTotal: 0 }; +} + +function setTransactionObservationError(message: string | null): void { + const element = document.querySelector("#transactionObservationsError"); + if (!element) { + return; + } + element.hidden = message === null; + element.textContent = message ?? ""; +} + +function setAccountObservationError(message: string | null): void { + const element = document.querySelector("#accountObservationsError"); + if (!element) { + return; + } + element.hidden = message === null; + element.textContent = message ?? ""; +} + +async function queryTransactionObservations(page: DataTablesPageRequest, callback: (response: DataTablesPageResponse) => void): Promise { + const pagination = observationPageRequest(page); + const signature = currentTransactionObservationSignature; + if (signature === null) { + callback(emptyTransactionObservationPage(pagination?.draw ?? 0)); + return; + } + if (pagination === null) { + setTransactionObservationError("La pagination des observations n'est pas admise par Store Desk."); + callback(emptyTransactionObservationPage(0)); + frontendError("main", "Store Desk transaction observation DataTables query rejected before IPC"); + return; + } + const request: StoreTransactionObservationQueryRequestDto = { limit: pagination.limit, offset: pagination.offset, signature }; + setTransactionObservationError(null); + frontendDebug("main", "Store Desk transaction observation DataTables server-side query started", { pageLength: request.limit }); + try { + const response = await invokeKsp("main", "store_query_transaction_observations", { request }); + if (currentTransactionObservationSignature !== signature) { + callback(emptyTransactionObservationPage(pagination.draw)); + frontendTrace("main", "Store Desk stale transaction observation response ignored"); + return; + } + const recordsTotal = decimalCountToSafeNumber(response.recordsTotalDecimal); + const recordsFiltered = decimalCountToSafeNumber(response.recordsFilteredDecimal); + if (recordsTotal === null || recordsFiltered === null) { + setTransactionObservationError("Le volume d'observations dépasse le domaine entier sûr de l'interface."); + callback(emptyTransactionObservationPage(pagination.draw)); + frontendError("main", "Store Desk transaction observation exact count exceeds frontend safe integer domain"); + return; + } + callback({ data: response.rows, draw: pagination.draw, recordsFiltered, recordsTotal }); + frontendDebug("main", "Store Desk transaction observation DataTables server-side query completed", { rowCount: response.rows.length }); + } catch { + setTransactionObservationError("La requête d'observations Transaction a échoué."); + callback(emptyTransactionObservationPage(pagination.draw)); + frontendError("main", "Store Desk transaction observation DataTables server-side query failed"); + } +} + +async function queryAccountObservations(page: DataTablesPageRequest, callback: (response: DataTablesPageResponse) => void): Promise { + const pagination = observationPageRequest(page); + const identity = currentAccountObservationIdentity; + if (identity === null) { + callback(emptyAccountObservationPage(pagination?.draw ?? 0)); + return; + } + if (pagination === null) { + setAccountObservationError("La pagination des observations n'est pas admise par Store Desk."); + callback(emptyAccountObservationPage(0)); + frontendError("main", "Store Desk account observation DataTables query rejected before IPC"); + return; + } + const request: StoreAccountObservationQueryRequestDto = { + limit: pagination.limit, + offset: pagination.offset, + pubkey: identity.pubkey, + slot: identity.slot, + stateHash: identity.stateHash, + }; + setAccountObservationError(null); + frontendDebug("main", "Store Desk account observation DataTables server-side query started", { pageLength: request.limit }); + try { + const response = await invokeKsp("main", "store_query_account_observations", { request }); + if (currentAccountObservationIdentity !== identity) { + callback(emptyAccountObservationPage(pagination.draw)); + frontendTrace("main", "Store Desk stale account observation response ignored"); + return; + } + const recordsTotal = decimalCountToSafeNumber(response.recordsTotalDecimal); + const recordsFiltered = decimalCountToSafeNumber(response.recordsFilteredDecimal); + if (recordsTotal === null || recordsFiltered === null) { + setAccountObservationError("Le volume d'observations dépasse le domaine entier sûr de l'interface."); + callback(emptyAccountObservationPage(pagination.draw)); + frontendError("main", "Store Desk account observation exact count exceeds frontend safe integer domain"); + return; + } + callback({ data: response.rows, draw: pagination.draw, recordsFiltered, recordsTotal }); + frontendDebug("main", "Store Desk account observation DataTables server-side query completed", { rowCount: response.rows.length }); + } catch { + setAccountObservationError("La requête d'observations Account a échoué."); + callback(emptyAccountObservationPage(pagination.draw)); + frontendError("main", "Store Desk account observation DataTables server-side query failed"); + } +} + +function provenanceColumns(prefix: string) { + return [ + { className: "font-monospace", data: "provenance.receivedAtUnixMillisDecimal" }, + { className: "font-monospace", data: "provenance.observedAtUnixMillisDecimal", defaultContent: "—" }, + { data: "provenance.provider", render: (data: unknown, type: string) => (type === "display" ? renderObservationCode(String(data), `${prefix}-provider`) : String(data)) }, + { data: "provenance.protocol", render: (data: unknown, type: string) => (type === "display" ? renderObservationCode(String(data), `${prefix}-protocol`) : String(data)) }, + { data: "provenance.acquisitionMethod", render: (data: unknown, type: string) => (type === "display" ? renderObservationCode(String(data), `${prefix}-method`) : String(data)) }, + { data: "provenance.origin", render: (data: unknown, type: string) => (type === "display" ? renderOrigin(String(data)) : String(data)) }, + { data: "provenance.endpointId", defaultContent: null, render: (data: unknown, type: string) => (type === "display" ? renderObservationCode(data === null ? null : String(data), `${prefix}-endpoint`) : data) }, + { data: "provenance.commitment", defaultContent: null, render: (data: unknown, type: string) => (type === "display" ? renderObservationCode(data === null ? null : String(data), `${prefix}-commitment`) : data) }, + { data: "provenance.captureSessionId", defaultContent: null, render: (data: unknown, type: string) => (type === "display" ? renderObservationCode(data === null ? null : String(data), `${prefix}-session`) : data) }, + { data: "provenance.filterId", defaultContent: null, render: (data: unknown, type: string) => (type === "display" ? renderObservationCode(data === null ? null : String(data), `${prefix}-filter`) : data) }, + { data: "provenance.sourcePayloadHash", defaultContent: null, render: (data: unknown, type: string) => (type === "display" ? renderObservationCode(data === null ? null : String(data), `${prefix}-source-hash`) : data) }, + { className: "font-monospace", data: "provenance.sourcePayloadSizeDecimal", defaultContent: "—" }, + ]; +} + +function initializeTransactionObservationTable(): void { + transactionObservationTable = new DataTable("#transactionObservationsTable", { + ajax: (data, callback) => { + void queryTransactionObservations(data as unknown as DataTablesPageRequest, response => callback(response)); + }, + autoWidth: false, + columns: [ + ...provenanceColumns("transaction-observation"), + { data: "observationKey", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-observation-key") : String(data)) }, + ], + info: true, lengthMenu: [25, 50, 100], ordering: false, pageLength: 25, paging: true, processing: true, searching: false, serverSide: true, scrollX: true, + language: { emptyTable: "Aucune observation pour cette transaction.", info: "_START_ à _END_ sur _TOTAL_ observation(s)", infoEmpty: "0 observation", lengthMenu: "Afficher _MENU_", loadingRecords: "Chargement Store…", processing: "Inspection Store…", zeroRecords: "Aucune observation correspondante." }, + }) as unknown as StoreDataTable; + transactionObservationTable.on("page", () => frontendDebug("main", "Store Desk transaction observation DataTables page changed")); + transactionObservationTable.on("length", () => frontendDebug("main", "Store Desk transaction observation DataTables page length changed")); + transactionObservationTable.on("draw", () => frontendTrace("main", "Store Desk transaction observation DataTables redraw completed")); + frontendTrace("main", "Store Desk transaction observation DataTable initialized", { pagingOwner: "datatables", serverSide: true }); +} + +function initializeAccountObservationTable(): void { + accountObservationTable = new DataTable("#accountObservationsTable", { + ajax: (data, callback) => { + void queryAccountObservations(data as unknown as DataTablesPageRequest, response => callback(response)); + }, + autoWidth: false, + columns: [ + ...provenanceColumns("account-observation"), + { data: "observationKey", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "account-observation-key") : String(data)) }, + { data: "isStartup", defaultContent: null, render: (data, type) => (type === "display" ? renderOptionalBoolean(data === null ? null : Boolean(data)) : data) }, + { className: "font-monospace", data: "writeVersionDecimal", defaultContent: "—" }, + { data: "transactionSignature", defaultContent: null, render: (data, type) => (type === "display" ? optionalLongText(data === null ? null : String(data), "account-observation-transaction-signature") : data) }, + ], + info: true, lengthMenu: [25, 50, 100], ordering: false, pageLength: 25, paging: true, processing: true, searching: false, serverSide: true, scrollX: true, + language: { emptyTable: "Aucune observation pour cet état Account.", info: "_START_ à _END_ sur _TOTAL_ observation(s)", infoEmpty: "0 observation", lengthMenu: "Afficher _MENU_", loadingRecords: "Chargement Store…", processing: "Inspection Store…", zeroRecords: "Aucune observation correspondante." }, + }) as unknown as StoreDataTable; + accountObservationTable.on("page", () => frontendDebug("main", "Store Desk account observation DataTables page changed")); + accountObservationTable.on("length", () => frontendDebug("main", "Store Desk account observation DataTables page length changed")); + accountObservationTable.on("draw", () => frontendTrace("main", "Store Desk account observation DataTables redraw completed")); + frontendTrace("main", "Store Desk account observation DataTable initialized", { pagingOwner: "datatables", serverSide: true }); +} + function renderShellStatus(status: ShellStatusDto): void { setText("appVersionBadge", status.applicationVersion); setText("runtimeVersion", status.applicationVersion); @@ -525,6 +731,9 @@ async function refreshDiagnostics(): Promise { } function clearAccountDetail(): void { + currentAccountObservationIdentity = null; + setAccountObservationError(null); + accountObservationTable?.draw(true); for (const elementId of [ "accountDetailPubkey", "accountDetailSlot", @@ -588,6 +797,8 @@ async function openAccountDetail(pubkey: string, slot: string, stateHash: string try { const detail = await invokeKsp("main", "store_get_account_detail", { request }); renderAccountDetail(detail); + currentAccountObservationIdentity = { pubkey, slot, stateHash }; + accountObservationTable?.draw(true); frontendDebug("main", "Store Desk account detail opened", { dataPreviewTruncated: detail.dataPreviewTruncated }); } catch { setText("accountDetailError", "Le détail de cet état Account n'a pas pu être chargé."); @@ -602,6 +813,9 @@ function redrawAccounts(resetPaging: boolean, reason: string): void { } function clearTransactionDetail(): void { + currentTransactionObservationSignature = null; + setTransactionObservationError(null); + transactionObservationTable?.draw(true); for (const elementId of [ "transactionDetailSignature", "transactionDetailSlot", @@ -661,6 +875,8 @@ async function openTransactionDetail(signature: string): Promise { try { const detail = await invokeKsp("main", "store_get_transaction_detail", { request }); renderTransactionDetail(detail); + currentTransactionObservationSignature = signature; + transactionObservationTable?.draw(true); frontendDebug("main", "Store Desk transaction detail opened", { payloadPreviewTruncated: detail.payloadPreviewTruncated, retentionState: detail.retentionState }); } catch { setText("transactionDetailError", "Le détail de cette transaction n'a pas pu être chargé."); @@ -780,11 +996,17 @@ function installInteractions(): void { }); }); const accountDetailModal = document.querySelector("#accountDetailModal"); + accountDetailModal?.addEventListener("shown.bs.modal", () => { + accountObservationTable?.columns.adjust(); + }); accountDetailModal?.addEventListener("hidden.bs.modal", () => { clearAccountDetail(); frontendDebug("main", "Store Desk account detail closed"); }); const detailModal = document.querySelector("#transactionDetailModal"); + detailModal?.addEventListener("shown.bs.modal", () => { + transactionObservationTable?.columns.adjust(); + }); detailModal?.addEventListener("hidden.bs.modal", () => { clearTransactionDetail(); frontendDebug("main", "Store Desk transaction detail closed"); @@ -796,6 +1018,8 @@ async function initializeMain(): Promise { frontendInfo("main", "Store Desk main frontend loaded"); initializeTransactionTable(); initializeAccountTable(); + initializeTransactionObservationTable(); + initializeAccountObservationTable(); installInteractions(); activateView("overview"); await refreshDiagnostics(); diff --git a/crates/ksp-app-store-desk/src/app_state.rs b/crates/ksp-app-store-desk/src/app_state.rs index 4f8a097..31a7767 100644 --- a/crates/ksp-app-store-desk/src/app_state.rs +++ b/crates/ksp-app-store-desk/src/app_state.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-store-desk/src/app_state.rs -// version: 4 +// version: 5 //! Shared backend state owned by the Store Desk Tauri application. @@ -105,7 +105,7 @@ impl crate::AppState { application_version: env!("CARGO_PKG_VERSION").to_owned(), config_document_count: document_count, fallback_logging_active: runtime.fallback_active, - shell_phase: "pre.008-raw-accounts".to_owned(), + shell_phase: "pre.010-observations-ui".to_owned(), startup_diagnostic: runtime.startup_diagnostic.clone(), }); } @@ -125,6 +125,14 @@ impl crate::AppState { return self.store_startup.account_detail(request).await; } + /// Executes one bounded metadata-only observation inspection for one explicit RAW account state. + pub(crate) async fn query_account_observations( + &self, + request: crate::StoreAccountObservationQueryRequestDto, + ) -> ksp_core_lib::Result { + return self.store_startup.query_account_observations(request).await; + } + /// Executes one bounded RAW transaction inspection query through the retained Store facade. pub(crate) async fn query_transactions( &self, @@ -138,6 +146,14 @@ impl crate::AppState { return self.store_startup.transaction_detail(request).await; } + /// Executes one bounded metadata-only observation inspection for one explicit RAW transaction. + pub(crate) async fn query_transaction_observations( + &self, + request: crate::StoreTransactionObservationQueryRequestDto, + ) -> ksp_core_lib::Result { + return self.store_startup.query_transaction_observations(request).await; + } + /// Returns the resolved splash timings captured during application bootstrap. #[must_use] pub(crate) const fn splash_settings(&self) -> crate::SplashSettings { diff --git a/crates/ksp-app-store-desk/src/dto_observation.rs b/crates/ksp-app-store-desk/src/dto_observation.rs new file mode 100644 index 0000000..5257c56 --- /dev/null +++ b/crates/ksp-app-store-desk/src/dto_observation.rs @@ -0,0 +1,125 @@ +// file: crates/ksp-app-store-desk/src/dto_observation.rs +// version: 1 + +//! Application-owned DTOs for safe RAW observation inspection inside entity detail modals. + +use ts_rs::TS; // rust-rules: trait-import + +/// Safe source-independent acquisition provenance projected for Store Desk observation tables. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_store_desk/dto_observation/StoreObservationProvenanceDto.ts")] +pub(crate) struct StoreObservationProvenanceDto { + /// Logical acquisition-method code. + pub(crate) acquisition_method: String, + /// Optional bounded capture/session identifier. + pub(crate) capture_session_id: std::option::Option, + /// Optional commitment code captured at acquisition. + pub(crate) commitment: std::option::Option, + /// Optional Config-owned logical endpoint identifier. + pub(crate) endpoint_id: std::option::Option, + /// Optional bounded logical filter identifier. + pub(crate) filter_id: std::option::Option, + /// Optional source-observation timestamp encoded as exact Unix-millisecond decimal text. + pub(crate) observed_at_unix_millis_decimal: std::option::Option, + /// Stable acquisition-origin code. + pub(crate) origin: String, + /// Logical transport/protocol code. + pub(crate) protocol: String, + /// Safe provider code. + pub(crate) provider: String, + /// Local receipt timestamp encoded as exact Unix-millisecond decimal text. + pub(crate) received_at_unix_millis_decimal: String, + /// Optional source-wire payload digest encoded as lower-case hexadecimal text, never source bytes. + pub(crate) source_payload_hash: std::option::Option, + /// Optional source-wire payload size encoded as exact decimal text. + pub(crate) source_payload_size_decimal: std::option::Option, +} + +/// DataTables random-access request for observations of one explicit RAW transaction. +#[derive(Debug, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_store_desk/dto_observation/StoreTransactionObservationQueryRequestDto.ts")] +pub(crate) struct StoreTransactionObservationQueryRequestDto { + /// Whitelisted page size (`25`, `50`, or `100`). + pub(crate) limit: u32, + /// Absolute zero-based observation offset encoded as exact decimal text. + pub(crate) offset: String, + /// Canonical 64-byte transaction signature encoded as lower-case hexadecimal text. + pub(crate) signature: String, +} + +/// Safe metadata-only row for one RAW transaction observation. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_store_desk/dto_observation/StoreTransactionObservationRowDto.ts")] +pub(crate) struct StoreTransactionObservationRowDto { + /// Deterministic observation key encoded as lower-case hexadecimal text. + pub(crate) observation_key: String, + /// Safe source-independent acquisition provenance. + pub(crate) provenance: crate::StoreObservationProvenanceDto, +} + +/// Server-side transaction-observation table result using exact decimal counts. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_store_desk/dto_observation/StoreTransactionObservationQueryResponseDto.ts")] +pub(crate) struct StoreTransactionObservationQueryResponseDto { + /// Exact count after the mandatory transaction filter. + pub(crate) records_filtered_decimal: String, + /// Exact count in the mandatory Store network scope before the entity filter. + pub(crate) records_total_decimal: String, + /// Metadata-only observation rows for the requested random-access page. + pub(crate) rows: std::vec::Vec, +} + +/// DataTables random-access request for observations of one explicit RAW account state. +#[derive(Debug, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_store_desk/dto_observation/StoreAccountObservationQueryRequestDto.ts")] +pub(crate) struct StoreAccountObservationQueryRequestDto { + /// Whitelisted page size (`25`, `50`, or `100`). + pub(crate) limit: u32, + /// Absolute zero-based observation offset encoded as exact decimal text. + pub(crate) offset: String, + /// Account public key in canonical base58 text. + pub(crate) pubkey: String, + /// Solana slot encoded as exact decimal text. + pub(crate) slot: String, + /// Canonical account-state hash encoded as lower-case hexadecimal text. + pub(crate) state_hash: String, +} + +/// Safe metadata-only row for one RAW account observation. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_store_desk/dto_observation/StoreAccountObservationRowDto.ts")] +pub(crate) struct StoreAccountObservationRowDto { + /// Optional source-reported startup/replay marker. + pub(crate) is_startup: std::option::Option, + /// Deterministic observation key encoded as lower-case hexadecimal text. + pub(crate) observation_key: String, + /// Safe source-independent acquisition provenance. + pub(crate) provenance: crate::StoreObservationProvenanceDto, + /// Optional transaction signature associated with the account write, encoded as lower-case hexadecimal text. + pub(crate) transaction_signature: std::option::Option, + /// Optional source-specific account write version encoded as exact decimal text. + pub(crate) write_version_decimal: std::option::Option, +} + +/// Server-side account-observation table result using exact decimal counts. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_store_desk/dto_observation/StoreAccountObservationQueryResponseDto.ts")] +pub(crate) struct StoreAccountObservationQueryResponseDto { + /// Exact count after the mandatory account-state filter. + pub(crate) records_filtered_decimal: String, + /// Exact count in the mandatory Store network scope before the entity filter. + pub(crate) records_total_decimal: String, + /// Metadata-only observation rows for the requested random-access page. + pub(crate) rows: std::vec::Vec, +} + +#[cfg(test)] +#[path = "../unit_tests/dto_observation.rs"] +mod tests; diff --git a/crates/ksp-app-store-desk/src/lib.rs b/crates/ksp-app-store-desk/src/lib.rs index 16dad20..0a1adf9 100644 --- a/crates/ksp-app-store-desk/src/lib.rs +++ b/crates/ksp-app-store-desk/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-store-desk/src/lib.rs -// version: 5 +// version: 6 //! Tauri desktop scaffold for backend-neutral KSP Store inspection. @@ -12,6 +12,7 @@ mod bootstrap; mod constants; mod dto_account; mod dto_common; +mod dto_observation; mod dto_transaction; mod errors; mod frontend_logging; @@ -77,6 +78,20 @@ pub(crate) use self::dto_common::CommandErrorDto; pub(crate) use self::dto_common::ShellStatusDto; /// Backend-neutral Store runtime and health projection exposed to Overview. pub(crate) use self::dto_common::StoreRuntimeStatusDto; +/// DataTables random-access request for observations of one explicit RAW account state. +pub(crate) use self::dto_observation::StoreAccountObservationQueryRequestDto; +/// Server-side account-observation table result using exact decimal counts. +pub(crate) use self::dto_observation::StoreAccountObservationQueryResponseDto; +/// Safe metadata-only row for one RAW account observation. +pub(crate) use self::dto_observation::StoreAccountObservationRowDto; +/// Safe source-independent acquisition provenance projected for observation tables. +pub(crate) use self::dto_observation::StoreObservationProvenanceDto; +/// DataTables random-access request for observations of one explicit RAW transaction. +pub(crate) use self::dto_observation::StoreTransactionObservationQueryRequestDto; +/// Server-side transaction-observation table result using exact decimal counts. +pub(crate) use self::dto_observation::StoreTransactionObservationQueryResponseDto; +/// Safe metadata-only row for one RAW transaction observation. +pub(crate) use self::dto_observation::StoreTransactionObservationRowDto; /// Bounded detail projection for one RAW transaction. pub(crate) use self::dto_transaction::StoreTransactionDetailDto; /// App-owned identity request for one explicit RAW transaction detail load. diff --git a/crates/ksp-app-store-desk/src/store_runtime.rs b/crates/ksp-app-store-desk/src/store_runtime.rs index 8264b9c..60d922e 100644 --- a/crates/ksp-app-store-desk/src/store_runtime.rs +++ b/crates/ksp-app-store-desk/src/store_runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-store-desk/src/store_runtime.rs -// version: 4 +// version: 5 //! Composite-selected Store readiness, status and bounded shutdown lifecycle owned by Store Desk. @@ -53,6 +53,18 @@ impl crate::StoreStartup { }; } + /// Executes one bounded metadata-only observation inspection for an explicit RAW account state. + pub(crate) async fn query_account_observations( + &self, + request: crate::StoreAccountObservationQueryRequestDto, + ) -> ksp_core_lib::Result { + let runtime = self.runtime.as_ref(); + return match runtime { + std::option::Option::Some(value) => value.query_account_observations(request).await, + std::option::Option::None => std::result::Result::Err(store_runtime_unavailable()), + }; + } + /// Executes one bounded server-side RAW transaction inspection query. pub(crate) async fn query_transactions( &self, @@ -74,6 +86,18 @@ impl crate::StoreStartup { }; } + /// Executes one bounded metadata-only observation inspection for an explicit RAW transaction. + pub(crate) async fn query_transaction_observations( + &self, + request: crate::StoreTransactionObservationQueryRequestDto, + ) -> ksp_core_lib::Result { + let runtime = self.runtime.as_ref(); + return match runtime { + std::option::Option::Some(value) => value.query_transaction_observations(request).await, + std::option::Option::None => std::result::Result::Err(store_runtime_unavailable()), + }; + } + /// Closes the retained Store runtime if startup reached the physical Store-open phase. pub(crate) async fn close(&self) -> ksp_core_lib::Result<()> { let runtime = self.runtime.as_ref(); @@ -184,6 +208,37 @@ impl crate::StoreRuntime { return std::result::Result::Ok(account_detail_dto(&state)); } + /// Executes one validated metadata-only observation query for one exact account-state identity. + pub(crate) async fn query_account_observations( + &self, + request: crate::StoreAccountObservationQueryRequestDto, + ) -> ksp_core_lib::Result { + let locked = self.store.read().await; + let store = locked.as_ref(); + let store = match store { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(store_runtime_unavailable()), + }; + let query = account_observation_query_from_request(store, &request); + let query = match query { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let page = ksp_store_lib::RawAccountObservationInspectionRead::inspect_raw_account_observations(store, &query).await; + let page = match page { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let records_total_decimal = page.total_items().to_string(); + let records_filtered_decimal = page.filtered_items().to_string(); + let items = page.into_items(); + let mut rows = std::vec::Vec::with_capacity(items.len()); + for item in items { + rows.push(account_observation_row_dto(&item)); + } + return std::result::Result::Ok(crate::StoreAccountObservationQueryResponseDto { records_filtered_decimal, records_total_decimal, rows }); + } + /// Executes one validated random-access transaction inspection query while retaining the Store read guard. pub(crate) async fn query_transactions( &self, @@ -246,6 +301,37 @@ impl crate::StoreRuntime { }; } + /// Executes one validated metadata-only observation query for one exact transaction identity. + pub(crate) async fn query_transaction_observations( + &self, + request: crate::StoreTransactionObservationQueryRequestDto, + ) -> ksp_core_lib::Result { + let locked = self.store.read().await; + let store = locked.as_ref(); + let store = match store { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(store_runtime_unavailable()), + }; + let query = transaction_observation_query_from_request(store, &request); + let query = match query { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let page = ksp_store_lib::RawTransactionObservationInspectionRead::inspect_raw_transaction_observations(store, &query).await; + let page = match page { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let records_total_decimal = page.total_items().to_string(); + let records_filtered_decimal = page.filtered_items().to_string(); + let items = page.into_items(); + let mut rows = std::vec::Vec::with_capacity(items.len()); + for item in items { + rows.push(transaction_observation_row_dto(&item)); + } + return std::result::Result::Ok(crate::StoreTransactionObservationQueryResponseDto { records_filtered_decimal, records_total_decimal, rows }); + } + /// Explicitly closes the Store exactly once through its backend-neutral facade after all read guards have drained. pub(crate) async fn close(&self) -> ksp_core_lib::Result<()> { let mut locked = self.store.write().await; @@ -281,23 +367,88 @@ impl crate::StoreRuntime { const ACCOUNT_DETAIL_PREVIEW_BYTES: usize = 512; const TRANSACTION_DETAIL_PREVIEW_BYTES: usize = 512; -fn account_query_from_request( - store: &ksp_store_lib::Store, - request: &crate::StoreAccountQueryRequestDto, -) -> ksp_core_lib::Result { - let limit = match request.limit { - 25 | 50 | 100 => ksp_store_lib::RawPageLimit::new(u64::from(request.limit)), +fn inspection_page_from_request(offset_text: &str, limit_value: u32) -> ksp_core_lib::Result { + let limit = match limit_value { + 25 | 50 | 100 => ksp_store_lib::RawPageLimit::new(u64::from(limit_value)), _ => return std::result::Result::Err(store_query_invalid()), }; let limit = match limit { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(store_query_invalid()), }; - let offset = request.offset.parse::(); + let offset = offset_text.parse::(); let offset = match offset { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(store_query_invalid()), }; + return std::result::Result::Ok(ksp_store_lib::RawInspectionPageRequest::new(offset, limit)); +} + +fn account_observation_query_from_request( + store: &ksp_store_lib::Store, + request: &crate::StoreAccountObservationQueryRequestDto, +) -> ksp_core_lib::Result { + let page = inspection_page_from_request(request.offset.as_str(), request.limit); + let page = match page { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let detail_request = + crate::StoreAccountDetailRequestDto { pubkey: request.pubkey.clone(), slot: request.slot.clone(), state_hash: request.state_hash.clone() }; + let reference = account_reference_from_request(store, &detail_request); + let reference = match reference { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let network = store.runtime_snapshot().network().clone(); + let query = ksp_store_lib::RawAccountObservationInspectionQuery::try_new( + network, + std::option::Option::Some(reference), + ksp_store_lib::RawSortDirection::Descending, + page, + ); + return match query { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(store_query_invalid()), + }; +} + +fn transaction_observation_query_from_request( + store: &ksp_store_lib::Store, + request: &crate::StoreTransactionObservationQueryRequestDto, +) -> ksp_core_lib::Result { + let page = inspection_page_from_request(request.offset.as_str(), request.limit); + let page = match page { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let reference = transaction_reference_from_request(store, request.signature.as_str()); + let reference = match reference { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let network = store.runtime_snapshot().network().clone(); + let query = ksp_store_lib::RawTransactionObservationInspectionQuery::try_new( + network, + std::option::Option::Some(reference), + ksp_store_lib::RawSortDirection::Descending, + page, + ); + return match query { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(store_query_invalid()), + }; +} + +fn account_query_from_request( + store: &ksp_store_lib::Store, + request: &crate::StoreAccountQueryRequestDto, +) -> ksp_core_lib::Result { + let page = inspection_page_from_request(request.offset.as_str(), request.limit); + let page = match page { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; let pubkey = parse_optional_pubkey(request.pubkey.as_deref()); let pubkey = match pubkey { std::result::Result::Ok(value) => value, @@ -324,7 +475,6 @@ fn account_query_from_request( _ => return std::result::Result::Err(store_query_invalid()), }; let network = store.runtime_snapshot().network().clone(); - let page = ksp_store_lib::RawInspectionPageRequest::new(offset, limit); return std::result::Result::Ok(ksp_store_lib::RawAccountStateInspectionQuery::new(network, pubkey, slots, direction, page)); } @@ -383,22 +533,59 @@ fn account_row_dto(summary: &ksp_store_lib::RawAccountStateSummary) -> crate::St }; } +fn observation_provenance_dto(provenance: &ksp_store_lib::RawAcquisitionProvenance) -> crate::StoreObservationProvenanceDto { + return crate::StoreObservationProvenanceDto { + acquisition_method: provenance.acquisition_method().as_str().to_owned(), + capture_session_id: provenance.capture_session_id().map(|value| return value.as_str().to_owned()), + commitment: provenance.commitment().map(|value| return value.as_str().to_owned()), + endpoint_id: provenance.endpoint_id().map(|value| return value.as_str().to_owned()), + filter_id: provenance.filter_id().map(|value| return value.as_str().to_owned()), + observed_at_unix_millis_decimal: provenance.observed_at().map(|value| return value.unix_millis().to_string()), + origin: acquisition_origin_code(provenance.origin()).to_owned(), + protocol: provenance.protocol().as_str().to_owned(), + provider: provenance.provider().as_str().to_owned(), + received_at_unix_millis_decimal: provenance.received_at().unix_millis().to_string(), + source_payload_hash: provenance.source_payload_hash().map(|value| return encode_hex(value.as_bytes())), + source_payload_size_decimal: provenance.source_payload_size_bytes().map(|value| return value.to_string()), + }; +} + +fn account_observation_row_dto(summary: &ksp_store_lib::RawAccountObservationSummary) -> crate::StoreAccountObservationRowDto { + return crate::StoreAccountObservationRowDto { + is_startup: summary.is_startup(), + observation_key: encode_hex(summary.observation_key().as_bytes()), + provenance: observation_provenance_dto(summary.provenance()), + transaction_signature: summary.transaction_signature().map(|value| return encode_hex(value.as_bytes())), + write_version_decimal: summary.write_version().map(|value| return value.to_string()), + }; +} + +fn transaction_observation_row_dto(summary: &ksp_store_lib::RawTransactionObservationSummary) -> crate::StoreTransactionObservationRowDto { + return crate::StoreTransactionObservationRowDto { + observation_key: encode_hex(summary.observation_key().as_bytes()), + provenance: observation_provenance_dto(summary.provenance()), + }; +} + +fn acquisition_origin_code(origin: ksp_store_lib::RawAcquisitionOrigin) -> &'static str { + return match origin { + ksp_store_lib::RawAcquisitionOrigin::Backfill => "backfill", + ksp_store_lib::RawAcquisitionOrigin::Import => "import", + ksp_store_lib::RawAcquisitionOrigin::Live => "live", + ksp_store_lib::RawAcquisitionOrigin::Repair => "repair", + ksp_store_lib::RawAcquisitionOrigin::Replay => "replay", + _ => "unknown", + }; +} + fn transaction_query_from_request( store: &ksp_store_lib::Store, request: &crate::StoreTransactionQueryRequestDto, ) -> ksp_core_lib::Result { - let limit = match request.limit { - 25 | 50 | 100 => ksp_store_lib::RawPageLimit::new(u64::from(request.limit)), - _ => return std::result::Result::Err(store_query_invalid()), - }; - let limit = match limit { + let page = inspection_page_from_request(request.offset.as_str(), request.limit); + let page = match page { std::result::Result::Ok(value) => value, - std::result::Result::Err(_) => return std::result::Result::Err(store_query_invalid()), - }; - let offset = request.offset.parse::(); - let offset = match offset { - std::result::Result::Ok(value) => value, - std::result::Result::Err(_) => return std::result::Result::Err(store_query_invalid()), + std::result::Result::Err(error) => return std::result::Result::Err(error), }; let slot_min = parse_optional_decimal_u64(request.slot_min.as_deref()); let slot_min = match slot_min { @@ -421,7 +608,6 @@ fn transaction_query_from_request( _ => return std::result::Result::Err(store_query_invalid()), }; let network = store.runtime_snapshot().network().clone(); - let page = ksp_store_lib::RawInspectionPageRequest::new(offset, limit); return std::result::Result::Ok(ksp_store_lib::RawTransactionInspectionQuery::new(network, slots, direction, page)); } diff --git a/crates/ksp-app-store-desk/src/tauri.rs b/crates/ksp-app-store-desk/src/tauri.rs index 9e32be8..e231d9a 100644 --- a/crates/ksp-app-store-desk/src/tauri.rs +++ b/crates/ksp-app-store-desk/src/tauri.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-store-desk/src/tauri.rs -// version: 4 +// version: 5 //! Tauri runtime assembly for the KSP Store desktop application. @@ -81,7 +81,9 @@ fn configure_commands(builder: tauri::Builder) -> tauri::Builder) -> std::result::Re }; } +#[tauri::command] +async fn store_query_account_observations( + request: crate::StoreAccountObservationQueryRequestDto, + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let result = state.query_account_observations(request).await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => { + std::result::Result::Err(project_command_error("store_query_account_observations", crate::TRACING_DOMAIN_STORE, &error)) + }, + }; +} + #[tauri::command] async fn store_query_accounts( request: crate::StoreAccountQueryRequestDto, @@ -200,6 +216,20 @@ async fn store_get_account_detail( }; } +#[tauri::command] +async fn store_query_transaction_observations( + request: crate::StoreTransactionObservationQueryRequestDto, + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let result = state.query_transaction_observations(request).await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => { + std::result::Result::Err(project_command_error("store_query_transaction_observations", crate::TRACING_DOMAIN_STORE, &error)) + }, + }; +} + #[tauri::command] async fn store_query_transactions( request: crate::StoreTransactionQueryRequestDto, diff --git a/crates/ksp-app-store-desk/tests/dependency_boundary.rs b/crates/ksp-app-store-desk/tests/dependency_boundary.rs index 25a7db6..0db6906 100644 --- a/crates/ksp-app-store-desk/tests/dependency_boundary.rs +++ b/crates/ksp-app-store-desk/tests/dependency_boundary.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-store-desk/tests/dependency_boundary.rs -// version: 6 +// version: 7 //! Dependency-boundary tests for the Store Desk application. @@ -131,3 +131,34 @@ fn pre_008_account_inspection_uses_store_facade_and_new_visible_items_use_crate_ assert!(!sources.contains(forbidden), "Store Desk account inspection crossed physical/backend-neutral boundary: {forbidden}"); } } + +#[test] +fn pre_010_observation_ui_uses_store_facade_and_new_visible_items_use_crate_root_exports() { + let lib = include_str!("../src/lib.rs"); + let dto = include_str!("../src/dto_observation.rs"); + let runtime = include_str!("../src/store_runtime.rs"); + let app_state = include_str!("../src/app_state.rs"); + let tauri = include_str!("../src/tauri.rs"); + for expected in [ + "pub(crate) use self::dto_observation::StoreAccountObservationQueryRequestDto;", + "pub(crate) use self::dto_observation::StoreAccountObservationQueryResponseDto;", + "pub(crate) use self::dto_observation::StoreAccountObservationRowDto;", + "pub(crate) use self::dto_observation::StoreObservationProvenanceDto;", + "pub(crate) use self::dto_observation::StoreTransactionObservationQueryRequestDto;", + "pub(crate) use self::dto_observation::StoreTransactionObservationQueryResponseDto;", + "pub(crate) use self::dto_observation::StoreTransactionObservationRowDto;", + ] { + assert!(lib.contains(expected), "missing pre.010 crate-root observation DTO re-export: {expected}"); + } + assert!(dto.contains("crate::StoreObservationProvenanceDto")); + assert!(runtime.contains("ksp_store_lib::RawTransactionObservationInspectionRead::inspect_raw_transaction_observations")); + assert!(runtime.contains("ksp_store_lib::RawAccountObservationInspectionRead::inspect_raw_account_observations")); + assert!(app_state.contains("request: crate::StoreTransactionObservationQueryRequestDto")); + assert!(app_state.contains("request: crate::StoreAccountObservationQueryRequestDto")); + assert!(tauri.contains("request: crate::StoreTransactionObservationQueryRequestDto")); + assert!(tauri.contains("request: crate::StoreAccountObservationQueryRequestDto")); + let sources = [runtime, app_state, tauri].join("\n"); + for forbidden in ["ksp_store_api", "ksp_store_postgres", "tokio_postgres", "deadpool_postgres", "SELECT ", "OFFSET "] { + assert!(!sources.contains(forbidden), "Store Desk observation UI crossed physical/backend-neutral boundary: {forbidden}"); + } +} diff --git a/crates/ksp-app-store-desk/tests/desktop_contract.rs b/crates/ksp-app-store-desk/tests/desktop_contract.rs index 1b6d645..89ca664 100644 --- a/crates/ksp-app-store-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-store-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-store-desk/tests/desktop_contract.rs -// version: 8 +// version: 9 //! Desktop template and frontend contract tests for the Store Desk scaffold. @@ -272,3 +272,58 @@ fn pre_008_account_datatable_is_server_side_copyable_and_uses_datatables_as_only assert!(!html.contains(forbidden), "second Store pager marker present: {forbidden}"); } } + +#[test] +fn pre_010_observations_are_server_side_inside_entity_details_with_datatables_as_only_pager() { + let html = include_str!("../frontend/main.html"); + let frontend = include_str!("../frontend/ts/main.ts"); + let tauri = include_str!("../src/tauri.rs"); + let runtime = include_str!("../src/store_runtime.rs"); + for expected in [ + "id=\"transactionObservationsTable\"", + "id=\"accountObservationsTable\"", + "id=\"transactionObservationsError\"", + "id=\"accountObservationsError\"", + ] { + assert!(html.contains(expected), "missing pre.010 observation UI contract: {expected}"); + } + assert!(frontend.contains("store_query_transaction_observations")); + assert!(frontend.contains("store_query_account_observations")); + assert!(frontend.contains("initializeTransactionObservationTable")); + assert!(frontend.contains("initializeAccountObservationTable")); + assert!(frontend.contains("currentTransactionObservationSignature")); + assert!(frontend.contains("currentAccountObservationIdentity")); + assert!(frontend.contains("stale transaction observation response ignored")); + assert!(frontend.contains("stale account observation response ignored")); + assert!(tauri.contains("store_query_transaction_observations")); + assert!(tauri.contains("store_query_account_observations")); + assert!(runtime.contains("RawSortDirection::Descending")); + assert!(!html.contains("store-observation-pager")); + assert!(!frontend.contains("cursor:")); +} + +#[test] +fn pre_010_observation_rows_project_safe_provenance_and_reuse_long_text_copy_without_source_bytes() { + let dto = include_str!("../src/dto_observation.rs"); + let frontend = include_str!("../frontend/ts/main.ts"); + for expected in [ + "source_payload_hash", + "source_payload_size_decimal", + "received_at_unix_millis_decimal", + "observed_at_unix_millis_decimal", + "capture_session_id", + "endpoint_id", + "filter_id", + "transaction_signature", + "write_version_decimal", + ] { + assert!(dto.contains(expected), "missing safe observation metadata field: {expected}"); + } + for forbidden in ["source_payload_bytes", "payload_preview", "data_preview", "raw_bytes"] { + assert!(!dto.contains(forbidden), "observation DTO leaked forbidden bytes field: {forbidden}"); + } + assert!(frontend.contains("transaction-observation-key")); + assert!(frontend.contains("account-observation-key")); + assert!(frontend.contains("account-observation-transaction-signature")); + assert!(frontend.contains("renderCopyableLongText")); +} diff --git a/crates/ksp-app-store-desk/tests/desktop_security.rs b/crates/ksp-app-store-desk/tests/desktop_security.rs index 406fa48..4a4ac34 100644 --- a/crates/ksp-app-store-desk/tests/desktop_security.rs +++ b/crates/ksp-app-store-desk/tests/desktop_security.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-store-desk/tests/desktop_security.rs -// version: 9 +// version: 10 //! Security boundary tests for the Store Desk scaffold. @@ -230,3 +230,37 @@ fn pre_008_account_copy_tracing_never_logs_identity_or_data_material() { assert!(!frontend.contains(forbidden), "account copy tracing leaked copied value material: {forbidden}"); } } + +#[test] +fn pre_010_observation_ipc_and_tracing_keep_entity_values_and_source_bytes_out_of_logs() { + let dto = include_str!("../src/dto_observation.rs"); + let frontend = include_str!("../frontend/ts/main.ts"); + let tauri = include_str!("../src/tauri.rs"); + for forbidden in ["draw:", "search:", "order:", "columns:", "cursor:", "network:"] { + assert!(!dto.contains(forbidden), "observation IPC request leaked DataTables/physical field: {forbidden}"); + } + for forbidden in ["source_payload_bytes", "payload_preview", "data_preview", "raw_bytes"] { + assert!(!dto.contains(forbidden), "observation IPC leaked raw/source bytes: {forbidden}"); + } + assert!(frontend.contains(r#"Store Desk transaction observation DataTables server-side query started", { pageLength: request.limit }"#)); + assert!(frontend.contains(r#"Store Desk account observation DataTables server-side query started", { pageLength: request.limit }"#)); + assert!(!frontend.contains(r#"query started", { signature"#)); + assert!(!frontend.contains(r#"query started", { pubkey"#)); + assert!(!frontend.contains(r#"query completed", { observationKey"#)); + assert!(tauri.contains("project_command_error("store_query_transaction_observations"")); + assert!(tauri.contains("project_command_error("store_query_account_observations"")); +} + +#[test] +fn pre_010_observation_page_hardening_rejects_hostile_draw_length_and_unsafe_exact_counts() { + let frontend = include_str!("../frontend/ts/main.ts"); + let runtime = include_str!("../src/store_runtime.rs"); + assert!(frontend.contains("Number.isSafeInteger(page.draw)")); + assert!(frontend.contains("Number.isSafeInteger(page.start)")); + assert!(frontend.contains("[25, 50, 100].includes(page.length)")); + assert!(frontend.contains("Number.MAX_SAFE_INTEGER")); + assert!(frontend.contains("exact count exceeds frontend safe integer domain")); + assert!(runtime.contains("inspection_page_from_request")); + assert!(runtime.contains("request.offset.as_str(), request.limit")); + assert!(runtime.contains("offset_text.parse::()")); +} diff --git a/crates/ksp-app-store-desk/unit_tests/dto_observation.rs b/crates/ksp-app-store-desk/unit_tests/dto_observation.rs new file mode 100644 index 0000000..2258875 --- /dev/null +++ b/crates/ksp-app-store-desk/unit_tests/dto_observation.rs @@ -0,0 +1,47 @@ +// file: crates/ksp-app-store-desk/unit_tests/dto_observation.rs +// version: 1 + +//! Unit tests for Store Desk observation DTO safety and request boundaries. + +#[test] +fn pre_010_observation_requests_expose_only_random_access_and_exact_entity_identity() { + let source = include_str!("../src/dto_observation.rs"); + for forbidden in ["draw:", "search:", "order:", "columns:", "cursor:", "network:", "payload_preview", "data_preview"] { + assert!(!source.contains(forbidden), "observation request/row contract leaked forbidden field: {forbidden}"); + } +} + +#[test] +fn pre_010_observation_rows_keep_source_bytes_out_and_preserve_safe_provenance_metadata() { + let provenance = crate::StoreObservationProvenanceDto { + acquisition_method: "subscribe".to_owned(), + capture_session_id: std::option::Option::Some("session-1".to_owned()), + commitment: std::option::Option::Some("confirmed".to_owned()), + endpoint_id: std::option::Option::Some("mainnet-primary".to_owned()), + filter_id: std::option::Option::Some("wallet-filter".to_owned()), + observed_at_unix_millis_decimal: std::option::Option::Some("10".to_owned()), + origin: "live".to_owned(), + protocol: "yellowstone-grpc".to_owned(), + provider: "publicnode".to_owned(), + received_at_unix_millis_decimal: "11".to_owned(), + source_payload_hash: std::option::Option::Some("12".repeat(32)), + source_payload_size_decimal: std::option::Option::Some("2048".to_owned()), + }; + let row = crate::StoreAccountObservationRowDto { + is_startup: std::option::Option::Some(false), + observation_key: "34".repeat(32), + provenance, + transaction_signature: std::option::Option::Some("56".repeat(64)), + write_version_decimal: std::option::Option::Some("9".to_owned()), + }; + let json = serde_json::to_string(&row); + let json = match json { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("observation DTO serialization failed: {error}"), + }; + assert!(json.contains("sourcePayloadHash")); + assert!(json.contains("sourcePayloadSizeDecimal")); + for forbidden in ["payloadPreview", "dataPreview", "rawBytes", "sourcePayloadBytes"] { + assert!(!json.contains(forbidden), "observation DTO serialized forbidden source material: {forbidden}"); + } +} diff --git a/deltas/0.3.8/pre.010.md b/deltas/0.3.8/pre.010.md new file mode 100644 index 0000000..022c2be --- /dev/null +++ b/deltas/0.3.8/pre.010.md @@ -0,0 +1,52 @@ + + + +# Delta `0.3.8-pre.010` + +## Base + +- livraison directe : `0.3.8-pre.009-fix.001` ; +- workspace : `0.3.8-pre.9.fix.1` ; +- gate opérateur précédent : entièrement vert, sans warning Clippy. + +## Objet + +Intégrer l'inspection des observations RAW dans les détails Store Desk et durcir le contrat DataTables/IPC V1, sans modification Store API/PostgreSQL/schema. + +## Changements + +- ajout de `dto_observation.rs` et de ses tests ; +- deux queries IPC metadata-only : Transaction observations et Account observations ; +- deux DataTables server-side dans les modals existants ; +- provenance sûre complète sans source payload bytes ; +- metadata Account optionnelles (`is_startup`, `write_version`, transaction signature) ; +- troncature/tooltip/copie réutilisée pour les chaînes longues ; +- validation commune Rust des offset/limit, whitelist 25/50/100 ; +- hardening draw/start/length, counts JS sûrs et réponses stale ; +- règle `pub`/`pub(crate)` : reexports crate-root et accès `crate::Item`. + +## Hors périmètre + +- aucune migration/index/schema ; +- aucun SQL PostgreSQL modifié ; +- aucune capability Store supplémentaire ; +- aucun payload/source byte dans les rows Observation ; +- aucun search/order DataTables arbitraire ; +- aucun cursor/pager applicatif. + +## Gate recommandé + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas + +cargo check --workspace +cargo clippy --workspace --all-targets + +cargo test -p ksp-app-store-desk +cargo test -p ksp-store-lib +cargo check -p ksp-store-lib --no-default-features +``` + +Smoke visuel recommandé : `cargo tauri dev`, ouverture d'un détail Transaction puis Account, pagination des observations, chaînes longues/copie, fermeture/réouverture rapide pour vérifier l'absence de réponse stale. diff --git a/docs/plans/029-V0_3_8_STORE_DESK_PLAN.md b/docs/plans/029-V0_3_8_STORE_DESK_PLAN.md index 99b28e0..ba51c93 100644 --- a/docs/plans/029-V0_3_8_STORE_DESK_PLAN.md +++ b/docs/plans/029-V0_3_8_STORE_DESK_PLAN.md @@ -1,5 +1,5 @@ - + # Plan v0.3.8 — Store Desk V1 RAW @@ -614,7 +614,11 @@ Les summaries d'observation restent strictement sans bytes RAW : clé d'observat ### pre.010 — observations UI + hardening UX -Intégrer provenance sûre dans les détails/tables retenues, aucune source payload byte, puis hardening des draws, counts, filtres, gros offsets et erreurs. +Intégrer les observations dans les modals de détail Transaction et Account via deux DataTables server-side dédiées, filtrées implicitement par l'identité exacte de l'entité ouverte. Les tables utilisent exclusivement les capabilities `RawTransactionObservationInspectionRead` / `RawAccountObservationInspectionRead` livrées en `pre.009`, avec ordre `descending` fixé côté application, `start/length -> offset/limit`, whitelist 25/50/100, counts exacts et DataTables comme seul propriétaire de pagination. Aucun cursor, network, search globale ou ordre DataTables arbitraire ne traverse l'IPC. + +Les rows Observation projettent uniquement `RawAcquisitionProvenance` sûre : provider, protocol, acquisition method, origin, received/observed timestamps, endpoint/session/filter/commitment optionnels et hash/taille source. Account ajoute uniquement les metadata déjà admises (`is_startup`, `write_version`, transaction signature associée). Aucun source payload byte, payload RAW Transaction ni data Account n'est exposé. Les identifiants longs réutilisent le helper troncature/tooltip/copie sans valeur dans le tracing. + +Le hardening V1 centralise la validation offset/limit côté Rust, rejette les draw/start/length hostiles avant IPC, refuse les counts dépassant `Number.MAX_SAFE_INTEGER`, ignore les réponses Observation devenues stale après changement/fermeture d'entité, ajuste les colonnes à l'ouverture du modal et conserve les erreurs d'observation dans une surface dédiée sans donnée métier. ### pre.011 — security / dependency / release completeness diff --git a/docs/validation/025-V0_3_8_STORE_DESK.md b/docs/validation/025-V0_3_8_STORE_DESK.md index a5dcdc5..2c5945c 100644 --- a/docs/validation/025-V0_3_8_STORE_DESK.md +++ b/docs/validation/025-V0_3_8_STORE_DESK.md @@ -1,5 +1,5 @@ - + # Validation v0.3.8 — Store Desk V1 RAW @@ -748,3 +748,15 @@ Le gate opérateur de `0.3.8-pre.9` confirme que les audits KSP, `cargo check -- - [X] la version workspace devient `0.3.8-pre.9.fix.1` car du code Rust de production et un test Rust sont modifiés. Le gate Cargo du fix reste à rejouer après application du delta. + +## 34. `pre.010` — observations UI + hardening UX + +Base directe : `0.3.8-pre.009-fix.001`, gate opérateur entièrement vert (`cargo check --workspace`, Clippy sans warning, Store API, PostgreSQL, Store façade et `--no-default-features`). + +La tranche `pre.010` ajoute uniquement l'intégration application/UI des capabilities Observation existantes : deux DTO server-side backend-neutres, deux commands Tauri, deux chemins `StoreStartup`/`StoreRuntime` et deux DataTables intégrées aux détails Transaction/Account. Les observations sont filtrées par l'identité exacte déjà sélectionnée, en direction descending fixe. Aucun schéma, migration, SQL backend, Store API ou Store façade n'est modifié. + +Les DTO Observation exposent seulement provenance sûre et metadata optionnelles Account ; aucun byte RAW/source n'est présent. Tous les nouveaux items `pub(crate)` sont réexportés via `lib.rs` et consommés avec `crate::Item`. Les chaînes longues utilisent le helper copyable existant et le tracing ne transporte ni signature, pubkey, state hash, observation key, source hash ni autre valeur copiée. + +Hardening ajouté : validation Rust commune `inspection_page_from_request`, whitelist 25/50/100, offset `u64`, draw/start/length frontend contrôlés, counts décimaux refusés au-delà du domaine JS sûr, réponses Observation stale ignorées lors d'un changement d'entité, erreurs de query séparées dans chaque modal et ajustement DataTables au `shown.bs.modal`. + +Validation d'assemblage assistant : audits Rust/exports/Markdown, syntaxe TypeScript/HTML, inventaire DTO/commands/Store paths et reconstruction delta. Les gates Cargo restent à exécuter par l'opérateur.