August Updates - #393
Open
Andre-Diamond wants to merge 62 commits into
Open
Conversation
- Update Wallet type to include capabilities\n- Modify buildWallet to compute capabilities for Summon, SDK, and Legacy wallets\n- Update hooks (useAppWallet, useMultisigWallet, useWalletBalances) to leverage capabilities\n- Update UI components (CardWallet, ShowInfo) to use capability-driven rendering\n- Add unit tests for Summon wallet capabilities
- Bot onboarding for AI agents: public /bot-setup page, agent-readable
/api/v1/botSetupGuide markdown endpoint, /llms.txt discovery, and a
"Copy agent prompt" button on the user Bot accounts card so any AI
agent pointed at the instance URL can register itself end-to-end.
- Cross-instance wallet transfer: new /api/v1/wallet/transfer/export
(owner JWT) and /api/v1/wallet/transfer/import endpoints with a
shared WalletTransferPayloadV1 type. UI exposes "Transfer wallet"
on the wallet info page (download JSON or push directly to a remote
instance URL, optional contacts/ballots payloads) and "Import
Transfer" on the wallets list page. Imports land as NewWallet so the
existing invite/claim flow takes over.
- Governance overview improvements:
- Wallet governance dashboard summary card (proposal status counts,
ballot progress, voting power, last ballot activity)
- Live network stats strip on the public /governance landing
- DRep list aggregate header, active/inactive filter, surfaced
active_epoch and hex per row
- Proposal detail "Your ballot entry" (rationale + anchor) and
"Technical details" sections surfacing fields that were already
fetched but never rendered
- Ballot UX + standalone rationale: shared rationale module
(build JSON-LD, hash, upload to IPFS, load-from-URL) and a reusable
RationaleEditor component. The vote card now offers an "Attach
voting rationale" toggle so a user can attach a CIP-100 anchor to a
single-proposal vote without creating a Ballot. VoteButton threads
the anchor through to txBuilder.vote(). Ballot summary now reports
rationale-uploaded / draft counts; moving a proposal between
ballots is gated by a Keep / Add to both / Move here dialog instead
of silently relocating it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…m/hours line 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 <noreply@anthropic.com>
…gn-off 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 <noreply@anthropic.com>
Adding or removing someone from a proxy had no in-app path at all: a proxy was reachable only by signers of its controlling multisig, so sharing it with an auditor or an ops account meant adding them as a signer, which changes the multisig script and its address. Introduce ProxyMember, a database-only access grant keyed by address so a grant can be issued before the invitee has ever signed in. Owners (signers of the controlling multisig, or the proxy's own user) always have access and cannot be removed; a `manager` member can also edit the access list, a `viewer` is read-only, and any member can remove themselves. The grant is deliberately off-chain: spending and voting still require an auth token, which lives at the multisig, so membership never confers on-chain authority. Every surface says so explicitly. The management dialog is built for one-shot use — paste an address and press Enter, one-click chips for existing wallet contacts, inline role changes, and removal with an Undo toast instead of a confirmation step. Signers are listed separately as permanent access so the two kinds of access are never confused. Recipients who are not signers cannot reach a wallet page, so shared proxies surface on /user instead. Alongside it, a glass FAQ on the proxy card explains what a proxy is, why it exists, and what the ten auth tokens do, with a CSS-only animation of the UTxO flow embedded in the setup and spend dialogs at their respective steps. The animation freezes on completed steps and honours prefers-reduced-motion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 1 opened with a JSON config file, which desktop and web assistant apps do not have. Anyone connecting through a connector screen — the common case — had to infer that the URL buried in the snippet was the only thing they needed. It now leads with that URL and the Settings → Connectors → Add custom connector path, with the config file and the CLI one-liner kept underneath for clients that use them. The endpoint is derived from SITE_URL instead of hardcoded. It printed the production host on every environment, so preprod was handing out setup instructions pointing at a different deployment than the one you were reading them on. Adds the two things that were missing rather than wrong: Step 2 says the permissions are individual checkboxes you can untick, which only became true when the consent screen grew them. Step 3 gives a way to tell a successful connect from a half-scoped one — 13 tools for everything, 7/4/2 per permission, counted from the registry so the numbers cannot drift. Without it both states look identical: a working server with fewer tools. And a callout for the asymmetry that is easy to read as a bug: removing a permission bites on the client's next request, while adding one cannot reach a token already issued, so the client must reconnect. The toast after saving permissions said only "Applies to the client's next request" — true of a removal, false of an addition, and it was the addition case that looked broken. Verified against a production build at desktop and 375px: no content clipped, the config block scrolls internally, and the tool counts render from MCP_TOOL_SUMMARIES.
docs(mcp): setup instructions that work for GUI clients
…58102 feat(proxy): manage proxy access in-app and explain the on-chain flow
Both branches appended new models to the end of prisma/schema.prisma — Document Sign-Off (five entities) here, the OAuth 2.1 authorization server on preprod — so the conflict was positional, not semantic. Kept both blocks. Everything else auto-merged: the `document` router sits alongside the new `mcp` router in root.ts, and preprod's `/oauth` noindex prefix landed next to the existing ones in seo.ts.
Every page shared the same og-image, so a /governance link and a /roadmap link were indistinguishable in a feed — the preview said "Mesh Multisig" and nothing about where it pointed. - generate-og-image.mjs now renders ten cards from one template. Each has its own eyebrow, headline, subhead, footer chips and accent tint, so the card identifies the destination at thumbnail size. Copy is authored as explicit lines (SVG has no wrapping) and the script warns when a line's estimated width would overflow the content column. - routeSeo entries carry `image` + `imageAlt`; getRouteSeo resolves them, _app falls back route-card-first, and Metatags emits real alt text instead of repeating the title. Blog posts without their own artwork now get the blog card rather than the home card. - og:image URLs carry ?v= (OG_IMAGE_VERSION). Scrapers cache by URL and would otherwise keep serving the old artwork indefinitely. Nothing at build time ties the PNGs to the paths in seo.ts, so ogCards asserts each referenced card exists, is 1200x630, and has alt text.
Document Sign-Off MVP + roadmap MRP mapping
Resolves the 10-commit drift between this branch and preprod. Conflict resolutions: - src/lib/governance/rationale.ts (add/add): both branches wrote a CIP-100 rationale module. Merged into one that keeps preprod's `uploadRationale` / `findBallotRowForVote` and this branch's typed builder, `computeAnchorHash`, `uploadRationaleToPinata` and `loadRationaleFromUrl`. The CIP-100 context key order is identical on both sides, so anchor hashes are unchanged. `loadRationaleFromUrl` now goes through `fetchIpfsJson` instead of a bare `fetch`, so anchor URLs (attacker-controlled) take the guarded resolver proxy and non-IPFS URLs are restricted to https. - ballot.tsx: kept preprod's `fetchIpfsJson` load path (multi-gateway proxy + `loadedAnchorsRef` de-dup) over this branch's plain fetch. - wallets.ts: kept both sides' procedures — preprod's `exportWallet` / `importWallet` and this branch's `exportTransferPayload`. - voteButtton.tsx: union of preprod's `voteKind` label and this branch's "+ rationale" suffix. - drep, wallets list, public-routes: additive, both sides kept. - /llms.txt was added twice. Dropped this branch's rewrite + `/api/llms-txt` handler (dead — preprod's page wins route resolution) and folded its new pointers (bot-setup guide, wallet transfer endpoints) into preprod's page. Verified: `npm run typecheck` clean, `npm test` 1084 + 87 passing.
Retargets this branch at `preprod` (per CONTRIBUTING.md all PRs land on preprod first) and resolves the drift. Review feedback: - useWalletBalances.ts: `wallet.capabilities!.address` no longer asserts. `capabilities` is optional on `Wallet`, so a row that reaches the hook without going through `buildWallet()` would have crashed. Guarded, with the previous address-resolution path as the fallback and `wallet.address` as the last resort; restored `network` to the dependency array. - common.ts: `canVote: false` for Summon now carries a TODO explaining that Summon's rawImportBodies.multisig has no DRep script to derive a credential from, pointing at the PR review thread. - run-snapshots-batch.ts: added the note explaining that buildWallet() now subsumes the conditional key-ordering branch this PR deleted; dropped the imports that branch was the only user of. - common.ts: restored the `@/…` path-alias imports this PR had switched to relative paths. Conflict resolutions against preprod: - freeUtxos.ts: preprod's `resolveWalletScriptAddress` helper supersedes the local buildWallet call. - useAppWallet / useMultisigWallet: kept preprod's memoized form. - card-info.tsx: dropped this branch's "Register Wallet — coming soon" placeholder; preprod ships the real register-wallet.tsx. - types/wallet.ts: additive, both sides kept. Verified: `npm run typecheck` clean, `npm test` 1087 + 87 passing, including this PR's 3 Summon cases (unordered-CBOR compatibility). Co-authored-by: peter maina <kanyuku@users.noreply.github.com>
feat: agent onboarding, governance overview, wallet transfer, ballot UX
…ties feat: Summon wallets via capability-based metadata (supersedes #212)
… disclosure Turns a Markdown vault into a single signed object, and lets one document be proved to belong to it without revealing its siblings. Two relations over one vault. A body [[wikilink]] carries a name only, creates no hash dependency, and may cycle freely. A frontmatter `trusts:` edge carries the target's hash, so a document's hash covers everything it trusts transitively — and those edges must form a DAG. A cycle fails the build naming the offending chain rather than reporting that one exists. The root commits to its children's hashes and never their titles. Without that blinding the disclosure leak just moves up a level: proving one facet would name every other facet. What remains is a count. Disclosure reveals the target, the documents on the path to a chosen hub, and the bare hashes of withheld siblings. Verification needs SHA-256 and the expected root — no database, no network, no client from us, so a third party can re-implement it and check our work without trusting us. Scope: proves MEMBERSHIP, not a predicate. Revealing a document reveals all of it; "the limit is at least X without showing X" is out of scope and belongs to BBS+ or SD-JWT if ever needed. Also adds a CI lane so these tests actually run on preprod PRs. 20 tests, typecheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Obsidian-style view of the feature vault -- searchable tree, note reader, logical links -- with the trust graph drawn over it rather than on a separate screen. In this construction the drill-down IS the disclosure path: selecting a note shows the documents a proof of it would reveal and the siblings it would keep sealed, so navigating and understanding the cost of disclosing are the same gesture. The trust edges need no new frontmatter. `area:` is already a downward edge from a workstream to the work in it and areas never point back, so those edges are acyclic by construction and each area note is a proxy hub. Body [[wikilinks]] stay the logical relation: they cycle freely and are excluded from every hash. Over this repo's own vault that yields 10 hubs, 62 notes, 52 trust edges and no orphans. The path animation traces the order a verifier recomputes in, bottom-up to the blinded root, and restarts on each selection -- it is the mechanism, not decoration. getServerSideProps, not static, for the reason /roadmap/graph documents: prerendering this SPA dies on "NextRouter was not mounted" in the deployed build while surviving a local one. Salts here are derived rather than stored, so hashes are stable across builds. That is fine for a public vault with nothing to withhold and is called out in the loader as not fine anywhere real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to the vault view: - Register /vault in publicRoutes. Without it the layout swapped in <PageHomepage /> for anyone without a connected wallet, so the page returned its own props and rendered the landing page over them. The route holds nothing user-specific, so it renders unauthenticated. - Add an interactive knowledge graph drawing both relations at once: trust edges solid and directed, logical wikilinks dashed. Turning the logical layer off leaves the DAG the root commits to, which makes the acyclicity argument visible rather than asserted. Force layout is hand-rolled — 62 nodes make the O(n^2) pass free and a dependency would cost more than it saves. Selecting a node drives the trust-path overlay, so the graph and the drill-down share one selection. - Render note bodies as Markdown instead of dumping source, with wikilinks inline and clickable. Reuses the react-markdown already in the tree; links become #vault/ hrefs because react-markdown's default urlTransform drops schemes it does not know. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/vault was registered as a public route but was invisible to the SEO system, so it inherited DEFAULT_TITLE and DEFAULT_DESCRIPTION — the homepage copy — on a page about something else entirely, and it never appeared in the sitemap. Adds the routeSeo entry, an INDEXABLE_ROUTES entry, and its own social card. The generator's own note is that every marketing route gets its own card: a shared card makes two different links indistinguishable in a feed. Regenerating produced byte-identical output for the other ten, so only the new card is added here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without this the page ships reachable only by typing the URL — the same defect that left the Document Sign-Off pages unreachable. Follows the pattern /roadmap/graph already uses: a footer entry for people and an entry in the no-JS fallback list so crawlers can find it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #356 added four routes under /wallets/[wallet]/documents -- list, new, detail and version review -- and touched no navigation file. The feature is live on preprod and reachable only by typing the URL. Adds the sidebar entry, placed before Signing since a document round is what produces something to sign. Active state uses a prefix match rather than equality, because the section has four routes and equality would drop the highlight as soon as a user opened a document. One render site covers both desktop and mobile: layout.tsx mounts MenuWallet once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sign()` verified with `checkSignature(generateNonce(payload), ...)`. `generateNonce(label)` returns `hex(label + 32 random characters)`, while the wallet signs `payload`, so the COSE payload and the value being checked against could never match. Verification returned false for every honest signature and `sign()` threw "Signature failed verification" on every call. Confirmed at runtime against the installed @meshsdk build: signing a canonical sign-off statement with a real key gives checkSignature(generateNonce(payload), sig, addr) === false checkSignature(payload, sig, addr) === true Three call sites depend on this helper — the wallet Signing page, the signable card, and Document Sign-Off review — so document sign-off could not complete a single approval. Why the existing test missed it: signing.test.ts mocks both `checkSignature` and `generateNonce`, so it proves the control flow (throw when verification fails) but cannot prove verification ever succeeds. The defect lived exactly in the gap the mock created. The new signDataRoundTrip test signs with a real key through the real @meshsdk helpers and asserts the round trip closes; it fails against the previous code with the exact "Signature failed verification" error. The dead generateNonce mock is dropped from the old test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…efects
Findings from an adversarial review of this branch. The first is a
soundness bug in the construction itself.
IDENTITY WAS NOT COMMITTED TO
hashNode covered salt, content and child hashes but discarded doc.id,
while the library models identity BY id: trust edges name targets by id,
and Disclosure and VerifyResult both hand ids to a relying party. Two
consequences, each reproduced as a test that fails against the previous
code:
- An honest disclosure could be RELABELLED. Take a real proof of one
document, rewrite targetId and the path ids, and verifyDisclosure
still returned ok:true — affirming membership of a document by a
name that was never in the vault, under a hub that does not exist.
- Two vaults whose documents differ only in name shared one root hash,
so a signature over the root did not say which signer held which
weight.
Binding doc.id fixes both and does not weaken the blinded root: blinding
means the root does not REVEAL titles — it is a hash over salted node
hashes — not that it is invariant to them. The old test asserted that
stronger, wrong property and pinned the defect in place; it now asserts
the binding.
CYCLE DETECTION
A node whose subtree failed was left in "visiting", so every later branch
reaching it reported a fresh, invented cycle around the one real one.
Marking it "done" instead crashes — no node was ever built for it, so the
height lookup dereferences undefined (verified). It needs its own
"failed" state, with a test that walks a second edge into the cycle.
DATA LAYER
- buildVaultTrustView re-read and re-hashed all 67 files on every
request. /vault is public and sitemap-indexed, so every crawler hit
paid for it. Memoised in production, matching loadVaultGraph.
- hubs came from graph.roots, but an orphaned feature has no parent
either, so it was listed as a hub AND under "outside the spine", and
inflated the hub count. Hubs are the area notes.
- Replaced a second, divergent wikilink regex with the vault's own
extractWikilinks, which also strips `#` heading anchors.
- disclosureFor omitted a hub's own children from `withheld`, so the
first thing every visitor sees under-reported what disclosing a hub
costs, and reported "nothing withheld" for a note outside the spine
when every other root is in fact sealed.
UI
- The reduced-motion branch returned no cleanup, but dragging calls
reheat(), which starts a loop on that path too — leaking a live
requestAnimationFrame past unmount.
- Drags ignored pointerId and had no pointercancel handler, so a second
touch could hijack a node and a cancelled gesture left it stuck to the
cursor.
- The trust-path trace played once and never again: SMIL `begin` is
measured from the document timeline, not from insertion, so a
remounted <g> renders straight to its frozen end state. Replaced with
a state-driven stepped reveal that restarts on every selection and
honours reduced motion.
- The graph rendered nothing until the first animation frame, which
never arrives in a background tab. Positions are seeded synchronously.
New CI guard: a test builds the repo's own vault and asserts it is a DAG.
buildVaultTrustView throws on a non-DAG from getServerSideProps, so a bad
`area:` would have been a 500 on a public page that no build step or test
could have caught.
1119 tests pass; tsc clean; next build exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The platform signs as a notary; humans still enact. Each document version gets one Ed25519 attestation over a canonical statement naming the content hash, the version, the wallet, the server time, and its position in the document's chain. Every attestation commits to the hash of the previous one, so inserting, removing, reordering or back-dating a version breaks the links — detectably, by anyone holding the public key, without this app or its database. WHY HOLDING THIS KEY IS ACCEPTABLE It is the least powerful key the platform could hold. It cannot approve a document: approval is DocumentReview, CIP-8 signatures from the wallet's own signers against the frozen DocumentSignerSnapshot, and this key is nowhere on that path. It cannot witness a transaction, so it cannot move funds. Stealing it buys forged timestamps and nothing else — and rotating it out while leaving its public half in the prior-keys registry refuses new forgeries while genuine history keeps verifying. The bound is enforced by the bytes, not by documentation: every attestation signs a statement that says, verbatim, that it is a timestamp and ordering record only, is not an approval, and grants no authority. Changing that statement invalidates the signature, and the chain verifier rejects any attestation whose statement is not the one this domain defines. DESIGN NOTES - attestation.ts is dependency-free (node crypto only), matching payload.ts and proof.ts, so a third party can verify a chain offline. - Attestation is OPTIONAL. With DOCUMENT_ATTESTATION_KEY unset it is a no-op and the rest of the feature is unchanged; a missing key must never block creating or approving a document. - When a key IS configured, attestation is atomic with version creation rather than best-effort. A silently unattested version makes the audit trail lie by omission, which is worse than a failed upload. - Concurrency is handled by @@unique([documentId, sequence]): two simultaneous uploads cannot both claim the same link, so one rolls back instead of forking the chain — mirroring the existing @@unique([documentId, versionNumber]). - Export and verification are a separate surface, not folded into ProofPackage: `...proof.v1` is a versioned format whose field names are a contract, and adding to it would change what existing verifiers parse. Folding it in belongs with a format bump. 17 tests cover the tamper cases, including the strongest one: an attacker who HAS the key rewriting history in place is still caught, because the next link commits to the original. MIGRATION: adds DocumentAttestation. Verified by applying every migration from scratch against a throwaway Postgres. Note this repo ships migrations via a path-filtered action on main that does not self-retry — merging here does not apply it; confirm with `prisma migrate status`. 1104 tests pass; tsc clean; next build exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `document_list` and `document_get` under a new `documents:read` scope, so an agent can answer "what needs signing, and who hasn't signed it" without being able to act on the answer. WHY THIS STARTS WITH REST Every MCP tool wraps a `src/pages/api/v1/*` handler through `invokeV1`, and mcpTools.test.ts asserts on disk that each `v1Path` exists. There was no document handler — sign-off is tRPC-only — so the REST surface comes first. It has standalone value, and routing it through `createCaller` means the signer-or-owner rule is not re-implemented: there is still one authorization path, the one the UI uses. THE BOUNDARY IS UNCHANGED Both tools are readOnlyHint, so the existing "exposes no tool that can sign, spend or broadcast" assertion still lists exactly the two ballot write tools. Nothing here can create, edit, approve or sign a document — approval is a CIP-8 signature from a named wallet signer against a frozen snapshot, which is not reachable from this surface by construction. Bot keys are excluded twice over: `mcpScopesForBot` is an explicit allowlist that does not map `documents:read`, so the tools never appear for a bot, and both handlers reject bot JWTs anyway. Sign-off is a human accountability record and an automated identity has no standing in it. RESPONSES ARE A PROJECTION, NOT THE ROWS `summariseDocument` is an allowlist. A version can carry up to 512KB of inline base64, which is pure waste as model context and never what a caller asking about signatures wants — and because this output becomes model context, a new column on DocumentVersion must not be able to start flowing to a model silently. The outcome field is recomputed from the frozen snapshot with `evaluateThreshold`, so a caller sees the same rule the server enforces rather than a denormalised copy. Human wallet JWTs receive the new scope automatically, matching the existing rule that a wallet JWT grants what the signed-in user can already do in the UI. 1087 tests pass; tsc clean; next build exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(signing): sign() rejected every valid signature
fix: Document Sign-Off shipped with no way to reach it
fix(security): unaccepted invitations are a count, not attacker-chosen text
feat(documents): DocumentDraft — a mutable body that cannot be signed
The demo at /vault reads this repo's `vault/` directory. It answers "what
would shielded sign-off look like" and not "how do I get one" — there was
no path from seeing the idea to having it. This builds the same
VaultTrustView from a wallet's real documents, so the same browser renders
it and no UI has to know where the notes came from.
THE MAPPING
`Document.documentType` already groups documents the way `area:` grouped
features, so it becomes the proxy hub:
blinded root -> document type -> document
Hubs never point at each other and documents never point at hubs, so the
graph is acyclic by construction — the same property the file-backed vault
gets from `area:`.
WHAT A DOCUMENT NODE COMMITS TO
Its latest version's identity — content hash, version number, status —
canonicalised, not its bytes. Most documents here are hashOnly and their
bytes are not on the server at all, and the content hash already commits
to them. So the vault commits to the hash that commits to the document,
and behaves identically whether or not a body was ever stored.
REAL SALTS, NOT DERIVED ONES
The demo derives salts from the note title and says in its own comment
that this is fine there and nowhere real: a salt that is a public function
of the title cannot stop anyone brute-forcing a short document from a
guessed title, which is the only thing the salt is for. This adds one
32-byte secret per wallet and derives each node's salt as
HMAC(vaultSalt, nodeId) — unguessable without the secret, one nullable
column, generated lazily so no wallet needs backfilling.
COLLISIONS ARE NORMAL, NOT FATAL
Node ids double as display labels and buildTrustGraph refuses duplicates,
so two documents sharing a title — or a document named after its own type
— would have thrown the whole page. They are disambiguated
deterministically instead. Both cases are tested.
Navigation: the Documents header now points at the wallet's own vault, and
the demo becomes the "see how it works" link from the empty state, which
reads as one story rather than two competing buttons.
MIGRATION: adds the nullable Wallet.vaultSalt. Verified by applying every
migration from scratch against a throwaway Postgres. Migrations ship here
via a path-filtered action on main that does not self-retry — merging does
not apply it.
1150 tests pass; tsc clean; next build exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): a wallet's own vault, built from the database
UPLOAD The new-version control was a bare <input type="file">, so it rendered the browser's own "Choose file / No file chosen" — unstyleable, out of place against the rest of the UI, and silent about what happens next. Replaced with a drop zone that accepts drag-and-drop and says the thing worth saying: with storageMode hashOnly the file is hashed in the browser and only the digest is sent, so the bytes never leave the machine. That is a surprising property and it belongs on the control, not in documentation. DELETE `archiveDocument` already existed server-side and was reachable from nowhere. Both are now in a header menu, with archive listed first because it is almost always the right one. Deleting is genuinely destructive here: versions, signatures, signer snapshots, attestations and the document's own event log all cascade, and there is no tombstone. So the mutation has two guards. A document anyone has signed requires the caller to retype its title. The dialog says plainly what is being destroyed and how many signatures go with it — in a product whose purpose is proving who approved what, that must not be one stray click. And an AuditLog row is written, awaited, BEFORE the delete. AuditLog holds no foreign key to Document, so unlike DocumentEvent it survives the cascade: the document goes, the record that someone deleted it — with the title, version count and signature count — does not. Verified the control by rendering it in isolation against a real build; the documents page itself is wallet-gated so it could not be driven end to end here. 1139 tests pass; tsc clean; next build exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): real upload control, and archive / delete actions
A Markdown editor over the DocumentDraft layer: split write/preview, autosave, and publish as a separate deliberate action. WHY IT NEVER AUTOSAVES A VERSION `uploadVersion` supersedes the previous version and resets approvals to zero. Autosaving through it would destroy in-flight approvals every few keystrokes and append a link to the attestation chain each time. The editor writes only to DocumentDraft; `publishDraft` is the one-way door, and it hashes server-side so the published bytes are the server's serialisation rather than something the client asserted. COLLABORATION, SAID HONESTLY This deployment runs stock `next start` with no custom server and no broker, so there is nowhere to terminate a WebSocket and nothing to fan out through. A CRDT cannot be hosted here, and shipping something that looks like live co-editing but silently drops writes would be worse than not shipping it. So: the draft is polled, a newer revision is adopted automatically while nothing local is unsaved, and if someone else saves while this author HAS unsaved edits, the editor stops and says so — leaving their text untouched and offering to load the other version. `saveDraft` enforces the same rule server-side through the revision, so a lost update is impossible even if this UI is wrong. THE SYNC RULE IS A PURE FUNCTION `decideDraftSync` is extracted from the component because its failure mode is silently destroying someone's writing and there is no React testing library here to exercise it in place. Seven cases are pinned, including the two that lose work: adopting over unsaved edits, and rolling an author backwards when a poll issued before their save answers after it. Server storage is off by default and the editor says why: the feature's posture is that bytes never reach the server, and storing a draft is a deliberate per-document trade. 1161 tests pass; tsc clean; next build exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): draft editor with live preview and honest collaboration
The construction was shipped without anywhere that explains it. The vault pages show a trust graph and a disclosure path to someone who has not been told what either means. The guide covers the three things that actually change how you use it: why the logical and trust relations must stay apart (hashes cannot cycle, so the moment you want tamper-evidence you must decide which references carry weight), why one root is not enough (proving anything reveals the root, and the root tells the recipient how your organisation is grouped), and exactly what a disclosure gives away — including the part that is easy to miss, that it leaks the COUNT of documents under the disclosed hub and the count of hubs. It also says plainly what is not a button yet. `disclose` and `verifyDisclosure` exist with tests covering relabelling, reordering, tampering and cross-vault splicing, but grep confirms they are imported only by the test file: nothing in the product exports a disclosure artefact. The vault view SHOWS what one would reveal, which is the part that changes how you organise a vault, and the guide is explicit that handing the artefact to a counterparty is the next step rather than a shipped one. Promising otherwise in a guide about proofs would be a strange place to start being loose. Linked from the wallet's own vault and from the public demo, because a guide nobody can find from the thing it explains is not documentation. 1161 tests pass; tsc clean; next build exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other table in this schema gets Row Level Security in the migration
that creates it — 20251215090000_enable_rls_disable_postgrest, its
follow-up 20260706100000_enable_rls_followup_tables, and the per-table
blocks in 20260805090000_add_document_signoff and
20260813000000_add_proxy_member. I added these two tables without it.
Verified against a throwaway Postgres with the `anon` and `authenticated`
roles present so both branches of the migration actually run:
with this migration every table reports relrowsecurity = true,
and four deny-all policies exist for the two
without it DocumentDraft and DocumentAttestation are the
ONLY two tables in the schema with RLS off
This deployment is Supabase-backed, where RLS off plus the PostgREST roles
is what stands between a table and the anon key. It matters more for these
two than for most: DocumentDraft is the one table in the document stack
that holds document BODIES rather than hashes, and DocumentAttestation
holds the signed notary chain.
Written as a follow-up rather than by editing those two migrations. Both
are merged but applied nowhere, so editing them would work today — and
would fail with a checksum error against any environment that had already
applied them, and this repo ships migrations through an action that does
not self-retry, so one failed deploy blocks every later migration too. The
follow-up is correct under either state.
No schema change: RLS is not modelled by Prisma, so prisma/schema.prisma
is untouched and there is no drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… keys
Document Sign-Off had no integration test. Every existing test covers one
piece of it in isolation, and the chain those pieces form — create, draft,
publish, freeze the signer set, sign, reach the threshold, export a proof,
verify it — had never been executed.
That is exactly where this feature's worst bug lived. `sign()` rejected
every valid signature for as long as the feature existed, and its unit
test did not catch it because it mocked `checkSignature` — the one thing
it was meant to prove. So this test signs with a real key, through the
real helper, against a real Postgres. The wallet is seeded with an address
whose mnemonic the test holds, which is what makes a genuine CIP-8
signature possible instead of a fixture pretending to be one.
Three cases:
- The whole lifecycle, ending in `verifyProof({ valid: true })` through
the public procedure. It also asserts the published contentHash equals
sha256 of the bytes the server stored, which is the claim that makes
"what you sign is what was published" true rather than asserted, and
that publishing is refused outright when server storage is off.
- A forged payload is rejected and the version stays InReview.
- A new version supersedes the signed one and resets approvals to zero.
Two bugs found writing it were mine, not the product's, and both are worth
recording. `publishDraft` creates a version without bumping the draft
revision, so my second save presented a stale expectedRevision. And the
first tamper attempt edited prose in the body — which proved nothing,
because the payload binds the content HASH, not the text. It now tampers
with the hash itself, and asserts the string actually changed so the test
cannot silently go vacuous again.
Runs in the existing trpc-integration-tests workflow. 88 integration tests
pass, 1161 unit tests pass, tsc clean, next build exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(vault): a how-to for shielded sign-off, linked from both vaults
fix(security): RLS for DocumentDraft and DocumentAttestation
test(documents): end-to-end sign-off against a real database and real keys
…oles Replaces the closed #385 after both open questions came back maximal: one human MAY hold two roles, and optional / non-signing parties ARE a launch requirement. They turn out to be the same change — both need the capacity in the signed bytes — so doing them together costs far less than either alone. NO SIGNOFF_DOMAIN BUMP, CONTRARY TO WHAT I EXPECTED `partyId` is an OPTIONAL payload field. `canonicalize` filters undefined keys, so a threshold signature produces bytes byte-identical to before this field existed and every proof already issued keeps verifying. A domain bump is only needed to RENAME or re-mean a field, not to add one. Proven by a test that asserts threshold bytes contain no partyId and that partyId: null canonicalizes identically to omitting it. It has to be in the SIGNED bytes rather than beside them: a contract's claim is "the Buyer signed as Buyer", and once one human can hold two roles, two signatures from one address are otherwise indistinguishable. The verifier binds it, so a relabelled capacity fails verification instead of quietly re-attributing a signature. A PARTY-AWARE OUTCOME RULE `evaluateThreshold` counts approvals anonymously, which cannot decide a contract: an optional Witness in the snapshot lets one be Approved over a REQUIRED party's explicit rejection, and leaving the Witness out denies them at submission instead. `evaluateContractOutcome` decides on WHICH parties acted — every required party approved, any required party rejected, optional parties recorded and never counted. A test asserts the two rules genuinely diverge on the same facts. It also refuses to decide a party set with nothing required: "every required party approved" is vacuously true over an empty set, and approving a contract nobody had to sign is the worst possible default. THE COST OF DUAL ROLES, PAID EXPLICITLY Allowing one address two capacities means dropping DocumentReview_versionId_signerAddress_key. That would silently weaken THRESHOLD mode, where one signer acting twice on a version is still wrong and the in-transaction check reads rows fetched before the write. A partial unique index restores exactly the old guarantee exactly where it still applies (WHERE partyId IS NULL). Prisma cannot express a WHERE on @@unique, so it is raw SQL with a note on how migrations are generated here and why that keeps it safe. VERIFIED AGAINST A THROWAWAY POSTGRES - one human on two parties with one address: allowed - that human signs the same version twice, once per capacity: allowed - twice as the SAME party: refused - threshold mode, one signer twice: still refused - RLS on both new tables, in this migration rather than a follow-up 1174 unit tests, 85 integration tests, tsc clean, next build exit 0. Still required before parties mode works, unchanged from #385: the access layer (a named party cannot reach any document procedure today), the startReview parties branch, the ordering gate and row lock in submitSignerAction, and rendering ContractField values into the body before hashing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): contract parties, roles, optional parties and dual roles
The gate the whole contract feature was blocked behind. Every document procedure authorizes through assertWalletAccess, which admits only signersAddresses and ownerAddress — and a counterparty is neither, so they were rejected with FORBIDDEN before any party logic could run. REDEMPTION IS IDENTITY, NAMING IS NOT `resolveDocumentAccess` admits a caller who is a wallet member OR a party on that one document whose invite has been REDEEMED. An unredeemed row carries an address nobody has proved control of — it is an intention to invite, not an identity — so it grants nothing. A test writes an address straight onto a party row and confirms the outsider is still locked out. Access is scoped to the single document. Being a party grants no access to the wallet, to its other documents, or to the party roster: all three are asserted from the outside. IT RETURNS EVERY PARTY THE CALLER HOLDS, NOT ONE One human may hold two roles now, so picking a single party here would silently choose a capacity on their behalf. Anything that acts as a party must be told which one. THE INVITE A 32-byte token, returned exactly once and stored only as its sha256 — the same shape as every other credential in this schema. Redemption is a conditional updateMany on inviteConsumedAt IS NULL rather than read-then-write, so a forwarded link cannot bind a second person and the loser of a race loses cleanly. Every failure — no such token, already used, expired — returns one message. Distinguishing them turns the endpoint into an oracle for guessing tokens. THE ROSTER FREEZES WITH THE ROUND startReview copies parties into DocumentSignerSnapshot, and the feature rests on that snapshot being immutable. Adding or removing a party afterwards would leave the frozen list describing a set that no longer exists — exactly the drift the snapshot exists to prevent. Mirrors the existing "A review round has already started" guard. Deleting a party who has signed is refused with an explanation rather than the foreign key error the NoAction constraint would otherwise produce. Seven integration tests against a real database, written from the outsider's side. 95 integration tests, 1174 unit tests, tsc clean, next build exit 0. Signing as a party still needs the startReview parties branch and the ordering gate in submitSignerAction; this is the access half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): let a named contract party reach the contract
…andling - Implemented `applyMetadataMessage` to handle CIP-20 metadata messages in transactions. - Refactored `useTransaction` to utilize the new metadata application method. - Updated `draftToTokenFlow` to incorporate fee and change outputs from completed transactions. - Introduced `splitTrailingChange` utility to differentiate between payment and change outputs. - Added comprehensive tests for transaction building, metadata application, and change output handling. - Created a new `BuildResultPanel` component to display transaction build results and errors. - Developed `buildDraftTx` function to construct unsigned transactions with metadata and change outputs.
…rafts - Added DraftSource type to represent different funding sources (multisig, connected wallet, arbitrary address). - Implemented createDraft function to initialize drafts with a default multisig source. - Introduced setSource function to change the funding source, clearing unsupported actions (certificates and votes) when switching away from multisig. - Enhanced validation logic to check for source-related issues, including missing addresses and invalid formats. - Updated applyDraftToTxBuilder to handle different input types based on the selected source. - Created SourcePicker component for user interface to select the funding source. - Added SwitchSourceDialog for confirmation when switching from multisig to another source. - Implemented useAddressUtxos hook to fetch UTxOs for arbitrary addresses. - Developed useSignAndSubmit hook for signing and submitting transactions from connected wallets. - Added comprehensive tests for source management functionality, ensuring correct behavior across different scenarios.
…d reached notifications - Add tests for threshold reached notifications and related functions. - Create governance provider for Blockfrost API interactions. - Implement ballot deadline reminder logic, including fetching proposals and sending notifications. - Develop email templates for ballot deadline reminders and threshold reached notifications. - Create API endpoint for triggering ballot deadline reminder scans.
…trations feat: implement resolveRegistrationScript API for resolving native scripts in registrations chore: update Discover Page documentation to reflect current status and scope test: add end-to-end tests for Discover tab functionality in import wizard test: create unit tests for discoverQuery utility functions test: add unit tests for resolveScript API endpoint feat: implement resolveScript API for resolving native scripts by hash or address feat: add discoverQuery utility for classifying user input in Discover tab feat: create nativeScriptJson utility for handling provider native script JSON
Feat/sign off tx visualization
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.