From 1293046bd904c1c34213095def99bc8a74f549da Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Fri, 21 Aug 2026 20:18:49 +0200 Subject: [PATCH] feat(documents): preview shielded sign-off inside the Documents section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embeds the demo vault as a working preview at /wallets/[wallet]/documents/preview, reached from a "Shielded sign-off" button in the Documents header — linked rather than left to be guessed at, since this section has already shipped unreachable once. The content is Mesh Multisig's OWN feature vault, not the wallet's documents, and the panel says so in the first sentence. A demo that let someone believe they were looking at their own records would be worse than no demo. What it shows honestly is the mechanism: selecting a note reveals the path a proof of it would disclose, and the documents it would keep sealed as hashes. Reuses VaultBrowser exactly as /vault does — it takes one `view` prop and owns its state — so there is one browser implementation rather than a second that drifts. TWO THINGS WORTH REVIEWING - loadVaultTrustView throws when the vault is not a DAG. That is right for a build-time check and wrong on a treasury page: an unrelated Markdown edit must not turn Documents into a 500. The loader is wrapped and degrades to hiding the panel. A CI test already asserts the vault IS a DAG, so the branch should stay unreachable. - The loading lives in src/lib/documents/vault-preview.ts rather than in the page, matching the thin-shell shape of every other page under documents/. It also makes the behaviour testable at all: importing the page pulls the whole component tree, including ESM-only react-markdown, which the CJS jest project cannot parse. Tests cover both paths, including that the props survive Next's JSON serialisation — a Map, Set or Date in getServerSideProps props is a runtime "Error serializing" on the deployed page that no type check catches. Verified against a real build: the route returns 200 and ships the view (10 hubs, 62 notes, 52 trust edges). 1141 tests pass; tsc clean; next build exit 0. Co-Authored-By: Claude Opus 5 --- src/__tests__/documentsVaultPreview.test.ts | 57 ++++++++++++ .../pages/wallet/documents/index.tsx | 23 +++-- .../pages/wallet/documents/vault-preview.tsx | 92 +++++++++++++++++++ src/lib/documents/vault-preview.ts | 30 ++++++ .../wallets/[wallet]/documents/preview.tsx | 19 ++++ 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/documentsVaultPreview.test.ts create mode 100644 src/components/pages/wallet/documents/vault-preview.tsx create mode 100644 src/lib/documents/vault-preview.ts create mode 100644 src/pages/wallets/[wallet]/documents/preview.tsx diff --git a/src/__tests__/documentsVaultPreview.test.ts b/src/__tests__/documentsVaultPreview.test.ts new file mode 100644 index 00000000..d5312bd3 --- /dev/null +++ b/src/__tests__/documentsVaultPreview.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; + +import type { VaultTrustView } from "@/lib/vault-trust-types"; + +type Props = { props: { view: VaultTrustView | null } }; + +const MODULE = "@/lib/documents/vault-preview"; + +/** + * The preview embeds the demo vault inside a wallet page, which puts a + * filesystem read and a hash graph on the request path of a treasury surface. + * Two things must hold: the props Next hands the client are actually + * serialisable, and a vault that fails to build hides the panel instead of + * taking the page down. + */ +describe("documents vault preview page", () => { + afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + }); + + it("supplies a view that survives Next's JSON serialisation", async () => { + const { loadVaultPreviewProps } = await import(MODULE); + const result = { props: loadVaultPreviewProps() } as Props; + + expect(result.props.view).not.toBeNull(); + expect(result.props.view!.hubs.length).toBeGreaterThan(0); + expect(result.props.view!.notes.length).toBeGreaterThan(0); + expect(result.props.view!.rootHash).toMatch(/^[0-9a-f]{64}$/); + + // getServerSideProps props are JSON-serialised. A Map, Set, Date or + // undefined in there is a runtime "Error serializing" on the deployed page + // that no type check catches, so assert the round trip is lossless. + const roundTripped: unknown = JSON.parse(JSON.stringify(result.props.view)); + expect(roundTripped).toEqual(result.props.view); + }); + + it("hides the panel instead of 500-ing when the vault will not build", async () => { + jest.resetModules(); + jest.doMock("@/lib/vault-trust", () => ({ + loadVaultTrustView: () => { + throw new Error("vault trust graph: Trust cycle: A -> B -> A"); + }, + })); + const errors = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + const { loadVaultPreviewProps } = await import(MODULE); + const result = { props: loadVaultPreviewProps() } as Props; + + // Degraded, not thrown: a Markdown edit in an unrelated directory must not + // be able to take down a wallet's Documents section. + expect(result.props.view).toBeNull(); + expect(errors).toHaveBeenCalled(); + }); +}); diff --git a/src/components/pages/wallet/documents/index.tsx b/src/components/pages/wallet/documents/index.tsx index 61d430d3..45a0f792 100644 --- a/src/components/pages/wallet/documents/index.tsx +++ b/src/components/pages/wallet/documents/index.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { useRouter } from "next/router"; -import { FileSignature, Plus } from "lucide-react"; +import { FileSignature, Plus, Shield } from "lucide-react"; import { api } from "@/utils/api"; import useAppWallet from "@/hooks/useAppWallet"; @@ -30,6 +30,12 @@ export default function PageDocuments() { return (
+ } /> @@ -90,7 +98,10 @@ export default function PageDocuments() { <> v{latest.versionNumber} {required !== undefined && ( - <> · {approvals}/{required} approvals + <> + {" "} + · {approvals}/{required} approvals + )} ) : ( diff --git a/src/components/pages/wallet/documents/vault-preview.tsx b/src/components/pages/wallet/documents/vault-preview.tsx new file mode 100644 index 00000000..6816b46e --- /dev/null +++ b/src/components/pages/wallet/documents/vault-preview.tsx @@ -0,0 +1,92 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { FileSignature, Shield } from "lucide-react"; + +import VaultBrowser from "@/components/pages/vault/browser"; +import { EmptyState } from "@/components/common/empty-state"; +import PageHeader from "@/components/ui/page-header"; +import { Button } from "@/components/ui/button"; +import type { VaultTrustView } from "@/lib/vault-trust-types"; + +/** + * Shielded sign-off, shown inside Documents as a working preview. + * + * The content is Mesh Multisig's OWN feature vault, not this wallet's documents, + * and the copy says so plainly — a demo that lets someone believe they are + * looking at their own records would be worse than no demo. What it does show + * honestly is the mechanism they would get: a vault of linked Markdown where + * every trust edge commits to a hash, so a single document can be proved to + * belong without revealing the ones beside it. + * + * This reuses `VaultBrowser` exactly as the public /vault page does — it takes + * one `view` prop and owns all its state — so there is one implementation of + * the browser, not a second one that drifts. + */ +export default function PageDocumentsVaultPreview({ + view, +}: { + view: VaultTrustView | null; +}) { + const router = useRouter(); + const walletId = router.query.wallet as string; + + return ( +
+ + + + +
+

+ + + + This is Mesh Multisig's own feature vault, not your + documents. + {" "} + It is real content with real hashes, shown so you can see how + shielded sign-off behaves before it holds anything of yours. + +

+

+ Today a document is signed on its own: one file, one hash, one round + of approvals. A vault adds the relationships between them. Every trust + edge commits to the hash of what it points at, so signing the root + binds the whole structure underneath it. +

+

+ That is what makes selective disclosure possible. Pick any note and + the panel on the right shows the path a proof of it would reveal — and + the documents it would keep sealed, present only as hashes. A + counterparty can verify that one policy belongs to your governance + spine without learning what else is in it. +

+
+ + {view ? ( + + ) : ( + + + Back to documents + + + } + /> + )} +
+ ); +} diff --git a/src/lib/documents/vault-preview.ts b/src/lib/documents/vault-preview.ts new file mode 100644 index 00000000..168627db --- /dev/null +++ b/src/lib/documents/vault-preview.ts @@ -0,0 +1,30 @@ +import { loadVaultTrustView } from "@/lib/vault-trust"; +import type { VaultTrustView } from "@/lib/vault-trust-types"; + +/** + * Props for the shielded sign-off preview inside a wallet's Documents section. + * + * Server-only: `loadVaultTrustView` reads the filesystem, so this must never be + * imported from a client component. + * + * It lives here rather than inline in the page for the same reason every other + * page under `documents/` is a thin shell — and because the behaviour below is + * worth testing on its own, which importing the page cannot do: the page pulls + * the whole component tree, including ESM-only `react-markdown`. + */ +export function loadVaultPreviewProps(): { view: VaultTrustView | null } { + try { + return { view: loadVaultTrustView() }; + } catch (error) { + // `loadVaultTrustView` throws when the vault's trust edges are not a DAG. + // That is right for a build-time check and wrong here: this panel is + // illustrative content on a treasury page, and an unrelated Markdown edit + // must not be able to turn Documents into a 500. A CI test asserts the + // vault IS a DAG, so this branch should stay unreachable — and if it ever + // is reached, hiding one panel beats taking the page down. + console.error("vault preview unavailable", { + message: (error as Error)?.message, + }); + return { view: null }; + } +} diff --git a/src/pages/wallets/[wallet]/documents/preview.tsx b/src/pages/wallets/[wallet]/documents/preview.tsx new file mode 100644 index 00000000..25b02806 --- /dev/null +++ b/src/pages/wallets/[wallet]/documents/preview.tsx @@ -0,0 +1,19 @@ +import type { InferGetServerSidePropsType } from "next"; + +import PageDocumentsVaultPreview from "@/components/pages/wallet/documents/vault-preview"; +import { loadVaultPreviewProps } from "@/lib/documents/vault-preview"; + +/** + * Shielded sign-off, previewed inside the wallet's Documents section. + * + * `getServerSideProps` rather than static, for the reason /vault and + * /roadmap/graph both use it: prerendering this SPA dies with "NextRouter was + * not mounted", which passes locally and breaks the deployed build. + */ +export const getServerSideProps = () => ({ props: loadVaultPreviewProps() }); + +export default function PageWalletDocumentsPreview({ + view, +}: InferGetServerSidePropsType) { + return ; +}