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/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/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/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" + /> +
+ +
+ +