diff --git a/src/__tests__/mcpTools.test.ts b/src/__tests__/mcpTools.test.ts index a7fe7eee..977bb849 100644 --- a/src/__tests__/mcpTools.test.ts +++ b/src/__tests__/mcpTools.test.ts @@ -34,6 +34,8 @@ describe("MCP tool registry", () => { "governance_open_proposals", "ballot_upsert", "ballot_publish_rationale", + "document_list", + "document_get", ]); }); diff --git a/src/data/mcp-tools.ts b/src/data/mcp-tools.ts index 880f4fed..173997d2 100644 --- a/src/data/mcp-tools.ts +++ b/src/data/mcp-tools.ts @@ -10,7 +10,11 @@ export type McpToolSummary = { name: string; - scope: "wallets:read" | "governance:read" | "ballots:write"; + scope: + | "wallets:read" + | "governance:read" + | "ballots:write" + | "documents:read"; /** One line, phrased for someone deciding whether to connect. */ blurb: string; }; @@ -34,7 +38,8 @@ export const MCP_TOOL_SUMMARIES: McpToolSummary[] = [ { name: "multisig_list_free_utxos", scope: "wallets:read", - blurb: "UTxOs not already locked by a pending transaction — what you can actually spend.", + blurb: + "UTxOs not already locked by a pending transaction — what you can actually spend.", }, { name: "multisig_list_proxies", @@ -49,7 +54,8 @@ export const MCP_TOOL_SUMMARIES: McpToolSummary[] = [ { name: "multisig_lookup_wallet", scope: "wallets:read", - blurb: "Find on-chain multisig registration metadata by participant key hash.", + blurb: + "Find on-chain multisig registration metadata by participant key hash.", }, { name: "governance_list_active_proposals", @@ -69,16 +75,31 @@ export const MCP_TOOL_SUMMARIES: McpToolSummary[] = [ { name: "governance_open_proposals", scope: "governance:read", - blurb: "Active proposals you have not voted on yet — the outstanding decisions.", + blurb: + "Active proposals you have not voted on yet — the outstanding decisions.", }, { name: "ballot_upsert", scope: "ballots:write", - blurb: "Create or update a ballot draft: a choice per proposal, plus rationale text.", + blurb: + "Create or update a ballot draft: a choice per proposal, plus rationale text.", }, { name: "ballot_publish_rationale", scope: "ballots:write", - blurb: "Publish a rationale to IPFS and record its anchor, ready for you to vote.", + blurb: + "Publish a rationale to IPFS and record its anchor, ready for you to vote.", + }, + { + name: "document_list", + scope: "documents:read", + blurb: + "Sign-off documents for a wallet, and who still needs to sign each one.", + }, + { + name: "document_get", + scope: "documents:read", + blurb: + "One document in full: every version, its hash, and who approved it.", }, ]; diff --git a/src/lib/documents/summary.ts b/src/lib/documents/summary.ts new file mode 100644 index 00000000..06a2cfa9 --- /dev/null +++ b/src/lib/documents/summary.ts @@ -0,0 +1,127 @@ +import { evaluateThreshold } from "./payload"; + +/** + * Compact projections of a document for the REST/MCP surface. + * + * Two reasons this exists rather than returning the Prisma rows: + * + * - SIZE. A version may carry up to 512KB of inline base64 (`contentInline`). + * Handing that to a model as tool output is pure waste, and it is never what + * a caller asking "what needs signing?" wants. + * - RESTRAINT. These endpoints back MCP tools, so whatever they return becomes + * model context. The projection is an allowlist: fields are here because a + * caller needs them, so a new column on DocumentVersion cannot silently + * start flowing to a model. + * + * Titles, descriptions and comments ARE included — they are the point of the + * feature and cannot be withheld — but they are user-authored strings, which is + * exactly why nothing on this surface can write or sign. + */ + +type ReviewLike = { + signerAddress: string; + action: string; + signedAt?: Date | string | null; + comment?: string | null; +}; + +type SnapshotLike = { + signersAddresses: string[]; + requiredSigners: number; +} | null; + +type VersionLike = { + id: string; + versionNumber: number; + contentHash: string; + hashAlgorithm: string; + status: string; + fileName?: string | null; + mimeType?: string | null; + fileSize?: number | null; + storageMode: string; + createdBy: string; + createdAt: Date | string; + decidedAt?: Date | string | null; + supersededAt?: Date | string | null; + reviews?: ReviewLike[]; + signerSnapshot?: SnapshotLike; +}; + +type DocumentLike = { + id: string; + walletId: string; + title: string; + description?: string | null; + documentType?: string | null; + status: string; + createdBy: string; + createdAt: Date | string; + updatedAt: Date | string; + versions?: VersionLike[]; +}; + +const iso = (value: Date | string | null | undefined): string | null => + value ? new Date(value).toISOString() : null; + +export function summariseVersion(version: VersionLike) { + const reviews = version.reviews ?? []; + const approvals = reviews.filter((r) => r.action === "approve").length; + const rejections = reviews.filter((r) => r.action === "reject").length; + const snapshot = version.signerSnapshot ?? null; + + const acted = new Set(reviews.map((r) => r.signerAddress)); + const awaiting = (snapshot?.signersAddresses ?? []).filter( + (address) => !acted.has(address), + ); + + return { + versionId: version.id, + versionNumber: version.versionNumber, + status: version.status, + contentHash: version.contentHash, + hashAlgorithm: version.hashAlgorithm, + fileName: version.fileName ?? null, + mimeType: version.mimeType ?? null, + fileSize: version.fileSize ?? null, + storageMode: version.storageMode, + createdBy: version.createdBy, + createdAt: iso(version.createdAt), + decidedAt: iso(version.decidedAt), + supersededAt: iso(version.supersededAt), + approvals, + rejections, + requiredSigners: snapshot?.requiredSigners ?? null, + // Only meaningful once a round has started and a snapshot exists. + awaitingSignatures: snapshot ? awaiting : null, + // Recomputed from the frozen snapshot rather than read off the row, so a + // caller sees the same rule the server enforces. + outcome: snapshot + ? evaluateThreshold({ + approvals, + rejections, + signerCount: snapshot.signersAddresses.length, + requiredSigners: snapshot.requiredSigners, + }) + : null, + }; +} + +export function summariseDocument(document: DocumentLike) { + const versions = (document.versions ?? []).map(summariseVersion); + return { + documentId: document.id, + walletId: document.walletId, + title: document.title, + description: document.description ?? null, + documentType: document.documentType ?? null, + status: document.status, + createdBy: document.createdBy, + createdAt: iso(document.createdAt), + updatedAt: iso(document.updatedAt), + versionCount: versions.length, + /** Highest version number — the one a signer would act on. */ + latestVersion: versions[0] ?? null, + versions, + }; +} diff --git a/src/lib/mcp/schemas.ts b/src/lib/mcp/schemas.ts index bd671318..27054bed 100644 --- a/src/lib/mcp/schemas.ts +++ b/src/lib/mcp/schemas.ts @@ -17,13 +17,14 @@ export type JsonSchema = Record; const walletId = { type: "string", minLength: 1, - description: "Wallet UUID from the multisig database (not a Cardano address).", + description: + "Wallet UUID from the multisig database (not a Cardano address).", } as const; const network = { type: "string", enum: ["0", "1"], - description: "Cardano network: \"0\" = preprod, \"1\" = mainnet.", + description: 'Cardano network: "0" = preprod, "1" = mainnet.', } as const; export const EMPTY_INPUT: JsonSchema = { @@ -39,6 +40,33 @@ export const WALLET_ONLY_INPUT: JsonSchema = { additionalProperties: false, }; +export const DOCUMENT_LIST_INPUT: JsonSchema = { + type: "object", + properties: { + walletId, + includeArchived: { + type: "boolean", + default: false, + description: "Include archived documents. Off by default.", + }, + }, + required: ["walletId"], + additionalProperties: false, +}; + +export const DOCUMENT_GET_INPUT: JsonSchema = { + type: "object", + properties: { + documentId: { + type: "string", + description: + "Document id, as returned by document_list in the documentId field.", + }, + }, + required: ["documentId"], + additionalProperties: false, +}; + export const FREE_UTXOS_INPUT: JsonSchema = { type: "object", properties: { @@ -132,11 +160,13 @@ export const BALLOT_UPSERT_INPUT: JsonSchema = { proposalId: { type: "string", minLength: 1, - description: "Governance proposal id, in # form.", + description: + "Governance proposal id, in # form.", }, proposalTitle: { type: "string", - description: "Human-readable proposal title. Required by the handler.", + description: + "Human-readable proposal title. Required by the handler.", }, choice: { type: "string", @@ -213,7 +243,8 @@ export const PUBLISH_RATIONALE_INPUT: JsonSchema = { proposalId: { type: "string", minLength: 1, - description: "Governance proposal id (#) on that ballot.", + description: + "Governance proposal id (#) on that ballot.", }, summary: { type: "string", diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts index c98e931e..36a5c09d 100644 --- a/src/lib/mcp/scopes.ts +++ b/src/lib/mcp/scopes.ts @@ -14,6 +14,7 @@ export const MCP_SCOPES = [ "wallets:read", "governance:read", "ballots:write", + "documents:read", ] as const; export type McpScope = (typeof MCP_SCOPES)[number]; @@ -28,6 +29,10 @@ export const MCP_SCOPE_DESCRIPTIONS: Record = { // public and effectively permanent — "ballot drafts" alone undersells that. "ballots:write": "Create and update governance ballot drafts, and publish rationale documents publicly to IPFS. Cannot vote on-chain.", + // Read-only and says so. Sign-off approvals are CIP-8 signatures from a named + // human signer; nothing reachable through MCP can produce one. + "documents:read": + "Read your wallets' sign-off documents: titles, version history, content hashes and who still needs to sign. Cannot create, edit, approve or sign anything.", }; export function isMcpScope(value: string): value is McpScope { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 910a1d45..e60a9559 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -6,6 +6,8 @@ import type { McpScope } from "@/lib/mcp/scopes"; import { ACTIVE_PROPOSALS_INPUT, BALLOT_UPSERT_INPUT, + DOCUMENT_GET_INPUT, + DOCUMENT_LIST_INPUT, OPEN_PROPOSALS_INPUT, PUBLISH_RATIONALE_INPUT, VOTE_HISTORY_INPUT, @@ -57,8 +59,16 @@ export type McpToolDef = { run: (args: Record, ctx: ToolContext) => Promise; }; -const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false } as const; -const READ_ONLY_CHAIN = { readOnlyHint: true, idempotentHint: true, openWorldHint: true } as const; +const READ_ONLY = { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, +} as const; +const READ_ONLY_CHAIN = { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, +} as const; /** * Lazy handler imports. @@ -83,6 +93,8 @@ const load = { drepInfo: () => import("@/pages/api/v1/drepInfo"), drepVotes: () => import("@/pages/api/governance/drepVotes"), ballotRationaleAnchor: () => import("@/pages/api/v1/ballotRationaleAnchor"), + documents: () => import("@/pages/api/v1/documents"), + documentDetail: () => import("@/pages/api/v1/documentDetail"), }; /** Vote history is two hops: resolve the wallet's DRep, then read its votes. */ @@ -367,12 +379,19 @@ export const MCP_TOOLS: McpToolDef[] = [ const active = await callV1(load.governanceActiveProposals, ctx, { method: "GET", - query: { network, count: String(count), page: "1", order: "desc", details: "false" }, + query: { + network, + count: String(count), + page: "1", + order: "desc", + details: "false", + }, }); if (active.status >= 400) return active; const proposals = - (active.body as { proposals?: { proposalId: string }[] }).proposals ?? []; + (active.body as { proposals?: { proposalId: string }[] }).proposals ?? + []; // A missing DRep or a Koios hiccup must not sink the whole answer — fall // back to "we don't know what was voted" rather than failing the call. @@ -388,10 +407,16 @@ export const MCP_TOOLS: McpToolDef[] = [ const annotated = proposals.map((p) => { const ours = voted.get(p.proposalId); - return { ...p, alreadyVoted: Boolean(ours), ourVote: ours?.vote ?? null }; + return { + ...p, + alreadyVoted: Boolean(ours), + ourVote: ours?.vote ?? null, + }; }); const includeVoted = args.includeVoted === true; - const rows = includeVoted ? annotated : annotated.filter((p) => !p.alreadyVoted); + const rows = includeVoted + ? annotated + : annotated.filter((p) => !p.alreadyVoted); return { status: 200, @@ -467,8 +492,52 @@ export const MCP_TOOLS: McpToolDef[] = [ ...(args.counterargumentDiscussion !== undefined ? { counterargumentDiscussion: args.counterargumentDiscussion } : {}), - ...(args.conclusion !== undefined ? { conclusion: args.conclusion } : {}), - ...(args.references !== undefined ? { references: args.references } : {}), + ...(args.conclusion !== undefined + ? { conclusion: args.conclusion } + : {}), + ...(args.references !== undefined + ? { references: args.references } + : {}), + }, + }), + }, + { + name: "document_list", + title: "List sign-off documents", + description: + "List a wallet's sign-off documents with their version history: content hashes, approval counts, the threshold each round needs, and which signers have not signed yet. Read-only — approving a document requires a signature from a wallet signer and cannot be done through this tool.", + scope: "documents:read", + inputSchema: DOCUMENT_LIST_INPUT, + annotations: READ_ONLY, + v1Path: "documents.ts", + run: async (args, ctx) => + wrapArray( + await callV1(load.documents, ctx, { + method: "GET", + query: { + walletId: String(args.walletId), + address: ctx.caller.subject, + ...(args.includeArchived ? { includeArchived: "true" } : {}), + }, + }), + "documents", + ), + }, + { + name: "document_get", + title: "Get a sign-off document", + description: + "Get one sign-off document by id: every version with its content hash and status, who approved or rejected each one, and the document's audit history. Read-only.", + scope: "documents:read", + inputSchema: DOCUMENT_GET_INPUT, + annotations: READ_ONLY, + v1Path: "documentDetail.ts", + run: async (args, ctx) => + callV1(load.documentDetail, ctx, { + method: "GET", + query: { + documentId: String(args.documentId), + address: ctx.caller.subject, }, }), }, diff --git a/src/pages/api/v1/documentDetail.ts b/src/pages/api/v1/documentDetail.ts new file mode 100644 index 00000000..7247a726 --- /dev/null +++ b/src/pages/api/v1/documentDetail.ts @@ -0,0 +1,103 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { summariseDocument } from "@/lib/documents/summary"; +import { addCorsCacheBustingHeaders, cors } from "@/lib/cors"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { + applyBotRateLimit, + applyRateLimit, +} from "@/lib/security/requestGuards"; +import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; + +/** + * GET /api/v1/documentDetail?documentId=&address= — one document with its + * version history and audit events. + * + * Same rules as `documents.ts`: authorization goes through + * `caller.document.getById` rather than being re-implemented, the response is a + * projection, and bot keys are excluded. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/documentDetail" })) return; + + await cors(req, res); + if (req.method === "OPTIONS") return res.status(200).end(); + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer )", + }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (isBotJwt(payload) && !applyBotRateLimit(req, res, payload.botId)) return; + + const { documentId, address } = req.query; + if (typeof address !== "string") { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + if (typeof documentId !== "string") { + return res.status(400).json({ error: "Invalid documentId parameter" }); + } + + if (isBotJwt(payload)) { + // See documents.ts: sign-off is a human accountability record. + return res + .status(403) + .json({ error: "Document sign-off is not available to bot keys" }); + } + + try { + const caller = createCaller({ + db, + session: { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + sessionAddress: payload.address, + sessionWallets: [payload.address], + primaryWallet: payload.address, + ip: getClientIP(req), + }); + + const document = await caller.document.getById({ documentId }); + if (!document) { + return res.status(404).json({ error: "Document not found" }); + } + + return res.status(200).json({ + ...summariseDocument(document), + events: (document.events ?? []).map((event) => ({ + type: event.type, + actorAddress: event.actorAddress, + createdAt: new Date(event.createdAt).toISOString(), + })), + }); + } catch (error) { + console.error("Error in documentDetail handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +} diff --git a/src/pages/api/v1/documents.ts b/src/pages/api/v1/documents.ts new file mode 100644 index 00000000..c2dba689 --- /dev/null +++ b/src/pages/api/v1/documents.ts @@ -0,0 +1,102 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { addCorsCacheBustingHeaders, cors } from "@/lib/cors"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { + applyBotRateLimit, + applyRateLimit, +} from "@/lib/security/requestGuards"; +import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; +import { summariseDocument } from "@/lib/documents/summary"; + +/** + * GET /api/v1/documents?walletId=&address= — sign-off documents for a wallet. + * + * Authorization is not re-implemented here: the request goes through + * `caller.document.listByWallet`, so the same signer-or-owner rule the UI uses + * applies, and there is one place to change it. + * + * The response is a deliberate PROJECTION, not the raw rows — see + * `summariseDocument`. This endpoint backs an MCP tool, so its output reaches a + * model, and the raw rows carry up to 512KB of inline base64 per version. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/documents" })) return; + + await cors(req, res); + if (req.method === "OPTIONS") return res.status(200).end(); + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer )", + }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (isBotJwt(payload)) { + if (!applyBotRateLimit(req, res, payload.botId)) return; + // Bot keys are deliberately excluded. Sign-off is a human accountability + // record: every approval is a CIP-8 signature from a named wallet signer, + // and an automated identity has no standing in it. Reading alone is + // harmless, but it is the first step of a surface that only makes sense for + // people, so it waits for a reason to exist. + return res + .status(403) + .json({ error: "Document sign-off is not available to bot keys" }); + } + + const { walletId, address, includeArchived } = req.query; + if (typeof address !== "string") { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + if (typeof walletId !== "string") { + return res.status(400).json({ error: "Invalid walletId parameter" }); + } + + try { + const caller = createCaller({ + db, + session: { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + sessionAddress: payload.address, + sessionWallets: [payload.address], + primaryWallet: payload.address, + ip: getClientIP(req), + }); + + const documents = await caller.document.listByWallet({ + walletId, + includeArchived: includeArchived === "true", + }); + + return res.status(200).json(documents.map(summariseDocument)); + } catch (error) { + console.error("Error in documents handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +}