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 ; +}