From e2d21b789c6a9ced520ea018dbaa4f1bd95a5fc8 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Fri, 7 Aug 2026 09:20:52 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs(roadmap):=20MRP=20task=20mapping,=20Ap?= =?UTF-8?q?ril=E2=80=93July=20delivery=20window,=20drop=20team/hours=20lin?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month headings were renumbered on 2026-08-03 (984aa46) so that Month 1 = April, but MRP task cards created before that date still carry bullet text for the following month — the card headed "MRP Month 2" lists the June workstreams. That mismatch has now cost enough time to be worth writing down. - Add an MRP task mapping table at the top: MRP Month N = roadmap Month N = calendar month, for all twelve months, with the on-chain task hashes we have and a note explaining why the cards disagree. - Route each MRP month to the actual merged PRs behind it (April 10, May 3, June 51, July 16), each count linking to the exact GitHub search so any row can be reproduced rather than taken on trust. - Widen "Delivered to date" from May–July to April–July. April's output is infrastructure, so it folds into the existing sections: the preprod environment and real-chain smoke CI (#218, #217) under Testing & CI, and a new transaction-and-signing-integrity through-line under Platform (#217 VKey witness filtering -> #227 invalid-CBOR guard -> #257 Mesh pin + witness verification guard). - Recover two May items the 2026-07-26 audit had missed: the Import Wallet wizard (#259) and the #257 signing fix. - Fix the timeline left stale by the renumbering: April 2026 – March 2027, in ROADMAP.md, the /roadmap page and the SEO description. - Remove the "Quirin + Andre · ~25 h/wk" line from ROADMAP.md and the public roadmap page. Co-Authored-By: Claude Opus 5 --- ROADMAP.md | 61 ++++++++++++++++--- src/components/pages/homepage/roadmap/data.ts | 2 +- .../pages/homepage/roadmap/index.tsx | 4 +- src/lib/seo.ts | 2 +- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5f3df03f..57b145af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,11 +1,51 @@ # 12-Month Roadmap: Mesh Multi-Sig Wallet -**Timeline:** May 2026 - April 2027 -**Team:** Quirin + Andre, part-time (~25 hrs/week combined), feature-based ownership +**Timeline:** April 2026 – March 2027 **Approach:** Month-by-month cadence combining baseline maintenance with feature delivery. No hard requirements for feature delivery or releases — tasks scale up/down based on project activity. --- +## MRP task mapping + +The authoritative mapping between MRP reward tasks and the months below. **Month N of this roadmap = MRP Month N = the calendar month in the same row.** Use this table whenever an MRP task and a roadmap section appear to disagree. + +| MRP task | Calendar month | Roadmap section | On-chain task hash | +|---|---|---|---| +| MRP Month 1 | April 2026 | [Month 1](#month-1--april-2026) | — | +| MRP Month 2 | May 2026 | [Month 2](#month-2--may-2026) | `02e1e7c8…65256f` | +| MRP Month 3 | June 2026 | [Month 3](#month-3--june-2026) | `a833f41c…91cef8` | +| MRP Month 4 | July 2026 | [Month 4](#month-4--july-2026) | `27034bf3…dd219a` | +| MRP Month 5 | August 2026 | [Month 5](#month-5--august-2026) | — | +| MRP Month 6 | September 2026 | [Month 6](#month-6--september-2026) | — | +| MRP Month 7 | October 2026 | [Month 7](#month-7--october-2026) | — | +| MRP Month 8 | November 2026 | [Month 8](#month-8--november-2026) | — | +| MRP Month 9 | December 2026 | [Month 9](#month-9--december-2026) | — | +| MRP Month 10 | January 2027 | [Month 10](#month-10--january-2027) | — | +| MRP Month 11 | February 2027 | [Month 11](#month-11--february-2027) | — | +| MRP Month 12 | March 2027 | [Month 12](#month-12--march-2027) | — | + +> **Why this table exists.** The month headings were renumbered on 2026-08-03 (`984aa46`) to match actual delivery: the original numbering started at "Month 1 — May 2026" while Month 1's own proof-of-completion table documented April work. MRP task cards created before that date therefore carry bullet text describing the **following** month — e.g. the card headed *MRP Month 2* lists the June workstreams. The table above is what governs; a card's bullet text does not. + +### Underlying PRs per MRP month + +Each MRP month resolves to a concrete, reproducible set of merged pull requests. The "all merged PRs" link runs the exact GitHub search; the counts are Quirin's authored merges in that window. + +| MRP month | Merged PRs (Quirin) | The actual PRs | +|---|---|---| +| **M1 — April 2026** | [10](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-04-01..2026-04-30) | [#215](https://github.com/MeshJS/multisig/pull/215) drep prerender fix · [#216](https://github.com/MeshJS/multisig/pull/216) missing User table on startup · [#217](https://github.com/MeshJS/multisig/pull/217) VKey witness filter + CI smoke system · [#218](https://github.com/MeshJS/multisig/pull/218) preprod environment · [#219](https://github.com/MeshJS/multisig/pull/219)/[#222](https://github.com/MeshJS/multisig/pull/222)/[#224](https://github.com/MeshJS/multisig/pull/224)/[#226](https://github.com/MeshJS/multisig/pull/226) 12-month roadmap + contributing guide · [#227](https://github.com/MeshJS/multisig/pull/227) invalid-CBOR guard in `addTransaction` · [#228](https://github.com/MeshJS/multisig/pull/228) M1 proof of completion | +| **M2 — May 2026** | [3](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-05-01..2026-05-31) | [#257](https://github.com/MeshJS/multisig/pull/257) pin Mesh SDK + reject witnesses that don't verify against the tx body · [#259](https://github.com/MeshJS/multisig/pull/259) Import Wallet wizard · [#260](https://github.com/MeshJS/multisig/pull/260) `main`→`preprod` merge clearing #229 + CodeQL fixes | +| **M3 — June 2026** | [51](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-06-01..2026-06-30) | Governance [#271](https://github.com/MeshJS/multisig/pull/271)–[#272](https://github.com/MeshJS/multisig/pull/272), [#279](https://github.com/MeshJS/multisig/pull/279), [#286](https://github.com/MeshJS/multisig/pull/286), [#296](https://github.com/MeshJS/multisig/pull/296)–[#297](https://github.com/MeshJS/multisig/pull/297), [#300](https://github.com/MeshJS/multisig/pull/300), [#302](https://github.com/MeshJS/multisig/pull/302), [#315](https://github.com/MeshJS/multisig/pull/315) · Signing & auth [#273](https://github.com/MeshJS/multisig/pull/273)–[#277](https://github.com/MeshJS/multisig/pull/277), [#281](https://github.com/MeshJS/multisig/pull/281)–[#282](https://github.com/MeshJS/multisig/pull/282), [#324](https://github.com/MeshJS/multisig/pull/324) · Mesh 2.0 groundwork [#229](https://github.com/MeshJS/multisig/pull/229), [#269](https://github.com/MeshJS/multisig/pull/269), [#278](https://github.com/MeshJS/multisig/pull/278) · Mobile & UX [#287](https://github.com/MeshJS/multisig/pull/287)–[#295](https://github.com/MeshJS/multisig/pull/295) · Landing/theme/SEO [#298](https://github.com/MeshJS/multisig/pull/298)–[#299](https://github.com/MeshJS/multisig/pull/299), [#308](https://github.com/MeshJS/multisig/pull/308)–[#318](https://github.com/MeshJS/multisig/pull/318), [#328](https://github.com/MeshJS/multisig/pull/328) · Infra & security [#284](https://github.com/MeshJS/multisig/pull/284), [#301](https://github.com/MeshJS/multisig/pull/301), [#319](https://github.com/MeshJS/multisig/pull/319) · Docs & releases [#280](https://github.com/MeshJS/multisig/pull/280), [#283](https://github.com/MeshJS/multisig/pull/283), [#285](https://github.com/MeshJS/multisig/pull/285), [#303](https://github.com/MeshJS/multisig/pull/303), [#309](https://github.com/MeshJS/multisig/pull/309), [#320](https://github.com/MeshJS/multisig/pull/320)–[#321](https://github.com/MeshJS/multisig/pull/321) | +| **M4 — July 2026** | [16](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-07-01..2026-07-31) | Bot platform & API [#341](https://github.com/MeshJS/multisig/pull/341)–[#345](https://github.com/MeshJS/multisig/pull/345) · Agent/crawler surface [#346](https://github.com/MeshJS/multisig/pull/346) · DRep vote-history explorer [#337](https://github.com/MeshJS/multisig/pull/337)–[#339](https://github.com/MeshJS/multisig/pull/339) · Roadmap & delivery audit [#347](https://github.com/MeshJS/multisig/pull/347), [#350](https://github.com/MeshJS/multisig/pull/350)–[#352](https://github.com/MeshJS/multisig/pull/352) · Production hardening [#332](https://github.com/MeshJS/multisig/pull/332)–[#334](https://github.com/MeshJS/multisig/pull/334) | + +Reproduce any row: + +```bash +gh pr list --repo MeshJS/multisig --state merged --limit 100 \ + --search "merged:2026-06-01..2026-06-30 author:QSchlegel" --json number,title,mergedAt +``` + +--- + ## Baseline (applies every month) - Issues and PRs do not stall @@ -14,10 +54,12 @@ --- -## Delivered to date (May – July 2026) +## Delivered to date (April – July 2026) What the product can actually do today, as verified in the codebase on 2026-07-26. The per-month **Progress** tables below track plan-vs-actual; this section is the cumulative capability inventory, and it is the input that reshaped M4–M6. +Coverage starts at **April**, the programme's first month — April's output is infrastructure rather than user-facing features (the preprod environment, the real-chain smoke system, transaction-integrity guards), so it shows up inside the sections below rather than as a headline capability of its own. + > **Caveat — delivered ≠ live.** Everything below is merged on `preprod`. `main` is 75 commits behind and the production database is four migrations behind, so a good share of this is not yet reachable on the production deployment. Closing that gap is the first item in August. ### Governance @@ -46,11 +88,16 @@ Resend-backed email channel with a real outbox: `NotificationDelivery` carries a ### Testing & CI - **Playwright E2E**: 11 spec files, ~54 tests, in `e2e/tests/` — wallet creation (legacy + SDK), ring transfers on real preprod, staking, proxy, DRep/ballot UI, bot management, notification settings, wallet access control, signing rejection, responsive smoke. Runs in Docker via `pr-playwright-browser.yml`, serialized against the v1 smoke job through a shared `ci-preprod-wallets` concurrency group ([#323](https://github.com/MeshJS/multisig/pull/323), [#335](https://github.com/MeshJS/multisig/pull/335), [#336](https://github.com/MeshJS/multisig/pull/336)). -- Real-chain smoke system closed ([#213](https://github.com/MeshJS/multisig/issues/213)); deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)). +- **Preprod environment + real-chain smoke CI** — built in April: the `preprod` branch and environment ([#218](https://github.com/MeshJS/multisig/pull/218)) and the CI smoke-test system that exercises the route chain against real preprod ([#217](https://github.com/MeshJS/multisig/pull/217)), which skips gracefully when `SMOKE_*` secrets are absent. [#213](https://github.com/MeshJS/multisig/issues/213) closed once the first real run was linked. Everything since — the Playwright suite above included — runs on this foundation. +- deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)). ### Platform -Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul; on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)). +**Transaction & signing integrity** — the through-line from April onward: extraneous VKey witnesses filtered out of submitted transactions ([#217](https://github.com/MeshJS/multisig/pull/217)); an invalid-CBOR guard on `POST /api/v1/addTransaction` plus a degraded "unreadable transaction" card with Reject & Delete, so an API-poisoned row can no longer lock a wallet's UTxOs ([#227](https://github.com/MeshJS/multisig/pull/227), [#211](https://github.com/MeshJS/multisig/issues/211)); Mesh SDK pinned to exact versions after a lockfile patch drift changed Conway CBOR encoding and made multisig DRep votes fail on chain, with a client-side guard that now rejects witnesses which don't verify against the body they're attached to ([#257](https://github.com/MeshJS/multisig/pull/257)). + +**Wallet lifecycle** — Import Wallet wizard covering four sources (another multisig instance, Summon, native-script CBOR, JSON backup) with `importWallet`/`exportWallet` procedures, cross-instance endpoints reusing the CIP-8 `checkSignature` path, a downloadable JSON backup and a `lockedSigners` gate so imported wallets can't silently diverge from their origin ([#259](https://github.com/MeshJS/multisig/pull/259)); on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)). + +**Everything else** — Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul. ### Landed ahead of schedule @@ -163,7 +210,7 @@ End-of-month snapshot. Last updated 2026-07-26. | FROST research kickoff (#220) | Not started | Carried to August. Needs to start there to leave runway before the October go/no-go | | CI/maintenance baseline | Watch item — unchanged | `pr-multisig-v1-smoke.yml` still `exit 1`s in its "Validate required CI secrets" step when secrets are absent, and dependabot-triggered runs never receive repo Actions secrets. Every dependabot PR is therefore red for systemic reasons, not because of the version bump — 7 are open, the oldest since 2026-06-15. The sibling `ci-smoke-preprod.yml` already has the skip-when-unconfigured guard to copy | | Wallet V2 (#33) | Delivered | On-chain wallet registration + discovery shipped in [#340](https://github.com/MeshJS/multisig/pull/340) | -| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month — see [Delivered to date](#delivered-to-date-may--july-2026) | +| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month — see [Delivered to date](#delivered-to-date-april--july-2026) | --- @@ -171,7 +218,7 @@ End-of-month snapshot. Last updated 2026-07-26. **Focus:** Close the production release gap, then start Document Sign-Off (see [Flagship feature](#flagship-feature--document-sign-off)). -Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-may--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work. +Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-april--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work. **Quirin** diff --git a/src/components/pages/homepage/roadmap/data.ts b/src/components/pages/homepage/roadmap/data.ts index c2a975c9..59193cfa 100644 --- a/src/components/pages/homepage/roadmap/data.ts +++ b/src/components/pages/homepage/roadmap/data.ts @@ -398,7 +398,7 @@ export const STATS = [ { k: "Shipped", v: "6", - n: "workstreams delivered in May–July", + n: "workstreams delivered in April–July", tone: "good" as const, }, { diff --git a/src/components/pages/homepage/roadmap/index.tsx b/src/components/pages/homepage/roadmap/index.tsx index 632e908d..9b78f652 100644 --- a/src/components/pages/homepage/roadmap/index.tsx +++ b/src/components/pages/homepage/roadmap/index.tsx @@ -222,11 +222,11 @@ export function PageRoadmap() { Roadmap

- Twelve months of Mesh Multisig, May 2026 to April 2027 — what has + Twelve months of Mesh Multisig, April 2026 to March 2027 — what has shipped, what is blocked, and what comes next.

- Quirin + Andre · ~25 h/wk · revised 2026-07-26 + revised 2026-07-26

diff --git a/src/lib/seo.ts b/src/lib/seo.ts index ed57fe69..2a46c361 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -86,7 +86,7 @@ export const routeSeo: Record = { "/roadmap": { title: "Roadmap", description: - "The twelve-month Mesh Multisig roadmap: what has shipped, what is in progress and what is planned for the Cardano multi-signature wallet, from May 2026 to April 2027.", + "The twelve-month Mesh Multisig roadmap: what has shipped, what is in progress and what is planned for the Cardano multi-signature wallet, from April 2026 to March 2027.", }, "/roadmap/graph": { title: "Feature Graph", From a226429b1caba1156ccb1f4f02acecc3c1998c96 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Fri, 7 Aug 2026 09:21:08 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(documents):=20Document=20Sign-Off=20MV?= =?UTF-8?q?P=20=E2=80=94=20data=20model,=20routes,=20CIP-8=20sign-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PRD-001: a wallet-native, off-chain approval layer where a team binds approval to an exact content hash, inherits the wallet's signer set and threshold, and exports a proof anyone can verify without an account. Two rules carry the feature, and both are enforced server-side rather than in the UI: 1. Version-hash binding. submitSignerAction rebuilds the canonical signed payload from the server's own records and requires a byte-identical match *before* the signature is checked, so a signature collected for one version can never be replayed onto another, and a tampered comment invalidates the submission. Inline content is re-hashed server-side and rejected on mismatch. 2. Threshold inheritance from a frozen snapshot. DocumentSignerSnapshot captures the wallet's signers, threshold and policy hash when the round starts; approval counting reads the snapshot, never the live wallet, so changing wallet membership cannot rewrite a decision already made. Data model (5 models + migration 20260805090000): Document, DocumentVersion, DocumentReview, DocumentSignerSnapshot, DocumentEvent. The migration enables RLS with deny-all PostgREST policies on all five tables, matching the contract in 20260706100000_enable_rls_followup_tables (#332). Router (src/server/api/routers/documents.ts): createDocument, uploadVersion, startReview, submitSignerAction, exportProof, verifyProof, plus the reads the pages need. CIP-8 verification uses Mesh's checkSignature against the signer's address. verifyProof is public on purpose — a counterparty holding the JSON and the file must be able to check it without an account, and it touches no DB. Four routes under /wallets/[wallet]/documents: list, create, detail, and the version review page. Files are hashed in the browser (SHA-256 via WebCrypto); only the digest is sent, so the bytes never leave the signer's machine. src/lib/documents/ is dependency-free apart from node crypto, with the signature check injected into the verifier — the same code can run in an offline verifier with no Mesh install. Uploading a new version supersedes the previous one and starts a fresh round at zero approvals: approval is bound to the hash, not the title. Tests: 28 covering canonicalization, version-hash binding, threshold evaluation and proof verification including tampering, duplicate signers, out-of-snapshot signers and non-canonical payloads. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 190 ++++ prisma/schema.prisma | 179 +++- src/__tests__/documentSignoff.test.ts | 394 ++++++++ .../pages/wallet/documents/detail.tsx | 268 +++++ .../pages/wallet/documents/hash-file.ts | 17 + .../pages/wallet/documents/index.tsx | 108 ++ src/components/pages/wallet/documents/new.tsx | 170 ++++ .../pages/wallet/documents/review.tsx | 224 +++++ .../pages/wallet/documents/status-badge.tsx | 38 + src/lib/documents/payload.ts | 172 ++++ src/lib/documents/proof.ts | 296 ++++++ .../[wallet]/documents/[documentId]/index.tsx | 7 + .../[documentId]/review/[versionId].tsx | 7 + .../wallets/[wallet]/documents/index.tsx | 7 + src/pages/wallets/[wallet]/documents/new.tsx | 7 + src/server/api/root.ts | 2 + src/server/api/routers/documents.ts | 932 ++++++++++++++++++ 17 files changed, 2998 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20260805090000_add_document_signoff/migration.sql create mode 100644 src/__tests__/documentSignoff.test.ts create mode 100644 src/components/pages/wallet/documents/detail.tsx create mode 100644 src/components/pages/wallet/documents/hash-file.ts create mode 100644 src/components/pages/wallet/documents/index.tsx create mode 100644 src/components/pages/wallet/documents/new.tsx create mode 100644 src/components/pages/wallet/documents/review.tsx create mode 100644 src/components/pages/wallet/documents/status-badge.tsx create mode 100644 src/lib/documents/payload.ts create mode 100644 src/lib/documents/proof.ts create mode 100644 src/pages/wallets/[wallet]/documents/[documentId]/index.tsx create mode 100644 src/pages/wallets/[wallet]/documents/[documentId]/review/[versionId].tsx create mode 100644 src/pages/wallets/[wallet]/documents/index.tsx create mode 100644 src/pages/wallets/[wallet]/documents/new.tsx create mode 100644 src/server/api/routers/documents.ts diff --git a/prisma/migrations/20260805090000_add_document_signoff/migration.sql b/prisma/migrations/20260805090000_add_document_signoff/migration.sql new file mode 100644 index 00000000..4df95649 --- /dev/null +++ b/prisma/migrations/20260805090000_add_document_signoff/migration.sql @@ -0,0 +1,190 @@ +-- Document Sign-Off (PRD-001) — five-entity model. +-- +-- Approval binds to an exact content hash on a DocumentVersion, never to the +-- mutable Document container. DocumentSignerSnapshot freezes the wallet's +-- signer set + threshold at review start so later membership changes cannot +-- rewrite history. DocumentEvent is append-only and feeds the proof export. + +-- CreateEnum +CREATE TYPE "DocumentStatus" AS ENUM ('Draft', 'InReview', 'Approved', 'Rejected', 'Superseded', 'Archived'); + +-- CreateEnum +CREATE TYPE "DocumentReviewAction" AS ENUM ('approve', 'reject'); + +-- CreateEnum +CREATE TYPE "DocumentStorageMode" AS ENUM ('hashOnly', 'inline', 'external'); + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "documentType" TEXT, + "createdBy" TEXT NOT NULL, + "status" "DocumentStatus" NOT NULL DEFAULT 'Draft', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentVersion" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "versionNumber" INTEGER NOT NULL, + "contentHash" TEXT NOT NULL, + "hashAlgorithm" TEXT NOT NULL DEFAULT 'sha256', + "fileName" TEXT, + "mimeType" TEXT, + "fileSize" INTEGER, + "storageMode" "DocumentStorageMode" NOT NULL DEFAULT 'hashOnly', + "contentRef" TEXT, + "contentInline" TEXT, + "reviewInstructions" TEXT, + "status" "DocumentStatus" NOT NULL DEFAULT 'Draft', + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewStartedAt" TIMESTAMP(3), + "decidedAt" TIMESTAMP(3), + "supersededAt" TIMESTAMP(3), + + CONSTRAINT "DocumentVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentReview" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "action" "DocumentReviewAction" NOT NULL, + "comment" TEXT, + "payload" TEXT NOT NULL, + "signature" TEXT NOT NULL, + "signatureKey" TEXT NOT NULL, + "signedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentReview_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentSignerSnapshot" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signersAddresses" TEXT[], + "signersDescriptions" TEXT[], + "requiredSigners" INTEGER NOT NULL, + "walletPolicyHash" TEXT NOT NULL, + "capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentSignerSnapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentEvent" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "versionId" TEXT, + "type" TEXT NOT NULL, + "actorAddress" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Document_walletId_idx" ON "Document"("walletId"); + +-- CreateIndex +CREATE INDEX "Document_walletId_status_idx" ON "Document"("walletId", "status"); + +-- CreateIndex +CREATE INDEX "Document_createdBy_idx" ON "Document"("createdBy"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_documentId_idx" ON "DocumentVersion"("documentId"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_contentHash_idx" ON "DocumentVersion"("contentHash"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_status_idx" ON "DocumentVersion"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentVersion_documentId_versionNumber_key" ON "DocumentVersion"("documentId", "versionNumber"); + +-- CreateIndex +CREATE INDEX "DocumentReview_versionId_idx" ON "DocumentReview"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentReview_signerAddress_idx" ON "DocumentReview"("signerAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentReview_versionId_signerAddress_key" ON "DocumentReview"("versionId", "signerAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentSignerSnapshot_versionId_key" ON "DocumentSignerSnapshot"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentSignerSnapshot_walletId_idx" ON "DocumentSignerSnapshot"("walletId"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_documentId_createdAt_idx" ON "DocumentEvent"("documentId", "createdAt"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_versionId_idx" ON "DocumentEvent"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_type_idx" ON "DocumentEvent"("type"); + +-- AddForeignKey +ALTER TABLE "DocumentVersion" ADD CONSTRAINT "DocumentVersion_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentReview" ADD CONSTRAINT "DocumentReview_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentSignerSnapshot" ADD CONSTRAINT "DocumentSignerSnapshot_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentEvent" ADD CONSTRAINT "DocumentEvent_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentEvent" ADD CONSTRAINT "DocumentEvent_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Row Level Security — same contract as 20251215090000_enable_rls_disable_postgrest +-- and 20260706100000_enable_rls_followup_tables: RLS on unconditionally, deny-all +-- policies for the PostgREST roles when those roles exist. Prisma connects as the +-- table owner / service role and continues to bypass RLS. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY[ + 'Document', 'DocumentVersion', 'DocumentReview', + 'DocumentSignerSnapshot', 'DocumentEvent' + ]) + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END LOOP; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef9363b8..953ea69b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -245,26 +245,26 @@ model EmailVerificationToken { } model NotificationDelivery { - id String @id @default(cuid()) - eventType String - channel String - recipientAddress String - recipientEmail String? - resourceType String - resourceId String - walletId String? - idempotencyKey String @unique - subject String - payload Json - status String @default("pending") - provider String? - providerMessageId String? - attempts Int @default(0) - lastError String? - nextAttemptAt DateTime @default(now()) - sentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([status, nextAttemptAt]) @@index([recipientAddress]) @@ -389,3 +389,142 @@ model ProposalTally { @@unique([network, proposalId]) } + +// --------------------------------------------------------------------------- +// Document Sign-Off (PRD-001) — five-entity model. +// +// A wallet-native, off-chain approval layer. Approval binds to an exact +// content hash, never to the mutable document container, and inherits the +// wallet's signer set + threshold frozen at the moment a review round starts. +// No on-chain dependency in the MVP. +// --------------------------------------------------------------------------- + +enum DocumentStatus { + Draft + InReview + Approved + Rejected + Superseded + Archived +} + +enum DocumentReviewAction { + approve + reject +} + +// How the version's bytes are retained. The MVP never requires the bytes — +// the content hash is the binding — so `hashOnly` is the privacy-preserving +// default (see the vault's "Storage Privacy Risk"). +enum DocumentStorageMode { + hashOnly + inline + external +} + +// The stable container. Title/description may change; approval never attaches +// here, only to a DocumentVersion. +model Document { + id String @id @default(cuid()) + walletId String + title String + description String? + documentType String? + createdBy String // signer address that created it + status DocumentStatus @default(Draft) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + versions DocumentVersion[] + events DocumentEvent[] + + @@index([walletId]) + @@index([walletId, status]) + @@index([createdBy]) +} + +// Each version is its own approval object — this is what makes version-bound +// approval and approval-reset-on-new-version possible. +model DocumentVersion { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + versionNumber Int + contentHash String // lowercase hex digest of the exact bytes + hashAlgorithm String @default("sha256") + fileName String? + mimeType String? + fileSize Int? + storageMode DocumentStorageMode @default(hashOnly) + contentRef String? // external URI when storageMode = external + contentInline String? // base64 bytes when storageMode = inline + reviewInstructions String? + status DocumentStatus @default(Draft) + createdBy String + createdAt DateTime @default(now()) + reviewStartedAt DateTime? + decidedAt DateTime? // approved or rejected + supersededAt DateTime? + reviews DocumentReview[] + signerSnapshot DocumentSignerSnapshot? + events DocumentEvent[] + + @@unique([documentId, versionNumber]) + @@index([documentId]) + @@index([contentHash]) + @@index([status]) +} + +// One signer's action on one version, with the exact payload that was signed. +// Append-only: a signer gets one action per version, and a new version starts +// a fresh round at zero approvals. +model DocumentReview { + id String @id @default(cuid()) + versionId String + version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + signerAddress String + action DocumentReviewAction + comment String? + payload String // canonical JSON string that was signed (CIP-8 payload) + signature String // COSE_Sign1 hex + signatureKey String // COSE key hex + signedAt DateTime // client-asserted, server-validated against a window + createdAt DateTime @default(now()) + + @@unique([versionId, signerAddress]) + @@index([versionId]) + @@index([signerAddress]) +} + +// The signer set and threshold that applied when the round started. Frozen so +// later membership changes never rewrite history. +model DocumentSignerSnapshot { + id String @id @default(cuid()) + versionId String @unique + version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + walletId String + signersAddresses String[] + signersDescriptions String[] + requiredSigners Int + walletPolicyHash String // sha256 of the wallet's scriptCbor — binds the round to the policy + capturedAt DateTime @default(now()) + + @@index([walletId]) +} + +// Append-only audit log. Feeds the detail page history and the proof export. +model DocumentEvent { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + versionId String? + version DocumentVersion? @relation(fields: [versionId], references: [id], onDelete: Cascade) + type String // "document.created" | "version.uploaded" | "review.started" | ... + actorAddress String? + metadata Json? + createdAt DateTime @default(now()) + + @@index([documentId, createdAt]) + @@index([versionId]) + @@index([type]) +} diff --git a/src/__tests__/documentSignoff.test.ts b/src/__tests__/documentSignoff.test.ts new file mode 100644 index 00000000..113477a5 --- /dev/null +++ b/src/__tests__/documentSignoff.test.ts @@ -0,0 +1,394 @@ +/** + * Document Sign-Off (PRD-001) — payload binding, threshold, and proof verification. + * + * These cover the two rules the feature stands on: a signature is bound to one + * exact document version, and the threshold comes from the frozen signer + * snapshot. Both are enforced server-side, so both are tested server-side. + */ + +import { + SIGNOFF_DOMAIN, + SIGNOFF_STATEMENTS, + buildSignOffPayload, + canonicalize, + canonicalizeSignOffPayload, + evaluateThreshold, + isSha256Hex, + isSignedAtWithinTolerance, + sha256Hex, + walletPolicyHash, +} from "@/lib/documents/payload"; +import { + PROOF_FORMAT, + VERIFICATION_INSTRUCTIONS, + verifyProofPackage, + type ProofPackage, + type ProofReview, +} from "@/lib/documents/proof"; + +const SIGNER_A = "addr_test1_signer_a"; +const SIGNER_B = "addr_test1_signer_b"; +const SIGNER_C = "addr_test1_signer_c"; +const OUTSIDER = "addr_test1_outsider"; + +const CONTENT_HASH = sha256Hex("the budget, version 1"); +const OTHER_HASH = sha256Hex("the budget, version 2"); +const POLICY_HASH = walletPolicyHash("8200581c-script-cbor"); +const SIGNED_AT = "2026-08-05T09:00:00.000Z"; + +/** Accepts anything — isolates the non-signature checks. */ +const acceptAll = async () => true; +const rejectAll = async () => false; + +function makeReview( + signerAddress: string, + action: "approve" | "reject" = "approve", + overrides: Partial<{ contentHash: string; versionId: string; comment: string }> = {}, +): ProofReview { + const payload = buildSignOffPayload({ + action, + comment: overrides.comment, + contentHash: overrides.contentHash ?? CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress, + versionId: overrides.versionId ?? "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + return { + signerAddress, + action, + comment: overrides.comment ?? null, + payload: canonicalizeSignOffPayload(payload), + signature: "cose_sign1_hex", + signatureKey: "cose_key_hex", + signedAt: SIGNED_AT, + }; +} + +function makeProof(reviews: ProofReview[], requiredSigners = 2): ProofPackage { + return { + format: PROOF_FORMAT, + exportedAt: "2026-08-05T10:00:00.000Z", + document: { + id: "doc_1", + walletId: "wallet_1", + title: "Q3 Treasury Budget", + description: null, + documentType: null, + createdBy: SIGNER_A, + createdAt: "2026-08-01T00:00:00.000Z", + }, + version: { + id: "ver_1", + versionNumber: 1, + contentHash: CONTENT_HASH, + hashAlgorithm: "sha256", + fileName: "budget.pdf", + mimeType: "application/pdf", + fileSize: 1024, + status: "Approved", + createdBy: SIGNER_A, + createdAt: "2026-08-01T00:00:00.000Z", + reviewStartedAt: "2026-08-02T00:00:00.000Z", + decidedAt: "2026-08-05T09:00:00.000Z", + }, + policy: { + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + requiredSigners, + signersAddresses: [SIGNER_A, SIGNER_B, SIGNER_C], + signersDescriptions: ["Alice", "Bob", "Carol"], + capturedAt: "2026-08-02T00:00:00.000Z", + }, + reviews, + events: [], + verification: { + domain: SIGNOFF_DOMAIN, + instructions: VERIFICATION_INSTRUCTIONS, + }, + }; +} + +// --------------------------------------------------------------------------- + +describe("canonicalization", () => { + it("is independent of key insertion order", () => { + expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 })); + }); + + it("produces no incidental whitespace", () => { + expect(canonicalize({ a: 1, b: "x" })).toBe('{"a":1,"b":"x"}'); + }); + + it("drops undefined but keeps null", () => { + expect(canonicalize({ a: undefined, b: null })).toBe('{"b":null}'); + }); + + it("recurses into nested objects and arrays", () => { + expect(canonicalize({ z: [{ b: 1, a: 2 }] })).toBe('{"z":[{"a":2,"b":1}]}'); + }); +}); + +describe("buildSignOffPayload", () => { + it("carries the plain-language statement that matches the action", () => { + const approve = buildSignOffPayload({ + action: "approve", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + expect(approve.statement).toBe(SIGNOFF_STATEMENTS.approve); + expect(approve.statement).toMatch(/I approve this exact document version/); + expect(approve.domain).toBe(SIGNOFF_DOMAIN); + }); + + it("always includes comment, so an empty comment is still signed", () => { + const payload = buildSignOffPayload({ + action: "reject", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + expect(payload.comment).toBe(""); + expect(canonicalizeSignOffPayload(payload)).toContain('"comment":""'); + }); + + it("rejects an unparseable signedAt rather than silently stamping now()", () => { + expect(() => + buildSignOffPayload({ + action: "approve", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: "not-a-date", + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }), + ).toThrow(/not a valid date/i); + }); +}); + +describe("version-hash binding", () => { + const base = { + action: "approve" as const, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }; + + it("produces a different payload for a different content hash", () => { + const v1 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const v2 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: OTHER_HASH, versionId: "ver_1" }), + ); + expect(v1).not.toBe(v2); + }); + + it("produces a different payload for a different version id", () => { + const v1 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const v2 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_2" }), + ); + expect(v1).not.toBe(v2); + }); + + it("a tampered comment changes the payload, so the signature no longer matches", () => { + const clean = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const tampered = canonicalizeSignOffPayload( + buildSignOffPayload({ + ...base, + contentHash: CONTENT_HASH, + versionId: "ver_1", + comment: "actually I meant no", + }), + ); + expect(clean).not.toBe(tampered); + }); + + it("rebuilding from identical inputs is byte-identical — the server-side check", () => { + const input = { ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }; + expect(canonicalizeSignOffPayload(buildSignOffPayload(input))).toBe( + canonicalizeSignOffPayload(buildSignOffPayload(input)), + ); + }); +}); + +describe("hash + time helpers", () => { + it("recognises a sha256 digest and rejects near-misses", () => { + expect(isSha256Hex(CONTENT_HASH)).toBe(true); + expect(isSha256Hex(CONTENT_HASH.toUpperCase())).toBe(false); + expect(isSha256Hex(CONTENT_HASH.slice(0, 63))).toBe(false); + expect(isSha256Hex("")).toBe(false); + }); + + it("accepts a signedAt inside the window and rejects one outside it", () => { + const now = new Date("2026-08-05T09:00:00.000Z"); + expect(isSignedAtWithinTolerance("2026-08-05T09:05:00.000Z", now)).toBe(true); + expect(isSignedAtWithinTolerance("2026-08-05T08:45:00.000Z", now)).toBe(false); + expect(isSignedAtWithinTolerance("nonsense", now)).toBe(false); + }); +}); + +describe("evaluateThreshold", () => { + it("approves once the threshold is met", () => { + expect( + evaluateThreshold({ approvals: 2, rejections: 0, signerCount: 3, requiredSigners: 2 }), + ).toBe("Approved"); + }); + + it("stays open while the threshold is still reachable", () => { + expect( + evaluateThreshold({ approvals: 1, rejections: 1, signerCount: 3, requiredSigners: 2 }), + ).toBe("InReview"); + }); + + it("rejects as soon as the threshold has become unreachable", () => { + expect( + evaluateThreshold({ approvals: 1, rejections: 2, signerCount: 3, requiredSigners: 2 }), + ).toBe("Rejected"); + }); + + it("handles unanimous policies", () => { + expect( + evaluateThreshold({ approvals: 2, rejections: 1, signerCount: 3, requiredSigners: 3 }), + ).toBe("Rejected"); + expect( + evaluateThreshold({ approvals: 3, rejections: 0, signerCount: 3, requiredSigners: 3 }), + ).toBe("Approved"); + }); +}); + +describe("verifyProofPackage", () => { + it("accepts a well-formed, fully signed, threshold-reaching package", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(true); + expect(result.approvals).toBe(2); + expect(result.thresholdReached).toBe(true); + expect(result.reviews.every((r) => r.valid)).toBe(true); + }); + + it("confirms a re-hashed document against the approved content hash", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const ok = await verifyProofPackage(proof, { + checkSignature: acceptAll, + expectedContentHash: CONTENT_HASH, + }); + expect(ok.contentHashMatches).toBe(true); + expect(ok.valid).toBe(true); + + const wrong = await verifyProofPackage(proof, { + checkSignature: acceptAll, + expectedContentHash: OTHER_HASH, + }); + expect(wrong.contentHashMatches).toBe(false); + expect(wrong.valid).toBe(false); + expect(wrong.errors.join(" ")).toMatch(/does not hash to the approved content hash/i); + }); + + it("fails when a signature does not verify", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: rejectAll }); + expect(result.valid).toBe(false); + expect(result.approvals).toBe(0); + expect(result.reviews[0]?.signatureValid).toBe(false); + }); + + it("fails when a review's payload names a different version's hash", async () => { + const proof = makeProof([ + makeReview(SIGNER_A), + makeReview(SIGNER_B, "approve", { contentHash: OTHER_HASH }), + ]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.payloadBindsToVersion).toBe(false); + expect(result.reviews[1]?.errors.join(" ")).toMatch(/payload\.contentHash/); + }); + + it("rejects a signer who is not in the frozen snapshot", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(OUTSIDER)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.signerInSnapshot).toBe(false); + expect(result.approvals).toBe(1); + }); + + it("rejects a duplicated signer rather than counting them twice", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_A)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.errors.join(" ")).toMatch(/duplicate/i); + expect(result.approvals).toBe(1); + }); + + it("rejects a payload that is not in canonical form", async () => { + const review = makeReview(SIGNER_A); + const reordered = JSON.stringify(JSON.parse(review.payload), null, 2); + const proof = makeProof([{ ...review, payload: reordered }, makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[0]?.errors.join(" ")).toMatch(/canonical/i); + }); + + it("reports not-yet-approved when the threshold is unmet", async () => { + const proof = makeProof([makeReview(SIGNER_A)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.thresholdReached).toBe(false); + expect(result.valid).toBe(false); + expect(result.approvals).toBe(1); + expect(result.requiredSigners).toBe(2); + }); + + it("counts rejections separately and does not credit them as approvals", async () => { + const proof = makeProof([ + makeReview(SIGNER_A, "approve"), + makeReview(SIGNER_B, "reject"), + ]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.approvals).toBe(1); + expect(result.rejections).toBe(1); + expect(result.thresholdReached).toBe(false); + }); + + it("flags an unknown proof format", async () => { + const proof = { ...makeProof([makeReview(SIGNER_A)]), format: "something-else" } as unknown as ProofPackage; + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch(/unknown proof format/i); + }); + + it("survives a signature checker that throws", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { + checkSignature: async () => { + throw new Error("cbor decode failed"); + }, + }); + expect(result.valid).toBe(false); + expect(result.reviews[0]?.errors.join(" ")).toMatch(/cbor decode failed/); + }); +}); diff --git a/src/components/pages/wallet/documents/detail.tsx b/src/components/pages/wallet/documents/detail.tsx new file mode 100644 index 00000000..ba8e75da --- /dev/null +++ b/src/components/pages/wallet/documents/detail.tsx @@ -0,0 +1,268 @@ +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { Download, PlayCircle, Upload } from "lucide-react"; + +import { api } from "@/utils/api"; +import useAppWallet from "@/hooks/useAppWallet"; +import { toastError } from "@/utils/toast-error"; +import { toast } from "@/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import PageHeader from "@/components/ui/page-header"; +import WalletDetailSkeleton from "@/components/pages/wallet/wallet-detail-skeleton"; +import DocumentStatusBadge from "./status-badge"; +import { sha256HexFromFile } from "./hash-file"; + +/** + * Document detail — lifecycle, version history, who approved, who is missing. + * + * "Missing signers" is computed against the version's frozen snapshot, not the + * live wallet, so the list stays truthful even after the wallet's membership + * changes. + */ +export default function PageDocumentDetail() { + const router = useRouter(); + const walletId = router.query.wallet as string; + const documentId = router.query.documentId as string; + const { appWallet } = useAppWallet(); + const [uploading, setUploading] = useState(false); + + const utils = api.useUtils(); + const { data: document, isLoading } = api.document.getById.useQuery( + { documentId }, + { enabled: !!documentId }, + ); + + const refresh = async () => { + await utils.document.getById.invalidate({ documentId }); + await utils.document.listByWallet.invalidate({ walletId }); + }; + + const startReview = api.document.startReview.useMutation({ + onSuccess: async () => { + await refresh(); + toast({ title: "Review round started" }); + }, + onError: (error) => toastError(error, "Could not start the review"), + }); + + const uploadVersion = api.document.uploadVersion.useMutation({ + onSuccess: async () => { + await refresh(); + toast({ + title: "New version uploaded", + description: "Approvals were reset — this version starts at zero.", + }); + }, + onError: (error) => toastError(error, "Could not upload the version"), + }); + + const exportProof = api.document.exportProof.useMutation({ + onSuccess: (proof) => { + const blob = new Blob([JSON.stringify(proof, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const anchor = window.document.createElement("a"); + anchor.href = url; + anchor.download = `signoff-proof-${proof.document.id}-v${proof.version.versionNumber}.json`; + anchor.click(); + URL.revokeObjectURL(url); + }, + onError: (error) => toastError(error, "Could not export the proof"), + }); + + async function onUploadFile(file: File | null) { + if (!file) return; + setUploading(true); + try { + uploadVersion.mutate({ + documentId, + contentHash: await sha256HexFromFile(file), + fileName: file.name, + mimeType: file.type || undefined, + fileSize: file.size, + storageMode: "hashOnly", + }); + } catch (error) { + toastError(error, "Could not hash that file"); + } finally { + setUploading(false); + } + } + + if (appWallet === undefined || isLoading) return ; + if (!document) { + return ( +
+

Document not found.

+
+ ); + } + + return ( +
+ + + + + {document.description && ( +

+ {document.description} +

+ )} + + + + New version + + +

+ Uploading a new version supersedes the current one and starts a fresh + round at zero approvals — approval is bound to the content hash, not + the title. +

+ void onUploadFile(e.target.files?.[0] ?? null)} + /> +
+
+ +
+ {document.versions.map((version) => { + const snapshot = version.signerSnapshot; + const approvals = version.reviews.filter((r) => r.action === "approve"); + const rejections = version.reviews.filter((r) => r.action === "reject"); + const acted = new Set(version.reviews.map((r) => r.signerAddress)); + const missing = + snapshot?.signersAddresses.filter((a) => !acted.has(a)) ?? []; + + return ( + + + + Version {version.versionNumber} + +
+ + {version.status === "Draft" && ( + + )} + {version.status === "InReview" && ( + + )} + {snapshot && ( + + )} +
+
+ +
+ + Content hash ({version.hashAlgorithm}) + + + {version.contentHash} + + {version.fileName && ( + + {version.fileName} + + )} +
+ + {snapshot ? ( +
+ + {approvals.length} of {snapshot.requiredSigners} approvals + {rejections.length > 0 && ` · ${rejections.length} rejected`} + + {version.reviews.map((review) => ( +
+ + {review.action === "approve" ? "✓" : "✗"}{" "} + {review.signerAddress} + + {review.comment && ( + + “{review.comment}” + + )} +
+ ))} + {missing.length > 0 && ( + + Waiting on {missing.length} signer + {missing.length === 1 ? "" : "s"} + + )} +
+ ) : ( + + No review round started yet. + + )} +
+
+ ); + })} +
+ + + + History + + + {document.events.map((event) => ( +
+ + {new Date(event.createdAt).toISOString()} + + {event.type} + {event.actorAddress && ( + + {event.actorAddress} + + )} +
+ ))} +
+
+ +

+ + An exported proof is an approval attestation by this wallet's signers. + It is not a qualified electronic signature. +

+
+ ); +} diff --git a/src/components/pages/wallet/documents/hash-file.ts b/src/components/pages/wallet/documents/hash-file.ts new file mode 100644 index 00000000..31b774b5 --- /dev/null +++ b/src/components/pages/wallet/documents/hash-file.ts @@ -0,0 +1,17 @@ +/** + * Browser-side content hashing for Document Sign-Off. + * + * The bytes never have to leave the machine — hashing locally is what lets a + * team bind an approval to a confidential document without uploading it. + */ + +export async function sha256HexFromBytes(bytes: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export async function sha256HexFromFile(file: File): Promise { + return sha256HexFromBytes(await file.arrayBuffer()); +} diff --git a/src/components/pages/wallet/documents/index.tsx b/src/components/pages/wallet/documents/index.tsx new file mode 100644 index 00000000..61d430d3 --- /dev/null +++ b/src/components/pages/wallet/documents/index.tsx @@ -0,0 +1,108 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { FileSignature, Plus } from "lucide-react"; + +import { api } from "@/utils/api"; +import useAppWallet from "@/hooks/useAppWallet"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { EmptyState } from "@/components/common/empty-state"; +import PageHeader from "@/components/ui/page-header"; +import WalletDetailSkeleton from "@/components/pages/wallet/wallet-detail-skeleton"; +import DocumentStatusBadge from "./status-badge"; + +/** + * Documents list — the status-scan view. A signer opening this page should be + * able to tell in one pass which documents are waiting on them. + */ +export default function PageDocuments() { + const router = useRouter(); + const walletId = router.query.wallet as string; + const { appWallet } = useAppWallet(); + + const { data: documents, isLoading } = api.document.listByWallet.useQuery( + { walletId }, + { enabled: !!walletId }, + ); + + if (appWallet === undefined) return ; + + return ( +
+ + + + +

+ Approvals are bound to an exact version hash and inherit this wallet's + signers and threshold. Uploading a new version starts a fresh round at zero + approvals. +

+ + {isLoading &&

Loading…

} + + {!isLoading && (documents?.length ?? 0) === 0 && ( + + New document + + } + /> + )} + +
+ {documents?.map((doc) => { + const latest = doc.versions[0]; + const approvals = + latest?.reviews.filter((r) => r.action === "approve").length ?? 0; + const required = latest?.signerSnapshot?.requiredSigners; + + return ( + + + +
+
+ {doc.title} + +
+ {doc.description && ( +

+ {doc.description} +

+ )} +
+
+ {latest ? ( + <> + v{latest.versionNumber} + {required !== undefined && ( + <> · {approvals}/{required} approvals + )} + + ) : ( + "No version yet" + )} +
+
+
+ + ); + })} +
+
+ ); +} diff --git a/src/components/pages/wallet/documents/new.tsx b/src/components/pages/wallet/documents/new.tsx new file mode 100644 index 00000000..d4620615 --- /dev/null +++ b/src/components/pages/wallet/documents/new.tsx @@ -0,0 +1,170 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; +import { FileUp } from "lucide-react"; + +import { api } from "@/utils/api"; +import useAppWallet from "@/hooks/useAppWallet"; +import { toastError } from "@/utils/toast-error"; +import { toast } from "@/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import PageHeader from "@/components/ui/page-header"; +import WalletDetailSkeleton from "@/components/pages/wallet/wallet-detail-skeleton"; +import { sha256HexFromFile } from "./hash-file"; + +/** + * Create a document, optionally with its first version. + * + * The file is hashed **in the browser** and only the digest is sent — the bytes + * never leave the machine, which is what makes it usable for confidential + * documents. The hash is the thing signers will bind their approval to. + */ +export default function PageDocumentNew() { + const router = useRouter(); + const walletId = router.query.wallet as string; + const { appWallet } = useAppWallet(); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [documentType, setDocumentType] = useState(""); + const [file, setFile] = useState(null); + const [contentHash, setContentHash] = useState(null); + const [hashing, setHashing] = useState(false); + + const utils = api.useUtils(); + const createDocument = api.document.createDocument.useMutation({ + onSuccess: async (doc) => { + await utils.document.listByWallet.invalidate({ walletId }); + toast({ title: "Document created" }); + void router.push(`/wallets/${walletId}/documents/${doc.id}`); + }, + onError: (error) => toastError(error, "Could not create the document"), + }); + + async function onPickFile(picked: File | null) { + setFile(picked); + setContentHash(null); + if (!picked) return; + setHashing(true); + try { + setContentHash(await sha256HexFromFile(picked)); + } catch (error) { + toastError(error, "Could not hash that file"); + } finally { + setHashing(false); + } + } + + if (appWallet === undefined) return ; + + const canSubmit = title.trim().length > 0 && !hashing && !createDocument.isPending; + + return ( +
+ + + + + Document + + +
+ + setTitle(e.target.value)} + placeholder="Q3 Treasury Budget" + /> +
+ +
+ +