diff --git a/apps/web/src/features/mcp/handler.ts b/apps/web/src/features/mcp/handler.ts index e3025ba1..52367f43 100644 --- a/apps/web/src/features/mcp/handler.ts +++ b/apps/web/src/features/mcp/handler.ts @@ -178,21 +178,38 @@ export async function handleMcpRequest(request: Request): Promise { }); const secret = bearerSecret(request); if (!secret) return unauthorized(); - if (!getOptionalServiceDatabase() || !origin) - return problem(503, "MCP_UNAVAILABLE", "MCP is not available here"); - const repository = accessTokenRepository(); - const token = await repository.authenticate(secret); - if (!token) return unauthorized(); - if (token.windowCount > accessTokenRequestsPerMinute) + let repository: ReturnType; + let token: Awaited>; + let session: Awaited>; + try { + if (!getOptionalServiceDatabase() || !origin) + return problem(503, "MCP_UNAVAILABLE", "MCP is not available here"); + repository = accessTokenRepository(); + token = await repository.authenticate(secret); + if (!token) return unauthorized(); + if (token.windowCount > accessTokenRequestsPerMinute) + return problem( + 429, + "MCP_RATE_LIMITED", + "Too many requests for this token", + { + "retry-after": String(token.windowResetSeconds), + }, + ); + session = await accessTokenCommerceSession(token); + } catch { + // Only authentication setup is retryable here: no tool has run. Keep + // provider/database details private, and do not mislabel an outage as an + // invalid token or encourage a retry after a tool may have changed data. return problem( - 429, - "MCP_RATE_LIMITED", - "Too many requests for this token", + 503, + "MCP_UNAVAILABLE", + "MCP is temporarily unavailable. Try again shortly.", { - "retry-after": String(token.windowResetSeconds), + "retry-after": "5", }, ); - const session = await accessTokenCommerceSession(token); + } if (!session?.accessToken) return unauthorized(); const body = await readMessage(request); if (!body.ok) return body.response; diff --git a/apps/web/src/features/mcp/mcp-owner-rule.integration.test.ts b/apps/web/src/features/mcp/mcp-owner-rule.integration.test.ts index 9635b8ee..0cf6f82f 100644 --- a/apps/web/src/features/mcp/mcp-owner-rule.integration.test.ts +++ b/apps/web/src/features/mcp/mcp-owner-rule.integration.test.ts @@ -144,7 +144,13 @@ const person = { email: `mcp.rule.${tag}@clockwork.test`, name: `MCP Rule ${tag}`, }; -const roleTarget = { id: randomUUID(), membershipId: randomUUID() }; +// Order acceptance falls back to the first internal user by UUID. Keep this +// disposable staff fixture after the persistent seed operator so concurrent +// suites cannot assign their exception cases to a user this suite deletes. +const roleTarget = { + id: `ffff${randomUUID().slice(4)}`, + membershipId: randomUUID(), +}; let secret = ""; let next = 1; const partners: string[] = []; @@ -153,6 +159,11 @@ const requestedMndaId = randomUUID(); let countersignerId = ""; beforeAll(async () => { + const persistentOperators = await sql` + select id from commerce_users + where id = '20000000-0000-4000-8000-000000000001' + and is_internal_staff and id < ${roleTarget.id}::uuid`; + expect(persistentOperators).toHaveLength(1); await sql`insert into commerce_users (id, workos_user_id, email, name, is_internal_staff, mfa_enrolled) values (${person.id}, ${`mcp_rule_${tag}`}, ${person.email}, ${person.name}, true, true)`; await sql`insert into memberships (id, organization_id, user_id, role) @@ -318,6 +329,10 @@ describe("the owner rule", () => { nfr_capacity: "5 TiB for 60 days", }, }), + check_contract_status: () => ({ + contractId: state.templateContractId, + requestNumber: state.signingRequestNumber, + }), create_partner: () => ({ name: `Rule Northwind ${tag}` }), update_partner: () => ({ id: state.partnerId, @@ -368,6 +383,57 @@ describe("the owner rule", () => { const outcomes: Record = {}; for (const [name, args] of Object.entries(cases)) { + if (name === "check_contract_status") { + // This fixture template needs no approval. Simulate an existing sent + // provider request, then prove refresh only reads its current state. + const { contractSigningRepository } = + await import("@/src/features/internal-ops/contracts/server"); + const repository = contractSigningRepository(); + const id = String(state.templateContractId); + const { record, token } = await repository.claim(id); + const providerId = randomUUID(); + await repository.update( + id, + token, + { state: "sent", providerId }, + { kind: "user", id: person.id, display: person.name }, + ); + await repository.release(id, token); + contact.readResponses.set( + `signwell-contracts.getContract:${providerId}`, + { + id: providerId, + status: "Viewed", + test_mode: record.testMode, + metadata: { + commerce_contract_id: record.contractId, + template_sha256: record.templateHash, + }, + apply_signing_order: true, + recipients: [ + { + id: "counterparty", + name: record.counterpartySigner.name, + email: record.counterpartySigner.email, + }, + { + id: "fil-one", + name: record.countersigner.name, + email: record.countersigner.email, + }, + ], + fields: [ + ["counterparty", "fil-one"].flatMap((recipient_id) => + ["signature", "autofill_date_signed"].map((type) => ({ + recipient_id, + type, + required: true, + })), + ), + ], + }, + ); + } if (name === "check_mnda_status") { // Simulate an already sent request locally, then let the real sync // workflow observe a provider read and advance it to viewed. @@ -442,6 +508,23 @@ describe("the owner rule", () => { contact.reads.some((read) => read.startsWith("signwell-mnda.get:")), ).toBe(true); } + if (name === "prepare_from_template") { + state.templateContractId = (value.contract as { id: string }).id; + state.signingRequestNumber = ( + value.signing as { requestNumber: number } + ).requestNumber; + } + if (name === "check_contract_status") { + expect(value.signing).toMatchObject({ + state: "viewed", + requestNumber: state.signingRequestNumber, + }); + expect( + contact.reads.some((read) => + read.startsWith("signwell-contracts.getContract:"), + ), + ).toBe(true); + } if (name === "create_mnda_draft" || name === "prepare_mnda_preview") state.mndaId = (value.mnda as { id: string } | undefined)?.id ?? ""; if (name === "record_contract") @@ -463,6 +546,7 @@ describe("the owner rule", () => { create_mnda_draft: true, prepare_mnda_preview: true, check_mnda_status: true, + check_contract_status: true, record_contract: true, prepare_contract_for_signature: true, prepare_from_template: true, diff --git a/apps/web/src/features/mcp/mcp-pagination.test.ts b/apps/web/src/features/mcp/mcp-pagination.test.ts new file mode 100644 index 00000000..e7482386 --- /dev/null +++ b/apps/web/src/features/mcp/mcp-pagination.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { McpToolContext } from "./tool"; + +const mocks = vi.hoisted(() => ({ + staff: vi.fn(), + pricing: vi.fn(), + handoffs: vi.fn(), + partners: vi.fn(), + owners: vi.fn(), +})); +vi.mock("@/src/auth/session", () => ({ + explicitDemoIdentityEnabled: () => false, + getRequestCommerceSession: mocks.staff, +})); +vi.mock("@/src/features/internal-ops/contracts/server", () => ({ + contractStaff: mocks.staff, + sessionHas: vi.fn(), +})); +vi.mock( + "@/src/features/internal-ops/sales-pricing/scenario-server", + async (original) => ({ + ...(await original()), + scenarioRepository: () => ({ listPage: mocks.pricing }), + }), +); +vi.mock("@/src/features/internal-ops/sales-pricing/scenario-actions", () => ({ + saveScenario: vi.fn(), +})); +vi.mock("@/src/features/internal-ops/handoff/server", () => ({ + handoffRepository: () => ({ listPage: mocks.handoffs }), +})); +vi.mock("@/src/features/internal-ops/handoff/actions", () => ({ + requestHandoff: vi.fn(), +})); +vi.mock("@/src/features/internal-ops/partners/server", () => ({ + partnerRepository: () => ({ listPage: mocks.partners, owners: mocks.owners }), + partnerToday: () => "2026-10-11", + loadPartner: vi.fn(), +})); +vi.mock("@/src/features/internal-ops/partners/actions", () => ({ + savePartner: vi.fn(), + savePartnerDeal: vi.fn(), +})); + +const { listPricingScenarios } = await import("./tools/pricing"); +const { listHandoffs } = await import("./tools/handoffs"); +const { listPartners } = await import("./tools/partners"); +const userId = "20000000-0000-4000-8000-000000000001"; +const context = () => + ({ origin: "https://commerce.fil.one", targets: {} }) as McpToolContext; +beforeEach(() => { + vi.clearAllMocks(); + mocks.staff.mockResolvedValue({ userId, roles: ["revenue"] }); + for (const method of [mocks.pricing, mocks.handoffs, mocks.partners]) + method.mockResolvedValue({ rows: [], nextCursor: "next-page" }); + mocks.owners.mockResolvedValue([]); +}); + +it.each([false, true])( + "forwards pricing pagination while respecting administrator=%s scope", + async (admin) => { + mocks.staff.mockResolvedValue({ + userId, + roles: [admin ? "commerce_admin" : "revenue"], + }); + expect( + await listPricingScenarios.run( + { limit: 7, cursor: "previous-page" }, + context(), + ), + ).toMatchObject({ scenarios: [], nextCursor: "next-page" }); + expect(mocks.staff).toHaveBeenCalledWith("sales:read"); + expect(mocks.pricing).toHaveBeenCalledWith( + admin ? { kind: "all" } : { kind: "own", ownerId: userId }, + { limit: 7, cursor: "previous-page" }, + ); + }, +); +it.each(["mine", "queue"])( + "filters %s handoffs in the repository before limiting", + async (scope) => { + expect( + await listHandoffs.run( + { scope, status: "open", limit: 3, cursor: "previous-page" }, + context(), + ), + ).toMatchObject({ handoffs: [], nextCursor: "next-page" }); + expect(mocks.staff).toHaveBeenCalledWith( + scope === "mine" ? "contract:write" : "operations:read", + ); + expect(mocks.handoffs).toHaveBeenCalledWith( + scope === "mine" ? { kind: "own", userId } : { kind: "all" }, + "open", + { limit: 3, cursor: "previous-page" }, + ); + }, +); +it("forwards partner filters, viewer scope and cursor without a capped UI read", async () => { + expect( + await listPartners.run( + { + search: "Northwind", + status: "active", + mine: true, + limit: 11, + cursor: "previous-page", + }, + context(), + ), + ).toMatchObject({ partners: [], nextCursor: "next-page", owners: [] }); + expect(mocks.staff).toHaveBeenCalledWith("sales:read"); + expect(mocks.partners).toHaveBeenCalledWith( + expect.objectContaining({ q: "Northwind", status: "active", mine: true }), + { viewerId: userId, today: "2026-10-11" }, + { limit: 11, cursor: "previous-page" }, + ); +}); +it.each([listPricingScenarios, listHandoffs, listPartners])( + "refuses $name before repository access when permission is missing", + async (tool) => { + mocks.staff.mockRejectedValue(new Error("CONTRACT_FORBIDDEN")); + await expect(tool.run({}, context())).rejects.toThrow("CONTRACT_FORBIDDEN"); + expect(mocks.pricing).not.toHaveBeenCalled(); + expect(mocks.handoffs).not.toHaveBeenCalled(); + expect(mocks.partners).not.toHaveBeenCalled(); + }, +); diff --git a/apps/web/src/features/mcp/mcp-transport.test.ts b/apps/web/src/features/mcp/mcp-transport.test.ts index 93570c8b..faab1d9b 100644 --- a/apps/web/src/features/mcp/mcp-transport.test.ts +++ b/apps/web/src/features/mcp/mcp-transport.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), recordToolCall: vi.fn(), + accessTokenSession: vi.fn(), + runWithSession: vi.fn((_session: unknown, run: () => unknown) => run()), session: { userId: "20000000-0000-4000-8000-0000000000aa", profile: { name: "Transport tester", email: "tester@clockwork.test" }, @@ -20,11 +22,11 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/src/auth/access-token-session", () => ({ accessTokenRepository: () => mocks, - accessTokenCommerceSession: () => Promise.resolve(mocks.session), + accessTokenCommerceSession: mocks.accessTokenSession, })); vi.mock("@/src/auth/session", () => ({ explicitDemoIdentityEnabled: () => false, - runWithAccessTokenSession: (_session: unknown, run: () => unknown) => run(), + runWithAccessTokenSession: mocks.runWithSession, })); vi.mock("@/src/auth/demo-deploy", () => ({ demoDeployIdentityEnabled: () => false, @@ -71,10 +73,61 @@ beforeEach(() => { windowResetSeconds: 60, }); mocks.recordToolCall.mockReset().mockResolvedValue(undefined); + mocks.accessTokenSession.mockReset().mockResolvedValue(mocks.session); + mocks.runWithSession.mockClear(); }); afterEach(() => vi.unstubAllEnvs()); +describe("MCP authentication availability", () => { + it.each(["authenticate", "accessTokenSession"] as const)( + "returns a sanitized retryable outage when %s fails before a tool can run", + async (stage) => { + mocks[stage].mockRejectedValueOnce( + new Error("private database URL and identity details"), + ); + const incoming = request(); + const response = await handleMcpRequest( + new Request(incoming, { + body: JSON.stringify({ + jsonrpc: "2.0", + id: 9, + method: "tools/call", + params: { name: "whoami", arguments: {} }, + }), + }), + ); + expect(response.status).toBe(503); + expect(response.headers.get("content-type")).toContain( + "application/problem+json", + ); + expect(response.headers.get("retry-after")).toBe("5"); + expect(response.headers.get("www-authenticate")).toBeNull(); + const body = await response.text(); + expect(JSON.parse(body)).toMatchObject({ + code: "MCP_UNAVAILABLE", + status: 503, + }); + expect(body).not.toContain("private"); + expect(mocks.runWithSession).not.toHaveBeenCalled(); + expect(mocks.recordToolCall).not.toHaveBeenCalled(); + }, + ); + + it("keeps invalid tokens and missing memberships as authentication refusals", async () => { + mocks.authenticate.mockResolvedValueOnce(null); + const invalidToken = await handleMcpRequest(request()); + expect(invalidToken.status).toBe(401); + expect(invalidToken.headers.get("www-authenticate")).toContain( + "invalid_token", + ); + mocks.accessTokenSession.mockResolvedValueOnce(null); + const missingMembership = await handleMcpRequest(request()); + expect(missingMembership.status).toBe(401); + expect(mocks.runWithSession).not.toHaveBeenCalled(); + }); +}); + describe("MCP transport origin validation", () => { it.each([ "https://unrelated.invalid", diff --git a/apps/web/src/features/mcp/mcp-workflows.test.ts b/apps/web/src/features/mcp/mcp-workflows.test.ts index 1474b2b4..b2cd5fa1 100644 --- a/apps/web/src/features/mcp/mcp-workflows.test.ts +++ b/apps/web/src/features/mcp/mcp-workflows.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { fixtureRecord as mndaFixture } from "../../../../../packages/contracts/src/mnda-fixture"; import type { McpToolContext } from "./tool"; @@ -7,7 +10,9 @@ const mocks = vi.hoisted(() => ({ loadOlderNotifications: vi.fn(), loadMndas: vi.fn(), prepareMnda: vi.fn(), + readMnda: vi.fn(), loadContract: vi.fn(), + operateContract: vi.fn(), saveContract: vi.fn(), addFile: vi.fn(), prepareCounterpartyPaper: vi.fn(), @@ -29,10 +34,11 @@ vi.mock("@/src/features/internal-ops/mnda/actions", () => ({ })); vi.mock("@/src/features/internal-ops/mnda/server", () => ({ mndaActor: vi.fn(), - mndaRepository: vi.fn(), + mndaRepository: () => ({ get: mocks.readMnda }), mndaStaff: vi.fn(), })); vi.mock("@/src/features/internal-ops/contracts/actions", () => ({ + operateContract: mocks.operateContract, saveContract: mocks.saveContract, prepareCounterpartyPaper: mocks.prepareCounterpartyPaper, prepareContract: vi.fn(), @@ -58,8 +64,9 @@ vi.mock("@/src/features/internal-ops/contracts/http", () => ({ })); const { listNotifications } = await import("./tools/notifications"); -const { createMndaDraft } = await import("./tools/mndas"); -const { prepareContractForSignature, recordContract } = +const { createMndaDraft, listMndas, getMnda, prepareMndaPreview } = + await import("./tools/mndas"); +const { prepareContractForSignature, recordContract, checkContractStatus } = await import("./tools/contracts"); const contractId = "30000000-0000-4000-8000-000000000001"; @@ -74,6 +81,117 @@ const context = () => beforeEach(() => vi.resetAllMocks()); describe("MCP workflow boundaries", () => { + it("exposes active countersigner IDs for an MNDA draft without exposing inactive choices", async () => { + mocks.loadMndas.mockResolvedValue({ + ok: true, + value: { + register: { total: 0, page: 1, pageSize: 25, records: [] }, + signers: [ + { + id: signerId, + name: "Signer", + title: "Director", + active: true, + isDefault: true, + email: "signer@example.test", + }, + { + id: unavailableSignerId, + name: "Retired signer", + active: false, + isDefault: false, + }, + ], + }, + }); + const result = await listMndas.run({}, context()); + expect(result).toMatchObject({ + countersigners: [ + { id: signerId, name: "Signer", title: "Director", isDefault: true }, + ], + }); + expect(JSON.stringify(result)).not.toContain(unavailableSignerId); + expect(JSON.stringify(result)).not.toContain("signer@example.test"); + }); + + it.each([getMnda, prepareMndaPreview])( + "returns saved draft detail through $name before revising", + async (tool) => { + const record = { + ...mndaFixture, + input: { + ...mndaFixture.input, + shortName: "Acme", + entityDescription: "a Delaware corporation", + streetAddress: "1 Main Street", + locality: "Wilmington, DE", + noticesContact: "Legal department", + noticesEmail: "notices@acme.example", + }, + }; + mocks.readMnda.mockResolvedValue(record); + const result = await tool.run({ id: record.id }, context()); + const detail = + tool.name === "get_mnda" ? result : (result as { mnda: unknown }).mnda; + expect(detail).toMatchObject({ + details: { + detailsMode: record.input.detailsMode, + shortName: "Acme", + entityDescription: "a Delaware corporation", + streetAddress: "1 Main Street", + locality: "Wilmington, DE", + noticesContact: "Legal department", + noticesEmail: "notices@acme.example", + countersignerId: record.countersigner.id, + }, + }); + expect(mocks.prepareMnda).not.toHaveBeenCalled(); + }, + ); + + it("requires an explicit signing request number for a contract refresh", () => { + expect( + z.object(checkContractStatus.input).safeParse({ contractId }).success, + ).toBe(false); + }); + + it("refuses refresh after the signing request has been replaced", async () => { + mocks.loadContract.mockResolvedValue({ + kind: "ready", + signing: { requestNumber: 2 }, + }); + await expect( + checkContractStatus.run({ contractId, requestNumber: 1 }, context()), + ).rejects.toMatchObject({ code: "CONTRACT_REQUEST_CHANGED" }); + expect(mocks.operateContract).not.toHaveBeenCalled(); + }); + + it("refuses refresh when the contract has no signing request", async () => { + mocks.loadContract.mockResolvedValue({ kind: "ready", signing: null }); + await expect( + checkContractStatus.run({ contractId, requestNumber: 1 }, context()), + ).rejects.toMatchObject({ code: "CONTRACT_SIGNING_NOT_FOUND" }); + expect(mocks.operateContract).not.toHaveBeenCalled(); + }); + + it("passes the exact request number and only sync to the existing contract action", async () => { + mocks.loadContract.mockResolvedValue({ + kind: "ready", + signing: { requestNumber: 3 }, + }); + mocks.operateContract.mockResolvedValue({ + ok: false, + code: "STOP_AFTER_SYNC", + }); + await expect( + checkContractStatus.run({ contractId, requestNumber: 3 }, context()), + ).rejects.toMatchObject({ code: "STOP_AFTER_SYNC" }); + expect(mocks.operateContract).toHaveBeenCalledExactlyOnceWith({ + contractId, + requestNumber: 3, + operation: "sync", + }); + }); it("returns a usable cursor when an unread-only page contains only read notifications", async () => { const read = { id: "40000000-0000-4000-8000-000000000001", diff --git a/apps/web/src/features/mcp/mcp.test.ts b/apps/web/src/features/mcp/mcp.test.ts index 9fa8cb27..6a126917 100644 --- a/apps/web/src/features/mcp/mcp.test.ts +++ b/apps/web/src/features/mcp/mcp.test.ts @@ -100,6 +100,7 @@ describe("the MCP tool set", () => { .sort(); expect(writes).toEqual( [ + "check_contract_status", "check_mnda_status", "create_handoff_request", "create_mnda_draft", diff --git a/apps/web/src/features/mcp/tools/contracts.ts b/apps/web/src/features/mcp/tools/contracts.ts index 0e8098d5..e3166a92 100644 --- a/apps/web/src/features/mcp/tools/contracts.ts +++ b/apps/web/src/features/mcp/tools/contracts.ts @@ -23,6 +23,7 @@ import { } from "@clockwork/contracts"; import { + operateContract, prepareContract, prepareCounterpartyPaper, saveContract, @@ -260,6 +261,36 @@ export const getContract = defineTool({ }, }); +export const checkContractStatus = defineTool({ + name: "check_contract_status", + title: "Refresh a contract's signing status", + description: + "Refresh one signing request from SignWell, as the contract page's Refresh button does. Pass the requestNumber returned by get_contract; a replaced request is refused. This only reads provider status and saves what it observes: it never sends, reminds, voids or approves a request.", + input: { + contractId: z.uuid(), + requestNumber: z.int().positive(), + }, + access: "write", + run: async ({ contractId, requestNumber }, context) => { + context.targets.contractId = contractId; + const detail = await contractDetail(contractId); + if (!detail.signing) + throw new McpToolError( + "CONTRACT_SIGNING_NOT_FOUND", + "This contract has no signing request to refresh.", + ); + if (detail.signing.requestNumber !== requestNumber) + throw new McpToolError( + "CONTRACT_REQUEST_CHANGED", + "The signing request changed. Read get_contract again before refreshing it.", + ); + unwrap( + await operateContract({ contractId, requestNumber, operation: "sync" }), + ); + return contractResult(contractId, context); + }, +}); + const fileUpload = z .object({ fileName: z.string().min(1).max(200), @@ -632,6 +663,7 @@ export const prepareFromTemplate = defineTool({ export const contractTools = [ listContracts, getContract, + checkContractStatus, recordContract, prepareContractForSignature, renewalNotices, diff --git a/apps/web/src/features/mcp/tools/handoffs.ts b/apps/web/src/features/mcp/tools/handoffs.ts index 1ae7529c..2182c87e 100644 --- a/apps/web/src/features/mcp/tools/handoffs.ts +++ b/apps/web/src/features/mcp/tools/handoffs.ts @@ -10,21 +10,20 @@ import { } from "@clockwork/contracts"; import { requestHandoff } from "@/src/features/internal-ops/handoff/actions"; -import { - loadHandoffQueue, - loadOwnHandoffs, -} from "@/src/features/internal-ops/handoff/server"; +import { handoffRepository } from "@/src/features/internal-ops/handoff/server"; import { contractReader, salesLibraryReader, } from "@/src/features/internal-ops/contracts/demo-access"; -import { sessionHas } from "@/src/features/internal-ops/contracts/server"; +import { + contractStaff, + sessionHas, +} from "@/src/features/internal-ops/contracts/server"; import { appLink, compact, defineTool, - McpToolError, unwrap, type McpToolContext, } from "../tool"; @@ -103,31 +102,30 @@ export const listHandoffs = defineTool({ name: "list_handoffs", title: "List handoff requests", description: - "Handoff requests. scope mine (default) lists the ones you raised; queue lists the operations queue, for people who may read it, optionally one status.", + "Handoff requests. scope mine (default) lists the ones you raised; queue lists the operations queue, for people who may read it, optionally one status. Use limit and the returned nextCursor as cursor to continue with the same filters.", input: { scope: z.enum(["mine", "queue"]).optional(), status: z.enum(handoffStatuses).optional(), + limit: z.int().min(1).max(100).optional(), + cursor: z.string().min(1).max(500).optional(), }, access: "read", - run: async ({ scope, status }, context) => { - if ((scope ?? "mine") === "mine") { - const own = await loadOwnHandoffs(); - if (!own) throw new McpToolError("CONTRACT_FORBIDDEN"); - return { - handoffs: own - .filter((request) => !status || request.status === status) - .map((request) => handoffView(request, context)), - }; - } - const queue = await loadHandoffQueue(status); - if (queue.kind !== "ready") - throw new McpToolError( - queue.kind === "forbidden" ? "CONTRACT_FORBIDDEN" : "UNAVAILABLE", - ); + run: async ({ scope, status, limit, cursor }, context) => { + const mine = (scope ?? "mine") === "mine"; + const session = await contractStaff( + mine ? "contract:write" : "operations:read", + ); + const page = await handoffRepository().listPage( + mine ? { kind: "own", userId: session.userId } : { kind: "all" }, + status, + { + ...(limit !== undefined ? { limit } : {}), + ...(cursor !== undefined ? { cursor } : {}), + }, + ); return { - handoffs: queue.value.requests.map((request) => - handoffView(request, context), - ), + nextCursor: page.nextCursor, + handoffs: page.rows.map((request) => handoffView(request, context)), }; }, }); diff --git a/apps/web/src/features/mcp/tools/mndas.ts b/apps/web/src/features/mcp/tools/mndas.ts index 5359b7b3..81505e97 100644 --- a/apps/web/src/features/mcp/tools/mndas.ts +++ b/apps/web/src/features/mcp/tools/mndas.ts @@ -36,7 +36,11 @@ import { /** PDFs at or below this size can come back inline as well as by link. */ const inlinePdfLimit = 4 * 1024 * 1024; -function mndaView(record: MndaRecord, context: McpToolContext) { +function mndaView( + record: MndaRecord, + context: McpToolContext, + includeDetails = false, +) { return compact({ id: record.id, company: record.input.company, @@ -56,6 +60,20 @@ function mndaView(record: MndaRecord, context: McpToolContext) { closedBecause: record.cancelCode, error: record.error, version: record.version, + ...(includeDetails + ? { + details: { + detailsMode: record.input.detailsMode, + shortName: record.input.shortName, + entityDescription: record.input.entityDescription, + streetAddress: record.input.streetAddress, + locality: record.input.locality, + noticesContact: record.input.noticesContact, + noticesEmail: record.input.noticesEmail, + countersignerId: record.countersigner.id, + }, + } + : {}), links: compact({ register: appLink( context, @@ -84,7 +102,7 @@ export const listMndas = defineTool({ name: "list_mndas", title: "List MNDAs", description: - "Search the MNDA register. Filter by company or signer text, by what the MNDA is waiting on (drafts, waiting_partner, waiting_fil_one, attention, completed, closed) or by exact states, and to the ones you prepared. Newest first, 25 per page by default.", + "Search the MNDA register. Filter by company or signer text, by what the MNDA is waiting on (drafts, waiting_partner, waiting_fil_one, attention, completed, closed) or by exact states, and to the ones you prepared. Newest first, 25 per page by default. Also lists active Fil One countersigner IDs for preparing drafts.", input: { search: z.string().max(120).optional().describe("Company or signer text"), group: z.enum(groupNames).optional(), @@ -117,6 +135,14 @@ export const listMndas = defineTool({ mndas: data.register.records.map((record) => mndaView(record, context)), sendingReady: data.ready, testMode: data.testMode, + countersigners: data.signers + .filter((signer) => signer.active) + .map((signer) => ({ + id: signer.id, + name: signer.name, + title: signer.title, + isDefault: signer.isDefault, + })), }; }, }); @@ -124,12 +150,13 @@ export const listMndas = defineTool({ export const getMnda = defineTool({ name: "get_mnda", title: "Get an MNDA", - description: "One MNDA with its signer, state, dates and links.", + description: + "One MNDA with its signer, state, dates, links and the saved legal entity, address and notices details needed to review or revise a draft.", input: { id: z.uuid() }, access: "read", run: async ({ id }, context) => { context.targets.mndaId = id; - return mndaView(await readMnda(id), context); + return mndaView(await readMnda(id), context, true); }, }); @@ -188,7 +215,7 @@ async function prepareDraft( if (!countersigner) throw new McpToolError( "countersigner_unavailable", - "No active Fil One countersigner was found. Pick one from the MNDA settings page.", + "The selected or default Fil One countersigner is unavailable. Run list_mndas to choose an active countersignerId, or ask an administrator to configure one in Commerce.", ); const record = unwrap( await prepareMnda({ @@ -218,7 +245,7 @@ async function prepareDraft( excludeId: record.id, }); return { - mnda: mndaView(record, context), + mnda: mndaView(record, context, true), next: `Review and send in Commerce: ${appLink(context, `/internal/mndas?q=${encodeURIComponent(record.input.company)}`)}. Sending is always done by a person in the app.`, ...(duplicates.ok && (duplicates.value.mndas.length || duplicates.value.contracts.length) @@ -273,7 +300,7 @@ export const prepareMndaPreview = defineTool({ context.targets.mndaId = id; const current = await readMnda(id); if (!changes || Object.keys(changes).length === 0) - return { mnda: mndaView(current, context) }; + return { mnda: mndaView(current, context, true) }; if (!["draft", "ready"].includes(current.state)) throw new McpToolError( "not_a_draft", diff --git a/apps/web/src/features/mcp/tools/partners.ts b/apps/web/src/features/mcp/tools/partners.ts index 474ff09b..93e190a5 100644 --- a/apps/web/src/features/mcp/tools/partners.ts +++ b/apps/web/src/features/mcp/tools/partners.ts @@ -13,6 +13,7 @@ import { partnerModels, partnerSizeUnits, partnerStatuses, + PartnerListQuerySchema, type PartnerDealWithConflicts, type PartnerRecord, } from "@clockwork/contracts"; @@ -23,7 +24,6 @@ import { } from "@/src/features/internal-ops/partners/actions"; import { loadPartner, - loadPartnerList, partnerRepository, partnerToday, } from "@/src/features/internal-ops/partners/server"; @@ -97,7 +97,7 @@ export const listPartners = defineTool({ name: "list_partners", title: "List partners", description: - "Channel, referral and technology partners, most recently changed first: status, models, owner, next step, headline commission and margin, and open deal counts. Filter by text, status, model, owner, your own, or next steps overdue or due this week.", + "Channel, referral and technology partners, most recently changed first: status, models, owner, next step, headline commission and margin, and open deal counts. Filter by text, status, model, owner, your own, or next steps overdue or due this week. Use limit and the returned nextCursor as cursor to continue with the same filters.", input: { search: z.string().max(100).optional(), status: z.enum(partnerStatuses).optional(), @@ -105,25 +105,38 @@ export const listPartners = defineTool({ ownerId: z.uuid().optional(), mine: z.boolean().optional(), due: z.enum(partnerDueFilters).optional(), + limit: z.int().min(1).max(100).optional(), + cursor: z.string().min(1).max(500).optional(), }, access: "read", run: async (args, context) => { - const list = loaded( - await loadPartnerList({ - ...(args.search ? { q: args.search } : {}), - ...(args.status ? { status: args.status } : {}), - ...(args.model ? { model: args.model } : {}), - ...(args.ownerId ? { owner: args.ownerId } : {}), - ...(args.mine ? { mine: "1" } : {}), - ...(args.due ? { due: args.due } : {}), - }), - ); + const session = await contractStaff("sales:read"); + const repository = partnerRepository(); + const query = PartnerListQuerySchema.parse({ + ...(args.search ? { q: args.search } : {}), + ...(args.status ? { status: args.status } : {}), + ...(args.model ? { model: args.model } : {}), + ...(args.ownerId ? { owner: args.ownerId } : {}), + ...(args.mine ? { mine: "1" } : {}), + ...(args.due ? { due: args.due } : {}), + }); + const [page, owners] = await Promise.all([ + repository.listPage( + query, + { viewerId: session.userId, today: partnerToday() }, + { + ...(args.limit !== undefined ? { limit: args.limit } : {}), + ...(args.cursor !== undefined ? { cursor: args.cursor } : {}), + }, + ), + repository.owners(), + ]); return { - truncated: list.result.truncated || undefined, - partners: list.result.rows.map((row) => + nextCursor: page.nextCursor, + partners: page.rows.map((row) => compact({ ...row, link: partnerLink(context, row.id) }), ), - owners: list.owners, + owners, }; }, }); diff --git a/apps/web/src/features/mcp/tools/pricing.ts b/apps/web/src/features/mcp/tools/pricing.ts index 81371785..589a7582 100644 --- a/apps/web/src/features/mcp/tools/pricing.ts +++ b/apps/web/src/features/mcp/tools/pricing.ts @@ -253,10 +253,14 @@ export const listPricingScenarios = defineTool({ name: "list_pricing_scenarios", title: "List pricing scenarios", description: - "Your saved pricing scenarios (every seller's for a commerce administrator) with totals. With id, one scenario's lines, totals and partner economics.", - input: { id: z.uuid().optional() }, + "Your saved pricing scenarios (every seller's for a commerce administrator) with totals. Use limit and the returned nextCursor as cursor to continue with the same filters. With id, one scenario's lines, totals and partner economics.", + input: { + id: z.uuid().optional(), + limit: z.int().min(1).max(100).optional(), + cursor: z.string().min(1).max(500).optional(), + }, access: "read", - run: async ({ id }, context) => { + run: async ({ id, limit, cursor }, context) => { const session = await contractStaff("sales:read"); const repository = scenarioRepository(); if (id) { @@ -266,9 +270,13 @@ export const listPricingScenarios = defineTool({ context, ); } - const scenarios = await repository.list(scenarioScope(session)); + const page = await repository.listPage(scenarioScope(session), { + ...(limit !== undefined ? { limit } : {}), + ...(cursor !== undefined ? { cursor } : {}), + }); return { - scenarios: scenarios.map((scenario) => + nextCursor: page.nextCursor, + scenarios: page.rows.map((scenario) => amounts({ ...scenario, link: appLink(context, `/internal/pricing?scenario=${scenario.id}`), diff --git a/docs/operations/mcp.md b/docs/operations/mcp.md index fb13ea6c..8bc4fa9c 100644 --- a/docs/operations/mcp.md +++ b/docs/operations/mcp.md @@ -120,43 +120,53 @@ be available. An unavailable ID is refused rather than replaced with a default. `list_notifications` returns `nextCursor`; pass it as `before` to continue, even if `unreadOnly` leaves the current page empty. A null cursor means the end. -| Tool | Access | What it does | -| -------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------- | -| `whoami` | read | You, your roles and permissions, whether you are MFA-enrolled, and the token's scope and expiry | -| `list_staff` | read | Administrators: staff in the current organization, roles, effective permissions, MFA status and membership versions | -| `prepare_staff_role_change` | write | Administrators: prepare one role addition or removal and an exact browser review link; no access changes until confirmation | -| `home_summary` | read | What Home shows you: MNDA, contract and partner counts, renewal notices, your open handoffs | -| `list_mndas` | read | Search the MNDA register by text, status group, state or "prepared by me" | -| `get_mnda` | read | One MNDA with its signer, state, dates and links | -| `create_mnda_draft` | write | Prepare a draft from the approved template and render its preview; flags MNDAs and contracts already on file | -| `prepare_mnda_preview` | write | A draft's preview link, or a revised draft that replaces it | -| `check_mnda_status` | write | Refresh an MNDA's status from SignWell, as the Refresh button does | -| `download_signed_mnda` | read | Link to the signed PDF; the PDF inline on request when it is at most 4 MB | -| `list_contracts` | read | Search the contract register | -| `get_contract` | read | One contract with its files, signing request and earlier requests | -| `record_contract` | write | Record or edit a contract (optimistic version) and attach one PDF as base64, at most 10 MB | -| `prepare_contract_for_signature` | write | Prepare an uploaded PDF for signature; another person approves it and a person sends it in the app | -| `renewal_notices` | read | Notice deadlines in the next 30, 60 or 90 days, and deadlines passed | -| `list_contract_templates` | read | Available templates and, for one, its fields and countersigners | -| `prepare_from_template` | write | Prepare a contract from an available template | -| `list_partners` | read | Partners with status, owner, next step and headline terms | -| `get_partner` | read | One partner with terms, deals, overlaps, linked MNDAs and contracts, and history | -| `create_partner` | write | Record a partner | -| `update_partner` | write | Edit a partner against the version you read | -| `list_partner_deals` | read | A partner's registered deals and overlapping registrations | -| `register_partner_deal` | write | Register a deal; overlaps come back as a warning | -| `update_partner_deal` | write | Edit a deal, such as marking it won or lost | -| `partner_next_steps_due` | read | Partner next steps overdue or due soon | -| `list_price_books` | read | Price books in force and their list-price rates | -| `indicative_price` | read | Price lines in TB, PB, TiB or PiB with discount and term, and partner economics, without saving | -| `list_pricing_scenarios` | read | Your saved scenarios, or one with its figures | -| `save_pricing_scenario` | write | Save or overwrite a scenario | -| `pricing_summary_pdf_link` | read | The customer or partner summary PDF link | -| `create_handoff_request` | write | Hand signed contracts to operations | -| `list_handoffs` | read | Your handoff requests, or the operations queue if you may read it | -| `list_notifications` | read | Your staff notifications, newest first, with links | -| `mark_notifications_read` | write | Mark notifications read, some or all | -| `list_sales_library` | read | Sales collateral with download links | +`list_pricing_scenarios`, `list_handoffs` and `list_partners` accept `limit` +(1–100, default 100) and an opaque `cursor`. Pass their `nextCursor` unchanged +with the same filters to continue; null means the end. These tools can traverse +beyond the UI list limits. Ownership and status filters apply before each page +is selected, and cursors preserve full database timestamp precision. Pricing +rows that cannot be read are skipped; continue while `nextCursor` is present +even when a page is empty. Lists are live: records edited while paging can move +in the ordering, so restart a traversal when a complete fresh snapshot matters. + +| Tool | Access | What it does | +| -------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------- | +| `whoami` | read | You, your roles and permissions, whether you are MFA-enrolled, and the token's scope and expiry | +| `list_staff` | read | Administrators: staff in the current organization, roles, effective permissions, MFA status and membership versions | +| `prepare_staff_role_change` | write | Administrators: prepare one role addition or removal and an exact browser review link; no access changes until confirmation | +| `home_summary` | read | What Home shows you: MNDA, contract and partner counts, renewal notices, your open handoffs | +| `list_mndas` | read | Search the MNDA register and list active countersigner IDs for drafts | +| `get_mnda` | read | One MNDA with signer, state, dates, links and saved legal, address and notices details | +| `create_mnda_draft` | write | Prepare a draft from the approved template and render its preview; flags MNDAs and contracts already on file | +| `prepare_mnda_preview` | write | A draft's preview link, or a revised draft that replaces it | +| `check_mnda_status` | write | Refresh an MNDA's status from SignWell, as the Refresh button does | +| `check_contract_status` | write | Refresh the exact contract signing request from SignWell using the requestNumber read from get_contract; never sends or approves | +| `download_signed_mnda` | read | Link to the signed PDF; the PDF inline on request when it is at most 4 MB | +| `list_contracts` | read | Search the contract register | +| `get_contract` | read | One contract with its files, signing request and earlier requests | +| `record_contract` | write | Record or edit a contract (optimistic version) and attach one PDF as base64, at most 10 MB | +| `prepare_contract_for_signature` | write | Prepare an uploaded PDF for signature; another person approves it and a person sends it in the app | +| `renewal_notices` | read | Notice deadlines in the next 30, 60 or 90 days, and deadlines passed | +| `list_contract_templates` | read | Available templates and, for one, its fields and countersigners | +| `prepare_from_template` | write | Prepare a contract from an available template | +| `list_partners` | read | Partners with status, owner, next step and headline terms | +| `get_partner` | read | One partner with terms, deals, overlaps, linked MNDAs and contracts, and history | +| `create_partner` | write | Record a partner | +| `update_partner` | write | Edit a partner against the version you read | +| `list_partner_deals` | read | A partner's registered deals and overlapping registrations | +| `register_partner_deal` | write | Register a deal; overlaps come back as a warning | +| `update_partner_deal` | write | Edit a deal, such as marking it won or lost | +| `partner_next_steps_due` | read | Partner next steps overdue or due soon | +| `list_price_books` | read | Price books in force and their list-price rates | +| `indicative_price` | read | Price lines in TB, PB, TiB or PiB with discount and term, and partner economics, without saving | +| `list_pricing_scenarios` | read | Your saved scenarios, or one with its figures | +| `save_pricing_scenario` | write | Save or overwrite a scenario | +| `pricing_summary_pdf_link` | read | The customer or partner summary PDF link | +| `create_handoff_request` | write | Hand signed contracts to operations | +| `list_handoffs` | read | Your handoff requests, or the operations queue if you may read it | +| `list_notifications` | read | Your staff notifications, newest first, with links | +| `mark_notifications_read` | write | Mark notifications read, some or all | +| `list_sales_library` | read | Sales collateral with download links | Links in results open in the app and need you to be signed in there. PDF downloads through those links are audited as they are in the app. @@ -166,6 +176,20 @@ sees and changes what a seller can in the app, an uploaded PDF still needs another person's approval before it is sent, and pricing shows list prices and the figures the Pricing page shows, never floors or transfer prices. +For MNDA preparation, `list_mndas` includes active `countersigners` with IDs, +names, titles and the default flag. `get_mnda` and `prepare_mnda_preview` +include saved legal entity, address and notices fields under `details`, so a +revision can be reviewed before it is prepared. These reads use the existing +MNDA permission check. + +For contract status, call `get_contract` first, then pass its +`signing.requestNumber` with `contractId` to `check_contract_status`. The +request number is required: a replaced signing request is refused, including a +replacement between the read and the provider check. Refresh reads SignWell and +stores the observed status; it cannot send, remind, void or approve a request. +It requires the same `contract:write` permission as the browser's Refresh +action. + ## Prepare a staff role change A commerce administrator can ask MCP to run `list_staff`, then @@ -184,10 +208,10 @@ own roles cannot be changed this way. The existing last-administrator and last-role protections still apply. The link contains identifiers and the proposed role, never the access token or -an authenticator code. An optional reason is entered in the browser. A token -never becomes a recent sign-in, and MCP never accepts an authenticator code. The -MCP preparation is audited against the target user; only browser confirmation -writes the normal `staff.role_granted` or `staff.role_revoked` event. +an authenticator code. A token never becomes a recent sign-in, and MCP never +accepts an authenticator code. The MCP preparation is audited against the target +user; only browser confirmation writes the normal `staff.role_granted` or +`staff.role_revoked` event. ## What stays in the app @@ -261,15 +285,16 @@ The guided demo has no MCP endpoint, so nothing can be sent from it. ## When something is refused -| Answer | What to do | -| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `401` with `WWW-Authenticate` | The token is missing, mistyped, expired or revoked, or you no longer have staff access. The answer does not say which. Create a new one. | -| `429` | Wait for the seconds in `Retry-After`. | -| `… disabled` | The token is read-only. Create a read-and-write token. | -| `*_FORBIDDEN` | Your role does not allow it, as in the app. | -| `RECENT_AUTH_REQUIRED` | It needs a fresh MFA check. Do it in the app. | -| `*_VERSION_CONFLICT` | Someone changed the record since you read it. Read it again and retry. | -| `*_NOT_CONFIGURED` | SignWell is not switched on in this environment. | +| Answer | What to do | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `401` with `WWW-Authenticate` | The token is missing, mistyped, expired or revoked, or you no longer have staff access. The answer does not say which. Create a new one. | +| `429` | Wait for the seconds in `Retry-After`. | +| `503` with `Retry-After` | Authentication services are temporarily unavailable before any tool ran. Wait for the indicated seconds and retry. A 503 without this header can mean deployment configuration is missing. | +| `… disabled` | The token is read-only. Create a read-and-write token. | +| `*_FORBIDDEN` | Your role does not allow it, as in the app. | +| `RECENT_AUTH_REQUIRED` | It needs a fresh MFA check. Do it in the app. | +| `*_VERSION_CONFLICT` | Someone changed the record since you read it. Read it again and retry. | +| `*_NOT_CONFIGURED` | SignWell is not switched on in this environment. | ## QA evidence — October 11, 2026 diff --git a/packages/db/src/repositories/keyset-page.integration.test.ts b/packages/db/src/repositories/keyset-page.integration.test.ts new file mode 100644 index 00000000..b53a93a9 --- /dev/null +++ b/packages/db/src/repositories/keyset-page.integration.test.ts @@ -0,0 +1,165 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, expect, it, vi } from "vitest"; +import { PartnerListQuerySchema } from "@clockwork/contracts"; +import { createRuntimeDatabase } from "../client"; +import { PricingScenarioRepository } from "./pricing-scenarios"; +import { HandoffRequestRepository } from "./system/handoff-requests"; +import { PartnerRepository } from "./partners"; + +const { db, client } = createRuntimeDatabase({ + url: + process.env.DIRECT_DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:54322/postgres?sslmode=disable", + role: "clockwork_service", + ssl: false, +}); +const owner = randomUUID(); +const foreign = randomUUID(); +const tag = `Pagination ${randomUUID()}`; +const scenarioIds: string[] = [], + handoffIds: string[] = [], + partnerIds: string[] = []; +afterAll(async () => { + await client`delete from commerce_handoff_requests where id = any(${handoffIds})`; + await client`delete from commerce_contracts where id = any(${handoffIds})`; + await client`delete from commerce_pricing_scenarios where id = any(${scenarioIds})`; + await client`delete from commerce_partners where id = any(${partnerIds})`; + await client.end(); +}); + +async function collect( + read: (cursor?: string) => Promise<{ rows: T[]; nextCursor: string | null }>, +) { + const ids: string[] = []; + let cursor: string | undefined; + for (let pageNumber = 0; pageNumber < 40; pageNumber += 1) { + const page = await read(cursor); + ids.push(...page.rows.map(({ id }) => id)); + if (!page.nextCursor) return ids; + expect(page.nextCursor).not.toBe(cursor); + cursor = page.nextCursor; + } + throw new Error("Pagination did not terminate"); +} + +it("reads beyond the pricing UI cap with ownership filtering, timestamp ties and sub-millisecond precision", async () => { + const bookId = randomUUID(); + const line = { + bookId, + bookVersion: 1, + rateId: randomUUID(), + sku: "STORAGE", + region: "us-east", + unit: "TB-month", + unitPrice: { currency: "USD", minor: "100" }, + minimumQuantity: "1", + quantity: "10", + termMonths: 12, + discountBps: 0, + }; + const inserted = await client<{ id: string }[]>` + insert into commerce_pricing_scenarios(id,owner_id,owner_name,name,company,currency,as_of,price_books,lines,updated_at) + select gen_random_uuid(), case when i <= 105 then ${owner}::uuid else ${foreign}::uuid end, + 'Owner', ${tag}, 'Example', 'USD', '2026-10-10', ${JSON.stringify([{ id: bookId, version: 1 }])}::jsonb, ${JSON.stringify([line])}::jsonb, + case when i > 105 then '2028-01-01'::timestamptz when i % 2 = 0 then '2027-01-01T00:00:00.000901Z'::timestamptz else '2027-01-01T00:00:00.000100Z'::timestamptz end + from generate_series(1,125) i returning id`; + scenarioIds.push(...inserted.map(({ id }) => id)); + const expected = await client< + { id: string }[] + >`select id from commerce_pricing_scenarios where owner_id=${owner} order by updated_at desc,id desc`; + const repository = new PricingScenarioRepository(db); + const scope = { kind: "own" as const, ownerId: owner }; + expect(await repository.list(scope)).toHaveLength(100); + const ids = await collect((cursor) => + repository.listPage(scope, { limit: 17, ...(cursor ? { cursor } : {}) }), + ); + expect(ids).toEqual(expected.map(({ id }) => id)); + expect(new Set(ids).size).toBe(105); + + const unreadableId = randomUUID(); + scenarioIds.push(unreadableId); + await client`insert into commerce_pricing_scenarios(id,owner_id,owner_name,name,company,currency,as_of,price_books,lines,updated_at) + values(${unreadableId},${owner},'Owner',${tag},'Example','USD','2026-10-10','[{}]'::jsonb,'[{}]'::jsonb,'2029-01-01')`; + const skipped = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + try { + const emptyPage = await repository.listPage(scope, { limit: 1 }); + expect(emptyPage.rows).toEqual([]); + expect(emptyPage.nextCursor).toBeTypeOf("string"); + if (!emptyPage.nextCursor) throw new Error("Missing continuation cursor"); + const nextPage = await repository.listPage(scope, { + limit: 1, + cursor: emptyPage.nextCursor, + }); + expect(nextPage.rows.map(({ id }) => id)).toEqual([expected[0]?.id]); + expect(skipped).toHaveBeenCalledTimes(1); + } finally { + skipped.mockRestore(); + } +}); + +it("reads beyond the handoff UI cap and applies ownership/status before page limits", async () => { + const contracts = await client<{ id: string }[]>` + insert into commerce_contracts(id,counterparty_name,contract_type,paper,status,owner_name,created_by_id,created_by_name) + select gen_random_uuid(), ${tag}, 'customer_msa','ours','executed','Owner',${owner},'Owner' + from generate_series(1,230) returning id`; + handoffIds.push(...contracts.map(({ id }) => id)); + await client` + insert into commerce_handoff_requests(id,requested_by_id,requested_by_name,counterparty_legal_name,signer_name,signer_email,contract_ids,requested_side,created_at) + select id, ${owner}::uuid, 'Owner', ${tag}, 'Signer','signer@example.test',array[id],'customer', + case when ordinal % 2 = 0 then '2027-01-01T00:00:00.000901Z'::timestamptz else '2027-01-01T00:00:00.000100Z'::timestamptz end + from unnest(${handoffIds}::uuid[]) with ordinality as fixture(id,ordinal)`; + await client`update commerce_handoff_requests set status='in_progress',assignee_id=${owner},assignee_name='Owner',version=version+1 + where id = any(${handoffIds.slice(205)})`; + // A different seller's newer request must never consume the first page. + const outsider = randomUUID(); + handoffIds.push(outsider); + await client`insert into commerce_contracts(id,counterparty_name,contract_type,paper,status,owner_name,created_by_id,created_by_name) + values(${outsider},${tag},'customer_msa','ours','executed','Other',${foreign},'Other')`; + await client`insert into commerce_handoff_requests(id,requested_by_id,requested_by_name,counterparty_legal_name,signer_name,signer_email,contract_ids,requested_side,created_at) + values(${outsider},${foreign},'Other',${tag},'Signer','signer@example.test',array[${outsider}::uuid],'customer','2028-01-01')`; + const repository = new HandoffRequestRepository(db); + const scope = { kind: "own" as const, userId: owner }; + expect(await repository.list(scope, "open")).toHaveLength(200); + const expected = await client< + { id: string }[] + >`select id from commerce_handoff_requests where requested_by_id=${owner} and status='open' order by created_at desc,id desc`; + const ids = await collect((cursor) => + repository.listPage(scope, "open", { + limit: 19, + ...(cursor ? { cursor } : {}), + }), + ); + expect(ids).toEqual(expected.map(({ id }) => id)); + expect(new Set(ids).size).toBe(205); +}); + +it("reads beyond the partner UI cap with query filters and full timestamp cursor precision", async () => { + const inserted = await client<{ id: string }[]>` + insert into commerce_partners(id,name,status,owner_id,owner_name,created_by_id,created_by_name,updated_at) + select gen_random_uuid(), ${tag}, case when i <= 505 then 'active' else 'ended' end, + ${owner}::uuid,'Owner',${owner}::uuid,'Owner', + case when i > 505 then '2028-01-01'::timestamptz when i % 2 = 0 then '2027-01-01T00:00:00.000901Z'::timestamptz else '2027-01-01T00:00:00.000100Z'::timestamptz end + from generate_series(1,530) i returning id`; + partnerIds.push(...inserted.map(({ id }) => id)); + const repository = new PartnerRepository(db); + const query = PartnerListQuerySchema.parse({ + q: tag, + status: "active", + mine: "1", + }); + const scope = { viewerId: owner, today: "2026-10-11" }; + expect((await repository.list(query, scope)).rows).toHaveLength(500); + const expected = await client< + { id: string }[] + >`select id from commerce_partners where owner_id=${owner} and status='active' order by updated_at desc,id desc`; + const ids = await collect((cursor) => + repository.listPage(query, scope, { + limit: 37, + ...(cursor ? { cursor } : {}), + }), + ); + expect(ids).toEqual(expected.map(({ id }) => id)); + expect(new Set(ids).size).toBe(505); +}); diff --git a/packages/db/src/repositories/keyset-page.test.ts b/packages/db/src/repositories/keyset-page.test.ts new file mode 100644 index 00000000..b8050f92 --- /dev/null +++ b/packages/db/src/repositories/keyset-page.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; +import { nextPageCursor, pageOptions } from "./keyset-page"; + +const last = { + id: "20000000-0000-4000-8000-000000000001", + cursorAt: "2027-01-01T00:00:00.000901Z", +}; +it("retains microseconds and the list identity in an opaque cursor", () => { + const cursor = nextPageCursor("pricing", true, last); + if (!cursor) throw new Error("Expected cursor"); + expect(pageOptions("pricing", { cursor, limit: 17 })).toEqual({ + limit: 17, + cursor: { source: "pricing", at: last.cursorAt, id: last.id }, + }); + expect(() => pageOptions("partners", { cursor })).toThrow("INVALID_INPUT"); + expect(nextPageCursor("pricing", false, last)).toBeNull(); + expect(nextPageCursor("pricing", true, undefined)).toBeNull(); +}); +it.each([0, -1, 101, 1.5, NaN, Infinity])( + "rejects invalid page limit %s", + (limit) => { + expect(() => pageOptions("pricing", { limit })).toThrow("INVALID_INPUT"); + }, +); +it.each([ + "", + "bad%%", + "e30", + Buffer.from( + JSON.stringify({ + source: "pricing", + at: "2027-02-30T00:00:00Z", + id: last.id, + }), + ).toString("base64url"), +])("rejects malformed cursor %s", (cursor) => { + expect(() => pageOptions("pricing", { cursor })).toThrow("INVALID_INPUT"); +}); diff --git a/packages/db/src/repositories/keyset-page.ts b/packages/db/src/repositories/keyset-page.ts new file mode 100644 index 00000000..dcf288d0 --- /dev/null +++ b/packages/db/src/repositories/keyset-page.ts @@ -0,0 +1,61 @@ +import { sql, type SQLWrapper } from "drizzle-orm"; +import { z } from "zod"; + +export interface KeysetPageOptions { + limit?: number; + cursor?: string; +} + +const cursorSchema = z + .object({ + source: z.string(), + at: z.iso.datetime(), + id: z.uuid(), + }) + .strict(); +export type KeysetCursor = z.infer; + +/** Opaque cursors retain PostgreSQL microseconds, never round through Date. */ +export function pageOptions(source: string, options: KeysetPageOptions = {}) { + const limit = options.limit ?? 100; + if (!Number.isInteger(limit) || limit < 1 || limit > 100) + throw new Error("INVALID_INPUT"); + let cursor: KeysetCursor | undefined; + if (options.cursor !== undefined) { + try { + if (!/^[A-Za-z0-9_-]{1,500}$/u.test(options.cursor)) + throw new Error("INVALID_INPUT"); + cursor = cursorSchema.parse( + JSON.parse(Buffer.from(options.cursor, "base64url").toString("utf8")), + ); + if (cursor.source !== source) throw new Error("INVALID_INPUT"); + } catch { + throw new Error("INVALID_INPUT"); + } + } + return { limit, cursor }; +} + +export const cursorTimestamp = (column: SQLWrapper) => + sql`to_char(${column} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`; + +export const beforeCursor = ( + at: SQLWrapper, + id: SQLWrapper, + cursor: KeysetCursor | undefined, +) => + cursor + ? sql`(${at}, ${id}) < (${cursor.at}::timestamptz, ${cursor.id}::uuid)` + : undefined; + +export function nextPageCursor( + source: string, + hasMore: boolean, + last: { id: string; cursorAt: string } | undefined, +) { + return hasMore && last + ? Buffer.from( + JSON.stringify({ source, at: last.cursorAt, id: last.id }), + ).toString("base64url") + : null; +} diff --git a/packages/db/src/repositories/partners.ts b/packages/db/src/repositories/partners.ts index f39ebde2..96d2f631 100644 --- a/packages/db/src/repositories/partners.ts +++ b/packages/db/src/repositories/partners.ts @@ -45,6 +45,14 @@ import { auditEvents, organizations } from "../schema"; import { commercePartnerDeals, commercePartners } from "../schema/partners"; import { withInternalTransaction } from "../transaction"; import { appendAuditAndOutbox } from "./audit-outbox"; +import { + beforeCursor, + cursorTimestamp, + nextPageCursor, + pageOptions, + type KeysetPageOptions, + type KeysetCursor, +} from "./keyset-page"; type PartnerRow = typeof commercePartners.$inferSelect; type DealRow = typeof commercePartnerDeals.$inferSelect; @@ -344,6 +352,29 @@ export class PartnerRepository { }); } + /** Uncapped traversal of the same filtered summaries as the UI list. */ + listPage( + query: PartnerListQuery, + scope: PartnerReadScope, + options: KeysetPageOptions = {}, + ) { + const { limit, cursor } = pageOptions("partners", options); + return this.tx(async (tx) => { + await expireLapsedDeals(tx, scope.today); + const rows = await summaryPageRows(tx, query, scope, limit + 1, cursor); + const page = rows.slice(0, limit); + const last = page.at(-1); + return { + rows: page.map(({ row }) => row), + nextCursor: nextPageCursor( + "partners", + rows.length > limit, + last ? { id: last.row.id, cursorAt: last.cursorAt } : undefined, + ), + }; + }); + } + /** The same rows as the list, up to the export limit, for the CSV. */ exportRows(query: PartnerListQuery, scope: PartnerReadScope) { return this.tx(async (tx) => { @@ -850,6 +881,16 @@ async function summaries( scope: PartnerReadScope, limit: number, ): Promise { + return (await summaryPageRows(tx, query, scope, limit)).map(({ row }) => row); +} + +async function summaryPageRows( + tx: RuntimeTransaction, + query: PartnerListQuery, + scope: PartnerReadScope, + limit: number, + cursor?: KeysetCursor, +) { const rows = await tx .select({ id: commercePartners.id, @@ -865,22 +906,31 @@ async function summaries( marginPct: commercePartners.marginPct, currency: commercePartners.currency, updatedAt: commercePartners.updatedAt, + cursorAt: cursorTimestamp(commercePartners.updatedAt), openDeals: sql`(select count(*)::int from public.commerce_partner_deals d where d.partner_id = ${commercePartners.id} and d.status in ('registered','accepted','disputed') and d.protected_until >= ${scope.today}::date)`, }) .from(commercePartners) - .where(listWhere(query, scope)) + .where( + and( + listWhere(query, scope), + beforeCursor(commercePartners.updatedAt, commercePartners.id, cursor), + ), + ) .orderBy(desc(commercePartners.updatedAt), desc(commercePartners.id)) .limit(limit); - return rows.map((row) => ({ - ...row, - models: [...row.models], - commissionPct: trimPartnerDecimal(row.commissionPct), - marginPct: trimPartnerDecimal(row.marginPct), - openDeals: Number(row.openDeals), - updatedAt: row.updatedAt.toISOString(), + return rows.map(({ cursorAt, ...row }) => ({ + cursorAt, + row: { + ...row, + models: [...row.models], + commissionPct: trimPartnerDecimal(row.commissionPct), + marginPct: trimPartnerDecimal(row.marginPct), + openDeals: Number(row.openDeals), + updatedAt: row.updatedAt.toISOString(), + }, })); } diff --git a/packages/db/src/repositories/pricing-scenarios.ts b/packages/db/src/repositories/pricing-scenarios.ts index 47dd05bb..87c4b0e2 100644 --- a/packages/db/src/repositories/pricing-scenarios.ts +++ b/packages/db/src/repositories/pricing-scenarios.ts @@ -22,6 +22,13 @@ import type { RuntimeDatabase, RuntimeTransaction } from "../client"; import { pricingScenarios } from "../schema/pricing-scenarios"; import { withInternalTransaction } from "../transaction"; import { appendAuditAndOutbox } from "./audit-outbox"; +import { + beforeCursor, + cursorTimestamp, + nextPageCursor, + pageOptions, + type KeysetPageOptions, +} from "./keyset-page"; import { DatabaseIndicativePriceBookReader, type IndicativePriceBookRecord, @@ -190,6 +197,46 @@ export class PricingScenarioRepository { ); } + /** Uncapped traversal for API readers; ownership applies before pagination. */ + listPage(scope: PricingScenarioScope, options: KeysetPageOptions = {}) { + const { limit, cursor } = pageOptions("pricing", options); + return this.tx(async (tx) => { + const rows = await tx + .select({ + row: pricingScenarios, + cursorAt: cursorTimestamp(pricingScenarios.updatedAt), + }) + .from(pricingScenarios) + .where( + and( + scope.kind === "own" + ? eq(pricingScenarios.ownerId, scope.ownerId) + : undefined, + beforeCursor( + pricingScenarios.updatedAt, + pricingScenarios.id, + cursor, + ), + ), + ) + .orderBy(desc(pricingScenarios.updatedAt), desc(pricingScenarios.id)) + .limit(limit + 1); + const page = rows.slice(0, limit); + const last = page.at(-1); + return { + rows: readable( + page.map(({ row }) => row), + summary, + ), + nextCursor: nextPageCursor( + "pricing", + rows.length > limit, + last ? { id: last.row.id, cursorAt: last.cursorAt } : undefined, + ), + }; + }); + } + /** A scenario outside the caller's scope reads as missing. */ async get(id: string, scope: PricingScenarioScope) { const row = await this.tx( diff --git a/packages/db/src/repositories/system/handoff-requests.ts b/packages/db/src/repositories/system/handoff-requests.ts index c475ef14..41b24609 100644 --- a/packages/db/src/repositories/system/handoff-requests.ts +++ b/packages/db/src/repositories/system/handoff-requests.ts @@ -20,6 +20,13 @@ import { mndaRequests } from "../../schema/mnda"; import { pricingScenarios } from "../../schema/pricing-scenarios"; import { withInternalTransaction } from "../../transaction"; import { appendAuditAndOutbox } from "../audit-outbox"; +import { + beforeCursor, + cursorTimestamp, + nextPageCursor, + pageOptions, + type KeysetPageOptions, +} from "../keyset-page"; type Row = typeof handoffRequests.$inferSelect; type UserActor = Actor & { kind: "user" }; @@ -158,6 +165,44 @@ export class HandoffRequestRepository { ); } + /** Uncapped traversal with ownership and status applied before pagination. */ + listPage( + scope: HandoffScope, + status?: HandoffStatus, + options: KeysetPageOptions = {}, + ) { + const { limit, cursor } = pageOptions("handoffs", options); + return this.tx(async (tx) => { + const rows = await tx + .select({ + row: handoffRequests, + cursorAt: cursorTimestamp(handoffRequests.createdAt), + }) + .from(handoffRequests) + .where( + and( + scope.kind === "own" + ? eq(handoffRequests.requestedById, scope.userId) + : undefined, + status ? eq(handoffRequests.status, status) : undefined, + beforeCursor(handoffRequests.createdAt, handoffRequests.id, cursor), + ), + ) + .orderBy(desc(handoffRequests.createdAt), desc(handoffRequests.id)) + .limit(limit + 1); + const page = rows.slice(0, limit); + const last = page.at(-1); + return { + rows: page.map(({ row }) => view(row)), + nextCursor: nextPageCursor( + "handoffs", + rows.length > limit, + last ? { id: last.row.id, cursorAt: last.cursorAt } : undefined, + ), + }; + }); + } + /** A request outside the caller's scope reads as missing. */ async get(id: string, scope: HandoffScope): Promise { return this.tx(async (tx) => {