Skip to content

Publish ballot rationales to IPFS (and close the open pin endpoint) - #362

Merged
QSchlegel merged 1 commit into
preprodfrom
claude/mcp-ipfs-rationale
Aug 13, 2026
Merged

Publish ballot rationales to IPFS (and close the open pin endpoint)#362
QSchlegel merged 1 commit into
preprodfrom
claude/mcp-ipfs-rationale

Conversation

@QSchlegel

Copy link
Copy Markdown
Collaborator

Agents can now draft a rationale and publish it. Submitting the vote and signing stay with the wallet's signers.

Security fix: the pin endpoint was wide open

POST /api/pinata-storage/put had no authentication at all. Anyone on the internet could pin arbitrary content to the project's Pinata account, burn its quota, and leave no record of who did it. It now requires the app's wallet session or a v1 bearer token.

Both existing callers (registerDrep, updateDrep) are same-origin in-app fetches that already send the session cookie, so they're unaffected. Verified against a running server: unauthenticated requests to both this and the new endpoint return 401.

This is why I didn't build agent uploads on top of it earlier — doing so would have widened the hole rather than added a feature.

New endpoint

POST /api/v1/ballotRationaleAnchor turns the text already stored in a ballot's rationaleComments into a CIP-100/136 JSON-LD document, pins it, and writes the anchor URL and hash back at the same proposal index.

Anchors were previously only settable by hand — pasted in, or round-tripped through the ballot CSV. Authorization matches botBallotsUpsert: bots need ballot:write plus wallet access; humans are gated on the shared signer-or-owner check.

The hashing is the subtle part

hashDrepAnchor is not a JSON-LD canonicalisation — it's blake2b-256 over JSON.stringify(doc, null, 2). Verified empirically: reordering keys changes the digest, and the digest matches the two-space pretty form, not the minified one.

So the bytes pinned to IPFS must be exactly that form, or a verifier fetching the URL and re-hashing gets a different answer than the chain records. serializeRationale is the single place that decides those bytes, and both the pin and the hash read from it.

Checked end to end against the real hashDrepAnchor:

anchor.hash    : e7eaf133e4df1c840f366aa08430356c7e97596460c73bb661662777df43b3bf
rehash(pinned) : e7eaf133e4df1c840f366aa08430356c7e97596460c73bb661662777df43b3bf

Empty optional fields are omitted rather than emitted as empty strings, since every key changes the hash.

Boundary

ballot_publish_rationale (scope ballots:write) is the second and last write tool. mcpTools.test.ts now asserts exactly two writable tools, both non-destructive, so widening that list stays a deliberate act rather than a quiet registry addition.

Verification

tsc clean, build green, 814 + 76 tests. Nine new tests on the anchor: context shape, 300-char summary truncation, empty-field omission, pretty-JSON serialization, hash === blake2b256(pinned bytes), determinism, and filename sanitisation (rationale-aa11#0/../../etc/passwdrationale-aa11-0.jsonld).

🤖 Generated with Claude Code

…endpoint

Agents can now draft a rationale and publish it; submitting the vote and
signing remain with the wallet's signers.

**Security fix.** POST /api/pinata-storage/put had no authentication at
all — anyone on the internet could pin arbitrary content to the project's
Pinata account, burn its quota, and leave no record of who did it. It now
requires the app's wallet session or a v1 bearer token. Both existing
callers (registerDrep, updateDrep) are same-origin in-app fetches that
already send the session cookie, so they are unaffected.

**New endpoint.** POST /api/v1/ballotRationaleAnchor turns the text already
stored in a ballot's rationaleComments into a CIP-100/136 JSON-LD
document, pins it, and writes the anchor URL and hash back at the same
proposal index. Anchors were previously only settable by hand — pasted in,
or round-tripped through the ballot CSV. Authorization matches
botBallotsUpsert: bots need ballot:write plus wallet access, humans are
gated on the shared signer-or-owner check.

**The hashing is the subtle part.** hashDrepAnchor is not a JSON-LD
canonicalisation — it is blake2b-256 over JSON.stringify(doc, null, 2).
Verified empirically: reordering keys changes the digest, and the digest
matches the two-space pretty form, not the minified one. So the bytes
pinned must be exactly that form or a verifier fetching the URL and
re-hashing gets a different answer than the chain records.
serializeRationale is the single place that decides those bytes, and both
the pin and the hash read from it. Checked end to end against the real
hashDrepAnchor: hash === blake2b256(pinned bytes).

Empty optional fields are omitted rather than emitted as empty strings,
since every key changes the hash.

Extracts the Pinata call into src/lib/server/pinataUpload.ts so the new
endpoint pins directly instead of looping back through an HTTP route.

The MCP tool `ballot_publish_rationale` (scope ballots:write) is the
second and last write tool. mcpTools.test.ts now asserts exactly two
writable tools, both non-destructive — widening that list stays a
deliberate act.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multisig Ready Ready Preview Aug 13, 2026 10:57am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant