From ee8957468ed493e8e896ddcd9247cd5d072cecd9 Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 12:13:25 -0400 Subject: [PATCH 1/6] Add retained document attachments to partner and account records --- .../internal/accounts/[id]/page.tsx | 12 +- .../internal/organizations/[id]/page.tsx | 4 + .../internal/partners/[id]/page.tsx | 6 + .../[type]/[id]/[fileId]/route.ts | 1 + .../record-documents/[type]/[id]/route.ts | 1 + apps/web/e2e/production-proof.spec.ts | 80 +++++ .../account-documents/documents.module.css | 41 +++ .../account-documents/documents.test.tsx | 102 ++++++ .../account-documents/documents.tsx | 326 ++++++++++++++++++ .../account-documents/http.test.ts | 157 +++++++++ .../internal-ops/account-documents/http.ts | 73 ++++ .../account-documents/section.tsx | 42 +++ .../internal-ops/account-documents/server.ts | 24 ++ .../internal-ops/contracts/upload-client.ts | 3 + .../internal-ops/partners/partner-detail.tsx | 3 + apps/web/src/i18n/catalogs.ts | 2 + .../messages/operations-account-documents.ts | 52 +++ apps/web/src/i18n/ownership.ts | 1 + docs/operations/partner-records.md | 27 ++ packages/contracts/src/account-documents.ts | 44 +++ packages/contracts/src/contract-register.ts | 5 +- packages/contracts/src/index.ts | 2 + packages/db/src/index.ts | 2 + .../account-documents.integration.test.ts | 173 ++++++++++ .../db/src/repositories/account-documents.ts | 196 +++++++++++ .../db/src/repositories/contract-documents.ts | 5 +- packages/db/src/schema/account-documents.ts | 57 +++ packages/db/src/schema/contracts.ts | 4 +- packages/db/src/schema/index.ts | 6 +- .../migrations/001482_account_documents.sql | 49 +++ .../tests/1482_account_documents.test.sql | 27 ++ 31 files changed, 1522 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route.ts create mode 100644 apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route.ts create mode 100644 apps/web/src/features/internal-ops/account-documents/documents.module.css create mode 100644 apps/web/src/features/internal-ops/account-documents/documents.test.tsx create mode 100644 apps/web/src/features/internal-ops/account-documents/documents.tsx create mode 100644 apps/web/src/features/internal-ops/account-documents/http.test.ts create mode 100644 apps/web/src/features/internal-ops/account-documents/http.ts create mode 100644 apps/web/src/features/internal-ops/account-documents/section.tsx create mode 100644 apps/web/src/features/internal-ops/account-documents/server.ts create mode 100644 apps/web/src/i18n/messages/operations-account-documents.ts create mode 100644 packages/contracts/src/account-documents.ts create mode 100644 packages/db/src/repositories/account-documents.integration.test.ts create mode 100644 packages/db/src/repositories/account-documents.ts create mode 100644 packages/db/src/schema/account-documents.ts create mode 100644 supabase/migrations/001482_account_documents.sql create mode 100644 supabase/tests/1482_account_documents.test.sql diff --git a/apps/web/app/(experience)/(internal)/internal/accounts/[id]/page.tsx b/apps/web/app/(experience)/(internal)/internal/accounts/[id]/page.tsx index 2f96c9d2..50314b5a 100644 --- a/apps/web/app/(experience)/(internal)/internal/accounts/[id]/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/accounts/[id]/page.tsx @@ -1,3 +1,4 @@ +import { AccountDocumentsSection } from "@/src/features/internal-ops/account-documents/section"; import type { Metadata } from "next"; import Link from "next/link"; @@ -62,7 +63,16 @@ async function Page({ params }: { params: Promise<{ id: string }> }) { )} } - supporting={} + supporting={ + <> + {record?.aggregateId ? ( + + ) : null} + + + } /> ); } diff --git a/apps/web/app/(experience)/(internal)/internal/organizations/[id]/page.tsx b/apps/web/app/(experience)/(internal)/internal/organizations/[id]/page.tsx index ab02ae04..4ba969ae 100644 --- a/apps/web/app/(experience)/(internal)/internal/organizations/[id]/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/organizations/[id]/page.tsx @@ -1,3 +1,4 @@ +import { AccountDocumentsSection } from "@/src/features/internal-ops/account-documents/section"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { cache } from "react"; @@ -60,6 +61,9 @@ async function Page({ organization={organization} created={query.created === "1"} > + }) { + } partner={loaded.partner} deals={loaded.deals} activity={loaded.activity} diff --git a/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route.ts b/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route.ts new file mode 100644 index 00000000..d13f1e8b --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route.ts @@ -0,0 +1 @@ +export { downloadAccountDocument as GET } from "@/src/features/internal-ops/account-documents/http"; diff --git a/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route.ts b/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route.ts new file mode 100644 index 00000000..1f0e6f48 --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route.ts @@ -0,0 +1 @@ +export { uploadAccountDocument as POST } from "@/src/features/internal-ops/account-documents/http"; diff --git a/apps/web/e2e/production-proof.spec.ts b/apps/web/e2e/production-proof.spec.ts index 3a5ccb63..632c0cef 100644 --- a/apps/web/e2e/production-proof.spec.ts +++ b/apps/web/e2e/production-proof.spec.ts @@ -1330,3 +1330,83 @@ test("@internal creates an access token, calls MCP with it and revokes it", asyn const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); + +test("@internal attaches external documents to partners and accounts", async ({ + page, +}) => { + const name = `Attachment proof ${Date.now()}`; + const pdf = Buffer.from(`%PDF-1.7\nExternal MNDA ${name}\n%%EOF`); + await page.goto("/internal/partners/new"); + await page.getByLabel(/^Partner name/u).fill(name); + await page.getByRole("button", { name: "Save partner" }).click(); + await expect(page).toHaveURL(/\/internal\/partners\/[0-9a-f-]{36}$/u, { + timeout: 15_000, + }); + const partnerUrl = page.url(); + async function attach(title: string) { + await expect(page.getByLabel("Choose PDF")).toBeEnabled(); + await page.getByLabel("Choose PDF").setInputFiles({ + name: "External MNDA.pdf", + mimeType: "application/pdf", + buffer: pdf, + }); + await page.getByLabel("Document title", { exact: true }).fill(title); + await page.getByLabel("Signed on (optional)").fill("2026-10-10"); + await page + .getByRole("button", { name: "Attach document", exact: true }) + .click(); + await expect(page.getByText(`“${title}” is attached.`)).toBeVisible(); + await page.reload(); + const link = page.getByRole("link", { + name: `Download ${title}`, + exact: true, + }); + await expect(link).toBeVisible(); + const href = await link.getAttribute("href"); + if (!href) throw new Error("The saved attachment has no download link"); + const response = await page.request.get(href); + expect(response.status()).toBe(200); + expect(await response.body()).toEqual(pdf); + return href; + } + const partnerFile = await attach("Signed external partner MNDA"); + const documents = page.getByRole("region", { + name: "Documents", + exact: true, + }); + await expect(documents.getByText("Uploaded externally")).toBeVisible(); + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations).toEqual([]); + await documents.screenshot({ + path: test.info().outputPath("partner-documents-desktop.png"), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await documents.screenshot({ + path: test.info().outputPath("partner-documents-mobile.png"), + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await page.setViewportSize({ width: 1280, height: 720 }); + + // The organization and account views share the account, not the partner ID. + await page.goto( + "/internal/organizations/30000000-0000-4000-8000-000000000001", + ); + const accountFile = await attach("Signed external customer MNDA"); + expect(accountFile).toContain(`/account/${customerAccount}/`); + const mismatched = partnerFile.replace( + /\/partner\/[^/]+\//u, + `/account/${customerAccount}/`, + ); + expect((await page.request.get(mismatched)).status()).toBe(404); + await page.goto(partnerUrl); + await expect( + page.getByRole("link", { name: "Download Signed external partner MNDA" }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Download Signed external customer MNDA" }), + ).toHaveCount(0); +}); diff --git a/apps/web/src/features/internal-ops/account-documents/documents.module.css b/apps/web/src/features/internal-ops/account-documents/documents.module.css new file mode 100644 index 00000000..d012638d --- /dev/null +++ b/apps/web/src/features/internal-ops/account-documents/documents.module.css @@ -0,0 +1,41 @@ +.form { + display: grid; + gap: var(--cw-space-3); + margin-top: var(--cw-space-4); +} +.fields { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); + gap: var(--cw-space-3); +} +.fileRow { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--cw-space-3); +} +.fileRow > label { + flex: 1; + min-width: 0; +} +.filename { + overflow-wrap: anywhere; +} +.metadata { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--cw-space-2); +} +@media (max-width: 48rem) { + .fileRow { + display: grid; + grid-template-columns: minmax(0, 1fr); + } + .fileRow > button { + justify-self: start; + } + .fields { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/web/src/features/internal-ops/account-documents/documents.test.tsx b/apps/web/src/features/internal-ops/account-documents/documents.test.tsx new file mode 100644 index 00000000..3e7714c0 --- /dev/null +++ b/apps/web/src/features/internal-ops/account-documents/documents.test.tsx @@ -0,0 +1,102 @@ +import { renderToString } from "react-dom/server"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ refresh: vi.fn(), fetch: vi.fn() })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: mocks.refresh }), +})); +import { AccountDocuments } from "./documents"; +const target = { + type: "partner" as const, + id: "10000000-0000-4000-8000-000000000001", +}; +const file = { + id: "90000000-0000-4000-8000-000000000001", + title: "External MNDA", + kind: "mnda" as const, + signedOn: "2026-10-10", + fileName: "Signed MNDA.pdf", + sizeBytes: 1024, + uploadedByName: "Iris", + createdAt: "2026-10-20T12:00:00Z", +}; +const choose = (name = "Signed MNDA.pdf", type = "application/pdf") => + fireEvent.change(screen.getByLabelText("Choose PDF"), { + target: { files: [new File(["%PDF-1.7"], name, { type })] }, + }); +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", mocks.fetch); +}); +it("shows external provenance, signing date and target-scoped links to read-only staff", () => { + render(); + expect(screen.getByText("Uploaded externally")).toBeInTheDocument(); + expect(screen.getByText("Signed Oct 10, 2026")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Download External MNDA" }), + ).toHaveAttribute( + "href", + `/internal/record-documents/partner/${target.id}/${file.id}`, + ); + expect(screen.queryByLabelText("Choose PDF")).not.toBeInTheDocument(); +}); +it("keeps details and the same retry ID after an interrupted upload, then shows the saved document immediately", async () => { + mocks.fetch.mockRejectedValueOnce(new Error("network interrupted")); + mocks.fetch.mockResolvedValueOnce(Response.json({ ok: true, value: file })); + render(); + choose(); + expect(screen.getByLabelText("Document title")).toHaveValue("Signed MNDA"); + fireEvent.change(screen.getByLabelText("Document title"), { + target: { value: "External MNDA" }, + }); + fireEvent.change(screen.getByLabelText("Signed on (optional)"), { + target: { value: "2026-10-10" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Attach document" })); + await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Attach document" }), + ).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole("button", { name: "Attach document" })); + await screen.findByRole("link", { name: "Download External MNDA" }); + const metadata = mocks.fetch.mock.calls.map((call) => { + const init = call[1] as RequestInit; + return JSON.parse((init.body as FormData).get("item") as string) as Record< + string, + unknown + >; + }); + expect(metadata[0]).toEqual(metadata[1]); + expect(metadata[0]).toMatchObject({ + title: "External MNDA", + kind: "mnda", + signedOn: "2026-10-10", + }); + expect(screen.getByLabelText("Document title")).toHaveValue(""); + expect(mocks.refresh).toHaveBeenCalledOnce(); +}); +it("refuses unsupported files before sending and keeps the form available", async () => { + render(); + choose("notes.txt", "text/plain"); + fireEvent.click(screen.getByRole("button", { name: "Attach document" })); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect( + await screen.findByText(/Only PDF files can be uploaded/i), + ).toBeInTheDocument(); +}); +it("keeps uploads unavailable in demo mode", () => { + render(); + expect(screen.queryByLabelText("Choose PDF")).not.toBeInTheDocument(); + expect(screen.getByText(/live staff workspace/)).toBeInTheDocument(); +}); + +it("keeps the file picker disabled until its client handler is ready", () => { + const markup = renderToString( + , + ); + const container = document.createElement("div"); + container.innerHTML = markup; + expect(container.querySelector('input[type="file"]')).toBeDisabled(); +}); diff --git a/apps/web/src/features/internal-ops/account-documents/documents.tsx b/apps/web/src/features/internal-ops/account-documents/documents.tsx new file mode 100644 index 00000000..245f4d19 --- /dev/null +++ b/apps/web/src/features/internal-ops/account-documents/documents.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { useId, useRef, useState, useSyncExternalStore } from "react"; +import { useRouter } from "next/navigation"; +import { + AccountDocumentInputSchema, + accountDocumentKinds, + type AccountDocumentKind, + type AccountDocumentRecord, + type AccountDocumentTarget, +} from "@clockwork/contracts"; +import { + Button, + EmptyState, + FileText, + InlineNotice, + Input, + Select, + StatusBadge, + buttonClassName, +} from "@clockwork/ui"; +import { useFormattingLocale, useTranslations } from "@/src/i18n/client"; +import type { MessageId } from "@/src/i18n"; +import { + errorMessage, + formatContractDate, + formatFileSize, +} from "../contracts/copy"; +import { localFileProblem, postDocumentForm } from "../contracts/upload-client"; +import { LocalTimestamp } from "../local-timestamp"; +import { SessionExpiredReload } from "../session-expiry"; +import styles from "../contracts/contracts.module.css"; +import attachmentStyles from "./documents.module.css"; + +const noSubscription = () => () => {}; +const onClient = () => true; +const onServer = () => false; + +const kindLabels: Record = { + mnda: "operations.accountDocuments.kind.mnda", + agreement: "operations.accountDocuments.kind.agreement", + other: "operations.accountDocuments.kind.other", +}; +const errors: Record = { + ATTACHMENT_ALREADY_EXISTS: "operations.accountDocuments.duplicate", + ATTACHMENT_RETRY_CONFLICT: "operations.accountDocuments.retry", + ATTACHMENT_TARGET_NOT_FOUND: "operations.accountDocuments.missing", + ATTACHMENT_NOT_FOUND: "operations.accountDocuments.missing", + CONTRACT_FORBIDDEN: "operations.accountDocuments.forbidden", + INVALID_INPUT: "operations.accountDocuments.invalid", +}; + +export function AccountDocuments({ + target, + files, + canWrite, + demo = false, +}: { + target: AccountDocumentTarget; + files: readonly AccountDocumentRecord[]; + canWrite: boolean; + demo?: boolean; +}) { + const hydrated = useSyncExternalStore(noSubscription, onClient, onServer); + const t = useTranslations(); + const locale = useFormattingLocale(); + const router = useRouter(); + const heading = useId(); + const fileInput = useRef(null); + const uploading = useRef(false); + const requestId = useRef(null); + const [title, setTitle] = useState(""); + const [kind, setKind] = useState("mnda"); + const [signedOn, setSignedOn] = useState(""); + const [file, setFile] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + // Keep the new row visible immediately, even while the server refresh runs. + const [added, setAdded] = useState([]); + const visible = [ + ...added.filter((row) => !files.some((f) => f.id === row.id)), + ...files, + ]; + const base = `/internal/record-documents/${target.type}/${target.id}`; + function changed() { + requestId.current = null; + setError(null); + setNotice(null); + } + async function upload() { + if (!file || uploading.current) return; + const problem = localFileProblem(file); + if (problem) { + setError(problem); + return; + } + requestId.current ??= crypto.randomUUID(); + const input = AccountDocumentInputSchema.safeParse({ + id: requestId.current, + title, + kind, + signedOn, + }); + if (!input.success) { + setError("INVALID_INPUT"); + return; + } + uploading.current = true; + setBusy(true); + setError(null); + setNotice(null); + const form = new FormData(); + form.set("item", JSON.stringify(input.data)); + form.set("file", file); + try { + const result = await postDocumentForm(base, form); + if (!result.ok) { + setError(result.code); + return; + } + setAdded((rows) => [ + result.value, + ...rows.filter((row) => row.id !== result.value.id), + ]); + setNotice( + t("operations.accountDocuments.uploaded", { name: result.value.title }), + ); + setFile(null); + setTitle(""); + setSignedOn(""); + requestId.current = null; + if (fileInput.current) fileInput.current.value = ""; + router.refresh(); + } finally { + uploading.current = false; + setBusy(false); + } + } + return ( +
+
+
+

{t("operations.accountDocuments.title")}

+

{t("operations.accountDocuments.description")}

+
+
+ {error ? ( + setError(null)} /> + ), + } + : {})} + /> + ) : null} + {notice ? ( + + ) : null} + {demo ? ( + + ) : visible.length === 0 ? ( + + ) : ( +
    + {visible.map((document) => ( +
  • + + + {document.title} + + {document.fileName} ·{" "} + {formatFileSize(document.sizeBytes, locale)} + + + + {t(kindLabels[document.kind])} + + + {t("operations.accountDocuments.external")} + + {document.signedOn ? ( + + {t("operations.accountDocuments.signed", { + date: formatContractDate(document.signedOn, locale), + })} + + ) : null} + + + {document.uploadedByName} ·{" "} + + + + + + {t("operations.accountDocuments.view")} + + + {t("operations.accountDocuments.download")} + + +
  • + ))} +
+ )} + {canWrite && !demo ? ( +
{ + event.preventDefault(); + void upload(); + }} + > +
+ { + changed(); + setTitle(e.target.value); + }} + /> + { + changed(); + setSignedOn(e.target.value); + }} + /> +
+
+ + +
+

+ {t("operations.accountDocuments.rules")} +

+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/features/internal-ops/account-documents/http.test.ts b/apps/web/src/features/internal-ops/account-documents/http.test.ts new file mode 100644 index 00000000..56884713 --- /dev/null +++ b/apps/web/src/features/internal-ops/account-documents/http.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type * as Http from "../contracts/http"; +import type * as Server from "./server"; +const mocks = vi.hoisted(() => ({ + session: vi.fn(), + demo: vi.fn(), + form: vi.fn(), + repo: { add: vi.fn(), read: vi.fn() }, +})); +vi.mock("@/src/auth/session", () => ({ + getCommerceSession: mocks.session, + explicitDemoIdentityEnabled: mocks.demo, +})); +vi.mock("@/src/db/service", () => ({ getServiceDatabase: vi.fn() })); +vi.mock("./server", async (original) => ({ + ...(await original()), + accountDocumentRepository: () => mocks.repo, +})); +vi.mock("../contracts/http", async (original) => ({ + ...(await original()), + readUploadForm: mocks.form, + uploadedPdf: () => ({ + fileName: "Signed MNDA.pdf", + bytes: new Uint8Array([37, 80, 68, 70, 45]), + }), +})); +import { uploadAccountDocument, downloadAccountDocument } from "./http"; +const origin = "https://commerce.fil.one"; +const id = "10000000-0000-4000-8000-000000000001"; +const fileId = "90000000-0000-4000-8000-000000000001"; +const params = (type = "partner", targetId = id) => ({ + params: Promise.resolve({ type, id: targetId, fileId }), +}); +const request = (from = origin) => + new Request(`${origin}/upload`, { + method: "POST", + headers: { origin: from }, + }); +const session = (patch = {}) => ({ + userId: id, + profile: { name: "Staff" }, + roles: [], + permissions: ["sales:read", "operations:read", "contract:write"], + isInternalStaff: true, + mfaVerified: true, + ...patch, +}); +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("APP_ORIGIN", origin); + mocks.demo.mockReturnValue(false); + mocks.session.mockResolvedValue(session()); + const form = new FormData(); + form.set( + "item", + JSON.stringify({ + id: fileId, + title: "External MNDA", + kind: "mnda", + signedOn: "2026-10-10", + }), + ); + mocks.form.mockResolvedValue(form); + mocks.repo.add.mockResolvedValue({ id: fileId }); + mocks.repo.read.mockResolvedValue({ + file: { fileName: "Signed MNDA.pdf" }, + bytes: new Uint8Array([37, 80, 68, 70, 45]), + }); +}); +it.each(["partner", "account"])( + "uploads to an explicit %s target using the authenticated actor", + async (type) => { + const response = await uploadAccountDocument(request(), params(type)); + expect(response.status).toBe(200); + expect(mocks.repo.add).toHaveBeenCalledWith( + { type, id }, + expect.objectContaining({ id: fileId, kind: "mnda" }), + expect.anything(), + { kind: "user", id, display: "Staff" }, + ); + expect(response.headers.get("cache-control")).toContain("no-store"); + }, +); +it("rejects cross-origin upload before reading the body or session", async () => { + expect( + (await uploadAccountDocument(request("https://elsewhere.test"), params())) + .status, + ).toBe(403); + expect(mocks.session).not.toHaveBeenCalled(); + expect(mocks.form).not.toHaveBeenCalled(); +}); +it.each([ + [{ isInternalStaff: false }, 403], + [{ mfaVerified: false }, 401], + [{ impersonation: {} }, 403], + [{ assistedSession: {} }, 403], + [{ permissions: ["sales:read"] }, 403], + [{ permissions: ["contract:write"] }, 403], +])( + "refuses unauthorized upload %j before reading bytes", + async (patch, status) => { + mocks.session.mockResolvedValue(session(patch)); + expect((await uploadAccountDocument(request(), params())).status).toBe( + status, + ); + expect(mocks.repo.add).not.toHaveBeenCalled(); + expect(mocks.form).not.toHaveBeenCalled(); + }, +); +it("does not let a partner-only reader access account files", async () => { + mocks.session.mockResolvedValue( + session({ permissions: ["sales:read", "contract:write"] }), + ); + expect( + (await downloadAccountDocument(new Request(origin), params("account"))) + .status, + ).toBe(403); + expect(mocks.repo.read).not.toHaveBeenCalled(); +}); +it("never lets demo identities reach stored evidence", async () => { + mocks.demo.mockReturnValue(true); + expect( + (await downloadAccountDocument(new Request(origin), params())).status, + ).toBe(403); + expect(mocks.repo.read).not.toHaveBeenCalled(); +}); +it("allows read-only staff to download, with private and sandboxed PDF headers", async () => { + mocks.session.mockResolvedValue(session({ permissions: ["sales:read"] })); + const response = await downloadAccountDocument( + new Request(`${origin}/file?view=1`), + params(), + ); + expect(response.status).toBe(200); + expect(mocks.repo.read).toHaveBeenCalledWith( + { type: "partner", id }, + fileId, + { kind: "user", id, display: "Staff" }, + ); + expect(response.headers.get("content-disposition")).toMatch(/^inline;/); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("cache-control")).toContain("no-store"); +}); +it("returns safe not-found and validation failures", async () => { + expect( + (await uploadAccountDocument(request(), params("unknown"))).status, + ).toBe(422); + mocks.repo.read.mockRejectedValue(new Error("ATTACHMENT_NOT_FOUND")); + expect( + (await downloadAccountDocument(new Request(origin), params())).status, + ).toBe(404); + mocks.repo.add.mockRejectedValue( + new Error("database credentials must not leak"), + ); + const response = await uploadAccountDocument(request(), params()); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ ok: false, code: "UNEXPECTED" }); +}); diff --git a/apps/web/src/features/internal-ops/account-documents/http.ts b/apps/web/src/features/internal-ops/account-documents/http.ts new file mode 100644 index 00000000..e3574f22 --- /dev/null +++ b/apps/web/src/features/internal-ops/account-documents/http.ts @@ -0,0 +1,73 @@ +import "server-only"; +import { z } from "zod"; +import { + AccountDocumentInputSchema, + AccountDocumentTargetSchema, +} from "@clockwork/contracts"; +import { contractActor } from "../contracts/server"; +import { + fromApplicationOrigin, + itemField, + jsonFailure, + pdfResponse, + readUploadForm, + uploadedPdf, + withDocumentSlot, +} from "../contracts/http"; +import { accountDocumentRepository, accountDocumentStaff } from "./server"; + +type Params = { type: string; id: string; fileId?: string }; +export async function uploadAccountDocument( + request: Request, + context: { params: Promise }, +) { + if (!fromApplicationOrigin(request)) + return new Response(null, { status: 403 }); + try { + const { type, id } = await context.params; + const target = AccountDocumentTargetSchema.parse({ type, id }); + const session = await accountDocumentStaff(target, true); + const file = await withDocumentSlot(async () => { + const form = await readUploadForm(request); + const input = AccountDocumentInputSchema.parse(itemField(form)); + return accountDocumentRepository().add( + target, + input, + await uploadedPdf(form), + contractActor(session), + ); + }); + return Response.json( + { ok: true, value: file }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (error) { + return jsonFailure(error); + } +} +export async function downloadAccountDocument( + request: Request, + context: { params: Promise }, +) { + try { + const { type, id, fileId } = await context.params; + const target = AccountDocumentTargetSchema.parse({ type, id }); + const session = await accountDocumentStaff(target); + return await withDocumentSlot(async () => { + const { file, bytes } = await accountDocumentRepository().read( + target, + z.uuid().parse(fileId), + contractActor(session), + ); + return pdfResponse( + bytes, + file.fileName, + new URL(request.url).searchParams.get("view") === "1" + ? "inline" + : "attachment", + ); + }); + } catch (error) { + return jsonFailure(error); + } +} diff --git a/apps/web/src/features/internal-ops/account-documents/section.tsx b/apps/web/src/features/internal-ops/account-documents/section.tsx new file mode 100644 index 00000000..45ecc7c7 --- /dev/null +++ b/apps/web/src/features/internal-ops/account-documents/section.tsx @@ -0,0 +1,42 @@ +import type { AccountDocumentTarget } from "@clockwork/contracts"; +import { InlineNotice } from "@clockwork/ui"; +import { explicitDemoIdentityEnabled } from "@/src/auth/session"; +import { getTranslations } from "@/src/i18n/server"; +import { sessionHas, ContractAccessError } from "../contracts/server"; +import { AccountDocuments } from "./documents"; +import { accountDocumentRepository, accountDocumentStaff } from "./server"; + +/** A failure to load attachments never hides the rest of the account. */ +export async function AccountDocumentsSection({ + target, +}: { + target: AccountDocumentTarget; +}) { + const t = await getTranslations(); + if (explicitDemoIdentityEnabled()) + return ( + + ); + try { + const session = await accountDocumentStaff(target); + const files = await accountDocumentRepository().list(target); + return ( + + ); + } catch (error) { + if (error instanceof ContractAccessError) return null; + console.error("Account documents could not be loaded", { + error: error instanceof Error ? error.name : "unknown", + }); + return ( + + ); + } +} diff --git a/apps/web/src/features/internal-ops/account-documents/server.ts b/apps/web/src/features/internal-ops/account-documents/server.ts new file mode 100644 index 00000000..850fc9a1 --- /dev/null +++ b/apps/web/src/features/internal-ops/account-documents/server.ts @@ -0,0 +1,24 @@ +import "server-only"; +import type { AccountDocumentTarget } from "@clockwork/contracts"; +import { AccountDocumentRepository } from "@clockwork/db"; +import { getServiceDatabase } from "@/src/db/service"; +import { + contractStaff, + documentStores, + sessionHas, + ContractAccessError, +} from "../contracts/server"; + +export async function accountDocumentStaff( + target: AccountDocumentTarget, + write = false, +) { + const session = await contractStaff( + target.type === "partner" ? "sales:read" : "operations:read", + ); + if (write && !sessionHas(session, "contract:write")) + throw new ContractAccessError("CONTRACT_FORBIDDEN"); + return session; +} +export const accountDocumentRepository = () => + new AccountDocumentRepository(getServiceDatabase(), documentStores); diff --git a/apps/web/src/features/internal-ops/contracts/upload-client.ts b/apps/web/src/features/internal-ops/contracts/upload-client.ts index 5f054b29..98dbe96f 100644 --- a/apps/web/src/features/internal-ops/contracts/upload-client.ts +++ b/apps/web/src/features/internal-ops/contracts/upload-client.ts @@ -50,3 +50,6 @@ export function uploadContractFile( export function postCollateral(url: string, form: FormData) { return post(url, form); } + +/** Shared multipart upload for staff document forms. */ +export { post as postDocumentForm }; diff --git a/apps/web/src/features/internal-ops/partners/partner-detail.tsx b/apps/web/src/features/internal-ops/partners/partner-detail.tsx index 1e181120..b795995c 100644 --- a/apps/web/src/features/internal-ops/partners/partner-detail.tsx +++ b/apps/web/src/features/internal-ops/partners/partner-detail.tsx @@ -423,6 +423,7 @@ export function PartnerDetail({ organizations, canEdit, demo, + documents, }: { t: Translator; locale: string; @@ -435,6 +436,7 @@ export function PartnerDetail({ organizations: readonly PartnerOrganizationOption[]; canEdit: boolean; demo: boolean; + documents?: ReactNode; }) { const overdue = partner.nextStepDue !== null && partner.nextStepDue < today; const overview: [string, ReactNode][] = [ @@ -538,6 +540,7 @@ export function PartnerDetail({ {demo ? ( ) : null} + {documents}
diff --git a/apps/web/src/i18n/catalogs.ts b/apps/web/src/i18n/catalogs.ts index 210817d6..3636787a 100644 --- a/apps/web/src/i18n/catalogs.ts +++ b/apps/web/src/i18n/catalogs.ts @@ -1,3 +1,4 @@ +import { accountDocumentMessages } from "./messages/operations-account-documents"; import { isPluralMessage, isSameInAllLanguages, @@ -79,6 +80,7 @@ export const messageModules = { operations: { messages: { ...operationsMessages, + ...accountDocumentMessages, ...operationsFinanceMessages, ...mndaMessages, ...salesMessages, diff --git a/apps/web/src/i18n/messages/operations-account-documents.ts b/apps/web/src/i18n/messages/operations-account-documents.ts new file mode 100644 index 00000000..d3a84b34 --- /dev/null +++ b/apps/web/src/i18n/messages/operations-account-documents.ts @@ -0,0 +1,52 @@ +import { defineStaffMessages } from "../define"; + +export const accountDocumentMessages = defineStaffMessages({ + "operations.accountDocuments.title": { en: "Documents" }, + "operations.accountDocuments.description": { + en: "Keep signed agreements and supporting PDFs with this record. Documents are visible to authorized Fil One staff.", + }, + "operations.accountDocuments.emptyTitle": { en: "No documents attached" }, + "operations.accountDocuments.emptyBody": { + en: "Attach an MNDA signed outside Commerce, an agreement, or a supporting document.", + }, + "operations.accountDocuments.name": { en: "Document title" }, + "operations.accountDocuments.kind": { en: "Document type" }, + "operations.accountDocuments.signedOn": { en: "Signed on (optional)" }, + "operations.accountDocuments.kind.mnda": { en: "MNDA / NDA" }, + "operations.accountDocuments.kind.agreement": { en: "Agreement" }, + "operations.accountDocuments.kind.other": { en: "Other document" }, + "operations.accountDocuments.choose": { en: "Choose PDF" }, + "operations.accountDocuments.upload": { en: "Attach document" }, + "operations.accountDocuments.uploading": { en: "Attaching…" }, + "operations.accountDocuments.rules": { + en: "PDF only, up to 25 MB. Attached documents are retained with their original details. Adding a file does not change signing or approval status.", + }, + "operations.accountDocuments.uploaded": { en: "“{name}” is attached." }, + "operations.accountDocuments.external": { en: "Uploaded externally" }, + "operations.accountDocuments.signed": { en: "Signed {date}" }, + "operations.accountDocuments.view": { en: "View" }, + "operations.accountDocuments.download": { en: "Download" }, + "operations.accountDocuments.viewNamed": { en: "View {name}" }, + "operations.accountDocuments.downloadNamed": { en: "Download {name}" }, + "operations.accountDocuments.unavailable": { + en: "Documents could not be loaded. Reload this page to try again.", + }, + "operations.accountDocuments.demo": { + en: "Document attachments are available on saved records in the live staff workspace.", + }, + "operations.accountDocuments.duplicate": { + en: "This PDF is already attached to this record. You can view or download it below.", + }, + "operations.accountDocuments.retry": { + en: "The previous upload used different details. Reload the page to check whether it was saved, then try again.", + }, + "operations.accountDocuments.missing": { + en: "This record or document is no longer available. Reload the page.", + }, + "operations.accountDocuments.invalid": { + en: "Enter a document title and check the signing date.", + }, + "operations.accountDocuments.forbidden": { + en: "You do not have permission to attach documents to this record.", + }, +}); diff --git a/apps/web/src/i18n/ownership.ts b/apps/web/src/i18n/ownership.ts index 1acbf572..bd89d30c 100644 --- a/apps/web/src/i18n/ownership.ts +++ b/apps/web/src/i18n/ownership.ts @@ -67,6 +67,7 @@ const messageModuleFiles: Readonly> = { "operations-contract-templates.ts": "operations", "operations-handoff.ts": "operations", "operations-partners.ts": "operations", + "operations-account-documents.ts": "operations", "operations-notifications.ts": "operations", "operations-organizations.ts": "operations", "platform.ts": "platform", diff --git a/docs/operations/partner-records.md b/docs/operations/partner-records.md index e8478912..ed3fd2d9 100644 --- a/docs/operations/partner-records.md +++ b/docs/operations/partner-records.md @@ -60,3 +60,30 @@ registration is created and no partner user is invited from a staff record. When a partner later gets a Commerce organization, link it on the record; the terms stay a staff record until someone decides how they map onto the engine's commission model. + +## Documents attached outside Commerce + +Open a saved partner and use **Documents → Choose PDF → Attach document**. +Record a title, choose **MNDA / NDA**, **Agreement** or **Other document**, and +optionally enter the signing date. PDFs may be up to 25 MB. Staff can view or +download each file with its original filename, uploader and upload time. + +The same section appears on organization and account detail pages. Those two +pages use the account's ID and show the same attachments. A standalone partner's +files stay on that partner record; linking an organization does not move files +or expand their audience. Attachments are staff-only and do not send signature +requests, mark an agreement executed, or satisfy onboarding approval gates. + +Reads require the record's existing permission (`sales:read` for partners, +`operations:read` for accounts); uploads also require `contract:write`. Every +request checks an actual staff session, MFA, and absence of impersonation or an +assisted session. Demo identities never read or write real attachments. + +Migration 001482 adds `commerce_account_documents`, with exactly one partner or +account foreign key per row. The existing document-store interface retains the +PDF using the `account_attachment` purpose. Rows and original details are +append-only for the service role; attached bytes are protected from cleanup. +Uploads and downloads append `account.document_attached` and +`account.document_downloaded` audit/outbox events. Retry IDs reuse the original +result only for identical content, metadata, target and uploader; an identical +PDF already on the same record is reported as a duplicate. diff --git a/packages/contracts/src/account-documents.ts b/packages/contracts/src/account-documents.ts new file mode 100644 index 00000000..1a6d934d --- /dev/null +++ b/packages/contracts/src/account-documents.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +/** Staff attachments belong to exactly one saved partner record or account. */ +export const AccountDocumentTargetSchema = z + .object({ + type: z.enum(["partner", "account"]), + id: z.guid(), + }) + .strict(); +export type AccountDocumentTarget = z.infer; +export const accountDocumentKinds = ["mnda", "agreement", "other"] as const; +export type AccountDocumentKind = (typeof accountDocumentKinds)[number]; +const line = (max: number) => + z + .string() + .trim() + .min(1) + .max(max) + .refine( + (value) => + [...value].every((character) => { + const code = character.charCodeAt(0); + return code >= 32 && code !== 127; + }), + "control_character", + ); +export const AccountDocumentInputSchema = z + .object({ + id: z.uuid(), + title: line(200), + kind: z.enum(accountDocumentKinds), + signedOn: z + .union([z.iso.date(), z.literal(""), z.null()]) + .transform((value) => value || null) + .default(null), + }) + .strict(); +export type AccountDocumentInput = z.infer; +export interface AccountDocumentRecord extends AccountDocumentInput { + fileName: string; + sizeBytes: number; + uploadedByName: string; + createdAt: string; +} diff --git a/packages/contracts/src/contract-register.ts b/packages/contracts/src/contract-register.ts index 992459f3..9dccea40 100644 --- a/packages/contracts/src/contract-register.ts +++ b/packages/contracts/src/contract-register.ts @@ -555,7 +555,10 @@ export interface ContractDocumentStore { readonly backend: string; put( bytes: Uint8Array, - options: { purpose: "contract" | "collateral"; contentType: string }, + options: { + purpose: "contract" | "collateral" | "account_attachment"; + contentType: string; + }, ): Promise; /** Throws `DOCUMENT_INTEGRITY` when the bytes no longer match their hash. */ get(key: string): Promise<{ bytes: Uint8Array; document: StoredDocument }>; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 290b571e..fe3940aa 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -16,3 +16,5 @@ export * from "./organization-onboarding"; export * from "./signing"; export * from "./staff-notifications"; export * from "./access-tokens"; + +export * from "./account-documents"; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 110b8e18..144f7430 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -22,3 +22,5 @@ export * from "./repositories/pricing-scenarios"; export * from "./repositories/partners"; export * from "./repositories/staff-notifications"; export * from "./repositories/access-tokens"; + +export * from "./repositories/account-documents"; diff --git a/packages/db/src/repositories/account-documents.integration.test.ts b/packages/db/src/repositories/account-documents.integration.test.ts new file mode 100644 index 00000000..17fe1389 --- /dev/null +++ b/packages/db/src/repositories/account-documents.integration.test.ts @@ -0,0 +1,173 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, describe, expect, it } from "vitest"; +import type { AccountDocumentTarget } from "@clockwork/contracts"; +import { createRuntimeDatabase } from "../client"; +import { AccountDocumentRepository } from "./account-documents"; +import { + ContractDocumentStores, + PostgresContractDocumentStore, +} from "./contract-documents"; +import { PartnerRepository } from "./partners"; + +const { client, db } = createRuntimeDatabase({ + url: + process.env.DIRECT_DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:54322/postgres?sslmode=disable", + role: "clockwork_service", + ssl: false, +}); +const store = new PostgresContractDocumentStore(db); +const repo = new AccountDocumentRepository( + db, + new ContractDocumentStores(store), +); +const actor = { + kind: "user" as const, + id: "20000000-0000-4000-8000-000000000001", + display: "Iris Operator", +}; +const account: AccountDocumentTarget = { + type: "account", + id: "10000000-0000-4000-8000-000000000001", +}; +const otherAccount: AccountDocumentTarget = { + type: "account", + id: "10000000-0000-4000-8000-000000000002", +}; +const input = () => ({ + id: randomUUID(), + title: "External MNDA", + kind: "mnda", + signedOn: "2026-10-15", +}); +const upload = () => ({ + fileName: "Signed MNDA.pdf", + bytes: Buffer.from(`%PDF-1.7\n${randomUUID()}\n%%EOF`), +}); +afterAll(() => client.end()); + +const documentCount = async () => { + const [row] = await client< + { count: number }[] + >`select count(*)::int as count from commerce_stored_documents where purpose = 'account_attachment'`; + return row?.count; +}; + +describe("staff account attachments", () => { + it("persists an externally signed MNDA on a standalone partner and audits its upload and download", async () => { + const partner = await new PartnerRepository(db).save( + { id: randomUUID(), name: `Attachment Partner ${randomUUID()}` }, + actor, + ); + const target: AccountDocumentTarget = { type: "partner", id: partner.id }; + const details = input(); + const pdf = upload(); + const file = await repo.add(target, details, pdf, actor); + expect(file).toMatchObject({ + ...details, + fileName: pdf.fileName, + uploadedByName: actor.display, + }); + expect(await repo.list(target)).toEqual([file]); + expect((await repo.read(target, file.id, actor)).bytes).toEqual(pdf.bytes); + const events = await client< + { event_type: string; after: Record }[] + >` + select event_type, after from audit_events where aggregate_id = ${file.id} + or after->>'documentId' = ${file.id}`; + expect(events.map((e) => e.event_type).sort()).toEqual([ + "account.document_attached", + "account.document_downloaded", + ]); + expect( + events.find((e) => e.event_type === "account.document_attached")?.after, + ).toMatchObject({ target, provenance: "external_upload" }); + const outbox = + await client`select id from outbox_messages where payload->>'aggregateId' = ${file.id}`; + expect(outbox).toHaveLength(1); + }); + + it("attaches to a customer account and rejects document IDs requested through another record", async () => { + const file = await repo.add(account, input(), upload(), actor); + expect(await repo.list(account)).toEqual(expect.arrayContaining([file])); + await expect(repo.read(otherAccount, file.id, actor)).rejects.toThrow( + "ATTACHMENT_NOT_FOUND", + ); + await expect( + repo.read({ type: "partner", id: account.id }, file.id, actor), + ).rejects.toThrow("ATTACHMENT_NOT_FOUND"); + }); + + it("makes concurrent retries idempotent, with one attachment, stored PDF and audit event", async () => { + const before = await documentCount(); + const details = input(); + const pdf = upload(); + const files = await Promise.all([ + repo.add(account, details, pdf, actor), + repo.add(account, details, pdf, actor), + ]); + expect(files[0]).toEqual(files[1]); + expect(await documentCount()).toBe((before ?? 0) + 1); + const events = + await client`select id from audit_events where aggregate_id = ${details.id}`; + expect(events).toHaveLength(1); + await expect( + repo.add( + account, + { ...details, title: "Changed after retry" }, + pdf, + actor, + ), + ).rejects.toThrow("ATTACHMENT_RETRY_CONFLICT"); + await expect(repo.add(otherAccount, details, pdf, actor)).rejects.toThrow( + "ATTACHMENT_RETRY_CONFLICT", + ); + expect(await documentCount()).toBe((before ?? 0) + 1); + }); + + it("rejects duplicate content on one record but allows the same agreement on a different account", async () => { + const pdf = upload(); + const first = await repo.add(account, input(), pdf, actor); + const before = await documentCount(); + await expect(repo.add(account, input(), pdf, actor)).rejects.toThrow( + "ATTACHMENT_ALREADY_EXISTS", + ); + expect(await documentCount()).toBe(before); + const second = await repo.add(otherAccount, input(), pdf, actor); + expect(second.id).not.toBe(first.id); + expect((await repo.read(otherAccount, second.id, actor)).bytes).toEqual( + pdf.bytes, + ); + }); + + it("leaves no bytes behind for missing targets, malformed files or failed inserts", async () => { + const before = await documentCount(); + await expect( + repo.add({ type: "account", id: randomUUID() }, input(), upload(), actor), + ).rejects.toThrow("ATTACHMENT_TARGET_NOT_FOUND"); + await expect( + repo.add( + account, + input(), + { fileName: "fake.pdf", bytes: Buffer.from("not a PDF") }, + actor, + ), + ).rejects.toThrow("DOCUMENT_NOT_PDF"); + await expect( + repo.add(account, input(), upload(), { ...actor, id: "invalid-uuid" }), + ).rejects.toThrow(); + expect(await documentCount()).toBe(before); + }); + + it("retains referenced bytes when cleanup runs after an ambiguous commit", async () => { + const file = await repo.add(account, input(), upload(), actor); + const [row] = await client< + { storage_key: string }[] + >`select storage_key from commerce_account_documents where id = ${file.id}`; + if (!row) throw new Error("Attachment was not persisted"); + await expect(store.delete(row.storage_key)).rejects.toMatchObject({ + cause: { message: "Stored document is still referenced" }, + }); + expect((await repo.read(account, file.id, actor)).file.id).toBe(file.id); + }); +}); diff --git a/packages/db/src/repositories/account-documents.ts b/packages/db/src/repositories/account-documents.ts new file mode 100644 index 00000000..9eb7d759 --- /dev/null +++ b/packages/db/src/repositories/account-documents.ts @@ -0,0 +1,196 @@ +import { createHash, randomUUID } from "node:crypto"; +import { and, desc, eq, sql } from "drizzle-orm"; +import { + AccountDocumentInputSchema, + AccountDocumentTargetSchema, + ContractFileNameSchema, + assertContractPdf, + type AccountDocumentRecord, + type AccountDocumentTarget, + type Actor, +} from "@clockwork/contracts"; +import type { RuntimeDatabase, RuntimeTransaction } from "../client"; +import { accounts } from "../schema"; +import { accountDocuments } from "../schema/account-documents"; +import { commercePartners } from "../schema/partners"; +import { withInternalTransaction } from "../transaction"; +import { appendAuditAndOutbox } from "./audit-outbox"; +import { + lazyDocumentStores, + type DocumentStoresSource, +} from "./contract-documents"; + +type Row = typeof accountDocuments.$inferSelect; +const scope = (target: AccountDocumentTarget) => + target.type === "partner" + ? eq(accountDocuments.partnerId, target.id) + : eq(accountDocuments.accountId, target.id); +const view = (row: Row): AccountDocumentRecord => ({ + id: row.id, + title: row.title, + kind: row.kind, + signedOn: row.signedOn, + fileName: row.fileName, + sizeBytes: row.sizeBytes, + uploadedByName: row.uploadedByName, + createdAt: row.createdAt.toISOString(), +}); + +async function requireTarget( + tx: RuntimeTransaction, + target: AccountDocumentTarget, +) { + const table = target.type === "partner" ? commercePartners : accounts; + const [record] = await tx + .select({ id: table.id }) + .from(table) + .where(eq(table.id, target.id)); + if (!record) throw new Error("ATTACHMENT_TARGET_NOT_FOUND"); +} + +/** Staff-only evidence. The web boundary checks staff, MFA and the target's + * read permission before every operation, plus contract:write for uploads. */ +export class AccountDocumentRepository { + private readonly stores; + constructor( + private readonly db: RuntimeDatabase, + source: DocumentStoresSource, + ) { + this.stores = lazyDocumentStores(source); + } + private tx(operation: (tx: RuntimeTransaction) => Promise) { + return withInternalTransaction(this.db, randomUUID(), operation); + } + list(rawTarget: AccountDocumentTarget) { + const target = AccountDocumentTargetSchema.parse(rawTarget); + return this.tx(async (tx) => { + await requireTarget(tx, target); + return ( + await tx + .select() + .from(accountDocuments) + .where(scope(target)) + .orderBy(desc(accountDocuments.createdAt), desc(accountDocuments.id)) + ).map(view); + }); + } + async add( + rawTarget: AccountDocumentTarget, + rawInput: unknown, + upload: { fileName: string; bytes: Uint8Array }, + actor: Actor & { kind: "user" }, + ) { + const target = AccountDocumentTargetSchema.parse(rawTarget); + const input = AccountDocumentInputSchema.parse(rawInput); + const fileName = ContractFileNameSchema.parse(upload.fileName); + assertContractPdf(upload.bytes); + const hash = createHash("sha256").update(upload.bytes).digest("hex"); + // Reject a missing target before writing bytes to the store. + await this.tx((tx) => requireTarget(tx, target)); + const store = this.stores().primary; + const stored = await store.put(upload.bytes, { + purpose: "account_attachment", + contentType: "application/pdf", + }); + try { + const result = await this.tx(async (tx) => { + // Serializes uploads and content duplicates for this one record. + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`attachment:${target.type}:${target.id}`}, 0))`, + ); + await requireTarget(tx, target); + const [retry] = await tx + .select() + .from(accountDocuments) + .where(eq(accountDocuments.id, input.id)); + if (retry) { + if ( + (target.type === "partner" ? retry.partnerId : retry.accountId) !== + target.id || + retry.sha256 !== hash || + retry.title !== input.title || + retry.kind !== input.kind || + retry.signedOn !== input.signedOn || + retry.fileName !== fileName || + retry.uploadedById !== actor.id + ) + throw new Error("ATTACHMENT_RETRY_CONFLICT"); + return { row: retry, reused: true }; + } + const [duplicate] = await tx + .select({ id: accountDocuments.id }) + .from(accountDocuments) + .where(and(scope(target), eq(accountDocuments.sha256, hash))); + if (duplicate) throw new Error("ATTACHMENT_ALREADY_EXISTS"); + const [row] = await tx + .insert(accountDocuments) + .values({ + ...input, + partnerId: target.type === "partner" ? target.id : null, + accountId: target.type === "account" ? target.id : null, + fileName, + storageBackend: stored.backend, + storageKey: stored.key, + sha256: stored.sha256, + sizeBytes: stored.sizeBytes, + contentType: stored.contentType, + uploadedById: actor.id, + uploadedByName: actor.display ?? actor.id, + }) + .returning(); + if (!row) throw new Error("ATTACHMENT_UNAVAILABLE"); + await appendAuditAndOutbox(tx, { + ...(target.type === "account" ? { accountId: target.id } : {}), + aggregateType: "document", + aggregateId: row.id, + aggregateVersion: 1, + eventType: "account.document_attached", + actor, + requestId: randomUUID(), + after: { + target, + title: row.title, + kind: row.kind, + signedOn: row.signedOn, + fileName, + sha256: row.sha256, + provenance: "external_upload", + }, + }); + return { row, reused: false }; + }); + if (result.reused) await store.delete(stored.key).catch(() => {}); + return view(result.row); + } catch (error) { + // The storage guard preserves evidence even if COMMIT succeeded but its + // response was lost. Only unreferenced bytes can be cleaned up here. + await store.delete(stored.key).catch(() => {}); + throw error; + } + } + async read(rawTarget: AccountDocumentTarget, id: string, actor: Actor) { + const target = AccountDocumentTargetSchema.parse(rawTarget); + const row = await this.tx(async (tx) => { + const [file] = await tx + .select() + .from(accountDocuments) + .where(and(scope(target), eq(accountDocuments.id, id))); + if (!file) throw new Error("ATTACHMENT_NOT_FOUND"); + return file; + }); + const bytes = await this.stores().read(row); + await this.tx((tx) => + appendAuditAndOutbox(tx, { + ...(target.type === "account" ? { accountId: target.id } : {}), + aggregateType: "document", + aggregateId: randomUUID(), + aggregateVersion: 1, + eventType: "account.document_downloaded", + actor, + requestId: randomUUID(), + after: { target, documentId: row.id }, + }), + ); + return { file: view(row), bytes }; + } +} diff --git a/packages/db/src/repositories/contract-documents.ts b/packages/db/src/repositories/contract-documents.ts index f3028e7e..f081cd52 100644 --- a/packages/db/src/repositories/contract-documents.ts +++ b/packages/db/src/repositories/contract-documents.ts @@ -35,7 +35,10 @@ export class PostgresContractDocumentStore implements ContractDocumentStore { async put( bytes: Uint8Array, - options: { purpose: "contract" | "collateral"; contentType: string }, + options: { + purpose: "contract" | "collateral" | "account_attachment"; + contentType: string; + }, ): Promise { if (options.contentType !== contractDocumentContentType) throw new Error("DOCUMENT_NOT_PDF"); diff --git a/packages/db/src/schema/account-documents.ts b/packages/db/src/schema/account-documents.ts new file mode 100644 index 00000000..7b6056c5 --- /dev/null +++ b/packages/db/src/schema/account-documents.ts @@ -0,0 +1,57 @@ +import { sql } from "drizzle-orm"; +import { + date, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import type { AccountDocumentKind } from "@clockwork/contracts"; +import { accounts } from "../schema"; +import { commercePartners } from "./partners"; + +export const accountDocuments = pgTable( + "commerce_account_documents", + { + id: uuid("id").primaryKey(), + partnerId: uuid("partner_id").references(() => commercePartners.id), + accountId: uuid("account_id").references(() => accounts.id), + title: text("title").notNull(), + kind: text("kind").$type().notNull(), + signedOn: date("signed_on", { mode: "string" }), + fileName: text("file_name").notNull(), + storageBackend: text("storage_backend").notNull(), + storageKey: text("storage_key").notNull(), + sha256: text("sha256").notNull(), + sizeBytes: integer("size_bytes").notNull(), + contentType: text("content_type").notNull(), + uploadedById: uuid("uploaded_by_id").notNull(), + uploadedByName: text("uploaded_by_name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("account_documents_storage").on(t.storageBackend, t.storageKey), + uniqueIndex("account_documents_partner_hash") + .on(t.partnerId, t.sha256) + .where(sql`${t.partnerId} is not null`), + uniqueIndex("account_documents_account_hash") + .on(t.accountId, t.sha256) + .where(sql`${t.accountId} is not null`), + index("account_documents_partner").on( + t.partnerId, + t.createdAt.desc(), + t.id, + ), + index("account_documents_account").on( + t.accountId, + t.createdAt.desc(), + t.id, + ), + ], +); +export const accountDocumentsSchema = { accountDocuments }; diff --git a/packages/db/src/schema/contracts.ts b/packages/db/src/schema/contracts.ts index d8d1db6f..aa2f8de1 100644 --- a/packages/db/src/schema/contracts.ts +++ b/packages/db/src/schema/contracts.ts @@ -38,7 +38,9 @@ const bytea = customType<{ data: Buffer; driverData: Buffer }>({ export const storedDocuments = pgTable("commerce_stored_documents", { id: uuid("id").primaryKey(), - purpose: text("purpose").$type<"contract" | "collateral">().notNull(), + purpose: text("purpose") + .$type<"contract" | "collateral" | "account_attachment">() + .notNull(), contentType: text("content_type").notNull(), sizeBytes: integer("size_bytes").notNull(), sha256: text("sha256").notNull(), diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index fac7eddb..961bc036 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,3 +1,4 @@ +import { accountDocumentsSchema } from "./account-documents"; import * as foundationSchema from "../schema"; import { coreSchema } from "./core"; import { lifecycleSchema } from "./lifecycle"; @@ -14,6 +15,7 @@ import { contractTemplatesSchema } from "./contract-templates"; import { accessTokensSchema } from "./access-tokens"; export * from "../schema"; +export * from "./account-documents"; export * from "./core"; export * from "./lifecycle"; export * from "./system"; @@ -42,7 +44,9 @@ export const runtimeSchema: typeof foundationSchema & typeof partnersSchema & typeof staffNotificationsSchema & typeof contractTemplatesSchema & - typeof accessTokensSchema = { + typeof accessTokensSchema & + typeof accountDocumentsSchema = { + ...accountDocumentsSchema, ...foundationSchema, ...coreSchema, ...lifecycleSchema, diff --git a/supabase/migrations/001482_account_documents.sql b/supabase/migrations/001482_account_documents.sql new file mode 100644 index 00000000..98ad3d57 --- /dev/null +++ b/supabase/migrations/001482_account_documents.sql @@ -0,0 +1,49 @@ +-- Externally supplied PDFs attached explicitly to a staff partner or account. +-- These are retained evidence, not a signing request or a lifecycle agreement. +alter table public.commerce_stored_documents drop constraint commerce_stored_documents_purpose_check; +alter table public.commerce_stored_documents add constraint commerce_stored_documents_purpose_check + check (purpose in ('contract', 'collateral', 'account_attachment')); + +create table public.commerce_account_documents ( + id uuid primary key, + partner_id uuid references public.commerce_partners(id), + account_id uuid references public.accounts(id), + title text not null check (length(btrim(title)) between 1 and 200), + kind text not null check (kind in ('mnda','agreement','other')), + signed_on date, + file_name text not null check (length(file_name) between 1 and 200), + storage_backend text not null, + storage_key text not null, + sha256 text not null check (sha256 ~ '^[a-f0-9]{64}$'), + size_bytes integer not null check (size_bytes between 5 and 26214400), + content_type text not null check (content_type = 'application/pdf'), + uploaded_by_id uuid not null, + uploaded_by_name text not null, + created_at timestamptz not null default now(), + constraint account_documents_one_target check (num_nonnulls(partner_id, account_id) = 1) +); +create unique index account_documents_storage on public.commerce_account_documents(storage_backend, storage_key); +create unique index account_documents_partner_hash on public.commerce_account_documents(partner_id, sha256) where partner_id is not null; +create unique index account_documents_account_hash on public.commerce_account_documents(account_id, sha256) where account_id is not null; +create index account_documents_partner on public.commerce_account_documents(partner_id, created_at desc, id); +create index account_documents_account on public.commerce_account_documents(account_id, created_at desc, id); + +-- The staff service has no permission to change or erase attached evidence. +alter table public.commerce_account_documents enable row level security; +alter table public.commerce_account_documents force row level security; +revoke all on public.commerce_account_documents from public, anon, authenticated, clockwork_runtime, clockwork_service; +grant select, insert on public.commerce_account_documents to clockwork_service; +create policy account_documents_read on public.commerce_account_documents for select to clockwork_service using (true); +create policy account_documents_insert on public.commerce_account_documents for insert to clockwork_service with check (true); + +-- Keep the original protection for contracts/collateral and include attachments. +-- Failed/ambiguous-commit cleanup must never delete a referenced PDF. +create or replace function public.protect_commerce_stored_document() returns trigger language plpgsql set search_path = public as $$ +begin + if exists (select 1 from public.commerce_contract_files where storage_backend = 'postgres' and storage_key = old.id::text) + or exists (select 1 from public.commerce_sales_collateral where storage_backend = 'postgres' and storage_key = old.id::text) + or exists (select 1 from public.commerce_account_documents where storage_backend = 'postgres' and storage_key = old.id::text) then + raise exception 'Stored document is still referenced'; + end if; + return old; +end $$; diff --git a/supabase/tests/1482_account_documents.test.sql b/supabase/tests/1482_account_documents.test.sql new file mode 100644 index 00000000..4e1242d0 --- /dev/null +++ b/supabase/tests/1482_account_documents.test.sql @@ -0,0 +1,27 @@ +begin; +select plan(13); +select has_table('public', 'commerce_account_documents', 'staff attachment table exists'); +select ok((select relrowsecurity and relforcerowsecurity from pg_class where oid = 'public.commerce_account_documents'::regclass), 'forced row security'); +select ok(has_table_privilege('clockwork_service','public.commerce_account_documents','SELECT') and has_table_privilege('clockwork_service','public.commerce_account_documents','INSERT'), 'staff service reads and appends'); +select ok(not has_table_privilege('clockwork_service','public.commerce_account_documents','UPDATE'), 'staff cannot overwrite evidence'); +select ok(not has_table_privilege('clockwork_service','public.commerce_account_documents','DELETE'), 'staff cannot erase evidence'); +select ok(not has_table_privilege('clockwork_runtime','public.commerce_account_documents','SELECT'), 'tenant runtime cannot read staff attachments'); +select ok(not has_table_privilege('authenticated','public.commerce_account_documents','SELECT'), 'browser cannot read attachments'); +select ok(not has_table_privilege('anon','public.commerce_account_documents','INSERT'), 'anonymous cannot attach'); +select throws_ok($$insert into commerce_account_documents(id,title,kind,file_name,storage_backend,storage_key,sha256,size_bytes,content_type,uploaded_by_id,uploaded_by_name) + values(gen_random_uuid(),'External NDA','mnda','NDA.pdf','postgres',gen_random_uuid()::text,repeat('a',64),8,'application/pdf',gen_random_uuid(),'Staff')$$, + '23514',null,'an explicit account or partner is required'); +select throws_ok($$insert into commerce_account_documents(id,account_id,partner_id,title,kind,file_name,storage_backend,storage_key,sha256,size_bytes,content_type,uploaded_by_id,uploaded_by_name) + values(gen_random_uuid(),gen_random_uuid(),gen_random_uuid(),'External NDA','mnda','NDA.pdf','postgres',gen_random_uuid()::text,repeat('a',64),8,'application/pdf',gen_random_uuid(),'Staff')$$, + '23514',null,'one target, never both'); +select throws_ok($$insert into commerce_account_documents(id,account_id,title,kind,file_name,storage_backend,storage_key,sha256,size_bytes,content_type,uploaded_by_id,uploaded_by_name) + values(gen_random_uuid(),gen_random_uuid(),'External NDA','mnda','NDA.pdf','postgres',gen_random_uuid()::text,repeat('a',64),8,'application/pdf',gen_random_uuid(),'Staff')$$, + '23503',null,'an account must exist'); +select throws_ok($$insert into commerce_account_documents(id,account_id,title,kind,file_name,storage_backend,storage_key,sha256,size_bytes,content_type,uploaded_by_id,uploaded_by_name) + values(gen_random_uuid(),gen_random_uuid(),'External NDA','mnda','NDA.pdf','postgres',gen_random_uuid()::text,repeat('a',64),26214401,'application/pdf',gen_random_uuid(),'Staff')$$, + '23514',null,'database enforces size limit'); +select throws_ok($$insert into commerce_account_documents(id,account_id,title,kind,file_name,storage_backend,storage_key,sha256,size_bytes,content_type,uploaded_by_id,uploaded_by_name) + values(gen_random_uuid(),gen_random_uuid(),'External NDA','mnda','NDA.pdf','postgres',gen_random_uuid()::text,repeat('a',64),8,'text/html',gen_random_uuid(),'Staff')$$, + '23514',null,'only PDF metadata'); +select * from finish(); +rollback; From a396d36b2c220269a7b3e791b48ffd9e0bee1425 Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 12:29:03 -0400 Subject: [PATCH 2/6] Complete route guards and patch release-blocking dependencies --- .../[type]/[id]/[fileId]/route.ts | 42 ++- .../record-documents/[type]/[id]/route.ts | 49 ++- apps/web/package.json | 2 +- .../account-documents/http.test.ts | 3 +- .../internal-ops/account-documents/http.ts | 73 ---- .../src/features/mcp/mcp-transport.test.ts | 33 ++ apps/web/src/features/mcp/server.ts | 3 + .../src/features/shell/handler-guards.test.ts | 3 + package.json | 2 +- pnpm-lock.yaml | 354 +++++++++--------- pnpm-workspace.yaml | 2 +- scripts/dependency-advisories.test.mjs | 52 +-- 12 files changed, 319 insertions(+), 299 deletions(-) delete mode 100644 apps/web/src/features/internal-ops/account-documents/http.ts diff --git a/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route.ts b/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route.ts index d13f1e8b..ed38ffda 100644 --- a/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route.ts +++ b/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route.ts @@ -1 +1,41 @@ -export { downloadAccountDocument as GET } from "@/src/features/internal-ops/account-documents/http"; +import "server-only"; +import { z } from "zod"; +import { AccountDocumentTargetSchema } from "@clockwork/contracts"; +import { contractActor } from "@/src/features/internal-ops/contracts/server"; +import { + jsonFailure, + pdfResponse, + withDocumentSlot, +} from "@/src/features/internal-ops/contracts/http"; +import { + accountDocumentRepository, + accountDocumentStaff, +} from "@/src/features/internal-ops/account-documents/server"; + +type Params = { type: string; id: string; fileId: string }; +export async function GET( + request: Request, + context: { params: Promise }, +) { + try { + const { type, id, fileId } = await context.params; + const target = AccountDocumentTargetSchema.parse({ type, id }); + const session = await accountDocumentStaff(target); + return await withDocumentSlot(async () => { + const { file, bytes } = await accountDocumentRepository().read( + target, + z.uuid().parse(fileId), + contractActor(session), + ); + return pdfResponse( + bytes, + file.fileName, + new URL(request.url).searchParams.get("view") === "1" + ? "inline" + : "attachment", + ); + }); + } catch (error) { + return jsonFailure(error); + } +} diff --git a/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route.ts b/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route.ts index 1f0e6f48..2e44ae62 100644 --- a/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route.ts +++ b/apps/web/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route.ts @@ -1 +1,48 @@ -export { uploadAccountDocument as POST } from "@/src/features/internal-ops/account-documents/http"; +import "server-only"; +import { + AccountDocumentInputSchema, + AccountDocumentTargetSchema, +} from "@clockwork/contracts"; +import { contractActor } from "@/src/features/internal-ops/contracts/server"; +import { + fromApplicationOrigin, + itemField, + jsonFailure, + readUploadForm, + uploadedPdf, + withDocumentSlot, +} from "@/src/features/internal-ops/contracts/http"; +import { + accountDocumentRepository, + accountDocumentStaff, +} from "@/src/features/internal-ops/account-documents/server"; + +type Params = { type: string; id: string }; +export async function POST( + request: Request, + context: { params: Promise }, +) { + if (!fromApplicationOrigin(request)) + return new Response(null, { status: 403 }); + try { + const { type, id } = await context.params; + const target = AccountDocumentTargetSchema.parse({ type, id }); + const session = await accountDocumentStaff(target, true); + const file = await withDocumentSlot(async () => { + const form = await readUploadForm(request); + const input = AccountDocumentInputSchema.parse(itemField(form)); + return accountDocumentRepository().add( + target, + input, + await uploadedPdf(form), + contractActor(session), + ); + }); + return Response.json( + { ok: true, value: file }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (error) { + return jsonFailure(error); + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 90481e23..13f3880f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,7 +29,7 @@ "@clockwork/testing": "workspace:*", "@clockwork/ui": "workspace:*", "@clockwork/workflows": "workspace:*", - "@modelcontextprotocol/sdk": "1.30.1", + "@modelcontextprotocol/sdk": "1.32.0", "@netlify/blobs": "10.7.11", "@swc/helpers": "0.5.15", "@workos-inc/authkit-nextjs": "catalog:", diff --git a/apps/web/src/features/internal-ops/account-documents/http.test.ts b/apps/web/src/features/internal-ops/account-documents/http.test.ts index 56884713..53b748b5 100644 --- a/apps/web/src/features/internal-ops/account-documents/http.test.ts +++ b/apps/web/src/features/internal-ops/account-documents/http.test.ts @@ -24,7 +24,8 @@ vi.mock("../contracts/http", async (original) => ({ bytes: new Uint8Array([37, 80, 68, 70, 45]), }), })); -import { uploadAccountDocument, downloadAccountDocument } from "./http"; +import { POST as uploadAccountDocument } from "@/app/(experience)/(internal)/internal/record-documents/[type]/[id]/route"; +import { GET as downloadAccountDocument } from "@/app/(experience)/(internal)/internal/record-documents/[type]/[id]/[fileId]/route"; const origin = "https://commerce.fil.one"; const id = "10000000-0000-4000-8000-000000000001"; const fileId = "90000000-0000-4000-8000-000000000001"; diff --git a/apps/web/src/features/internal-ops/account-documents/http.ts b/apps/web/src/features/internal-ops/account-documents/http.ts deleted file mode 100644 index e3574f22..00000000 --- a/apps/web/src/features/internal-ops/account-documents/http.ts +++ /dev/null @@ -1,73 +0,0 @@ -import "server-only"; -import { z } from "zod"; -import { - AccountDocumentInputSchema, - AccountDocumentTargetSchema, -} from "@clockwork/contracts"; -import { contractActor } from "../contracts/server"; -import { - fromApplicationOrigin, - itemField, - jsonFailure, - pdfResponse, - readUploadForm, - uploadedPdf, - withDocumentSlot, -} from "../contracts/http"; -import { accountDocumentRepository, accountDocumentStaff } from "./server"; - -type Params = { type: string; id: string; fileId?: string }; -export async function uploadAccountDocument( - request: Request, - context: { params: Promise }, -) { - if (!fromApplicationOrigin(request)) - return new Response(null, { status: 403 }); - try { - const { type, id } = await context.params; - const target = AccountDocumentTargetSchema.parse({ type, id }); - const session = await accountDocumentStaff(target, true); - const file = await withDocumentSlot(async () => { - const form = await readUploadForm(request); - const input = AccountDocumentInputSchema.parse(itemField(form)); - return accountDocumentRepository().add( - target, - input, - await uploadedPdf(form), - contractActor(session), - ); - }); - return Response.json( - { ok: true, value: file }, - { headers: { "cache-control": "private, no-store" } }, - ); - } catch (error) { - return jsonFailure(error); - } -} -export async function downloadAccountDocument( - request: Request, - context: { params: Promise }, -) { - try { - const { type, id, fileId } = await context.params; - const target = AccountDocumentTargetSchema.parse({ type, id }); - const session = await accountDocumentStaff(target); - return await withDocumentSlot(async () => { - const { file, bytes } = await accountDocumentRepository().read( - target, - z.uuid().parse(fileId), - contractActor(session), - ); - return pdfResponse( - bytes, - file.fileName, - new URL(request.url).searchParams.get("view") === "1" - ? "inline" - : "attachment", - ); - }); - } catch (error) { - return jsonFailure(error); - } -} diff --git a/apps/web/src/features/mcp/mcp-transport.test.ts b/apps/web/src/features/mcp/mcp-transport.test.ts index faab1d9b..04ead70c 100644 --- a/apps/web/src/features/mcp/mcp-transport.test.ts +++ b/apps/web/src/features/mcp/mcp-transport.test.ts @@ -200,6 +200,39 @@ describe("MCP transport origin validation", () => { }); describe("MCP HTTP protocol exchange", () => { + it("refuses excessive argument elements before validation or tool execution", async () => { + const response = await handleMcpRequest( + new Request(request(), { + body: JSON.stringify({ + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { + name: "whoami", + arguments: { nested: { values: Array(10_001).fill(null) } }, + }, + }), + }), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + result: { content: { text: string }[] }; + }; + expect(body).toMatchObject({ + id: 10, + result: { isError: true }, + }); + expect(body.result.content[0]?.text).toContain("maximum of 10000 elements"); + expect(mocks.recordToolCall).toHaveBeenCalledOnce(); + expect(mocks.recordToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + tool: "whoami", + outcome: "refused", + code: "INVALID_ARGUMENTS", + }), + ); + }); + it("initializes, lists allowed tools, calls whoami and audits the call", async () => { const protocolVersion = "2025-11-25"; const exchange = (message: Record, initialized = true) => diff --git a/apps/web/src/features/mcp/server.ts b/apps/web/src/features/mcp/server.ts index 1748966c..c6732b49 100644 --- a/apps/web/src/features/mcp/server.ts +++ b/apps/web/src/features/mcp/server.ts @@ -101,6 +101,9 @@ export function createCommerceMcpServer( ): McpServer { const scope = context.session.accessToken.scope; const server = new McpServer(mcpServerInfo, { + // Bound validation work independently of the HTTP byte limit: a compact + // array of invalid values can otherwise produce an enormous error tree. + maxToolInputElements: 10_000, instructions: scope === "read" ? `${instructions} This token is read-only: tools that change something are disabled and not listed.` diff --git a/apps/web/src/features/shell/handler-guards.test.ts b/apps/web/src/features/shell/handler-guards.test.ts index 55c17212..e3a9a0e9 100644 --- a/apps/web/src/features/shell/handler-guards.test.ts +++ b/apps/web/src/features/shell/handler-guards.test.ts @@ -27,6 +27,9 @@ const guardCalls = [ "requireAudience(", "mndaStaff(", "contractStaff(", + // account-documents/server.ts checks staff/MFA, target read permission and + // contract:write for uploads; its route tests exercise each refusal. + "accountDocumentStaff(", "notificationStaff(", "contractReader(", // contract-templates/server.ts: contractStaff("contract_template:manage"), diff --git a/package.json b/package.json index f83f235d..f256f880 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,6 @@ "pnpm": { "auditConfig": { "ignoreGhsas": [ - "GHSA-6qxp-vccf-f47h", "GHSA-jmr9-qjv8-65gv", "GHSA-7pqw-9j4j-h8q3", "GHSA-86w9-cpqp-85rv", @@ -94,6 +93,7 @@ ] }, "overrides": { + "vite@>=8 <8.2.4": "8.2.4", "@esbuild-kit/core-utils@3.3.2>esbuild": "0.25.12", "netlify-cli@27.1.1>@opentelemetry/api": "1.8.0", "@trigger.dev/core@4.5.9>@opentelemetry/api-logs": "0.221.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f55460e..b6a64027 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,8 +67,8 @@ catalogs: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 8.2.0 - version: 8.2.0 + specifier: 8.2.4 + version: 8.2.4 vitest: specifier: 4.1.11 version: 4.1.11 @@ -77,6 +77,7 @@ catalogs: version: 4.4.3 overrides: + vite@>=8 <8.2.4: 8.2.4 '@esbuild-kit/core-utils@3.3.2>esbuild': 0.25.12 netlify-cli@27.1.1>@opentelemetry/api: 1.8.0 '@trigger.dev/core@4.5.9>@opentelemetry/api-logs': 0.221.0 @@ -225,7 +226,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) apps/web: dependencies: @@ -257,8 +258,8 @@ importers: specifier: workspace:* version: link:../../packages/workflows '@modelcontextprotocol/sdk': - specifier: 1.30.1 - version: 1.30.1(zod@4.4.3) + specifier: 1.32.0 + version: 1.32.0(zod@4.4.3) '@netlify/blobs': specifier: 10.7.11 version: 10.7.11(supports-color@10.2.2) @@ -301,13 +302,13 @@ importers: version: 10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) '@storybook/addon-docs': specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) '@storybook/addon-vitest': specifier: 10.5.5 - version: 10.5.5(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))) + version: 10.5.5(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1)) '@storybook/nextjs-vite': specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) '@tailwindcss/postcss': specifier: 4.3.3 version: 4.3.3 @@ -328,7 +329,7 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: 6.0.5 - version: 6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 6.0.5(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) axe-core: specifier: 4.12.1 version: 4.12.1 @@ -349,10 +350,10 @@ importers: version: 5.9.3 vite: specifier: 'catalog:' - version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + version: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages/api: dependencies: @@ -395,7 +396,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages/contracts: dependencies: @@ -408,7 +409,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages/db: dependencies: @@ -436,7 +437,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages/documents: dependencies: @@ -464,7 +465,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages/domain: dependencies: @@ -483,7 +484,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages/integrations: dependencies: @@ -514,7 +515,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages/testing: dependencies: @@ -550,7 +551,7 @@ importers: version: 22.4.0(@types/node@24.13.3) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) devDependencies: typescript: specifier: 'catalog:' @@ -579,7 +580,7 @@ importers: devDependencies: '@storybook/nextjs-vite': specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) '@types/react': specifier: 'catalog:' version: 19.2.18 @@ -591,7 +592,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages/workflows: dependencies: @@ -625,7 +626,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) packages: @@ -916,9 +917,6 @@ packages: '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - '@emnapi/core@2.0.0-alpha.3': - resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -931,18 +929,12 @@ packages: '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} - '@emnapi/runtime@2.0.0-alpha.3': - resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} - '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@emnapi/wasi-threads@2.0.1': - resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} - '@envelop/instrumentation@1.0.0': resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} engines: {node: '>=18.0.0'} @@ -1627,7 +1619,7 @@ packages: resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} peerDependencies: typescript: '>= 4.3.x' - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: 8.2.4 peerDependenciesMeta: typescript: optional: true @@ -1672,8 +1664,8 @@ packages: '@microsoft/fetch-event-source@2.0.1': resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} - '@modelcontextprotocol/sdk@1.30.1': - resolution: {integrity: sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==} + '@modelcontextprotocol/sdk@1.32.0': + resolution: {integrity: sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -2508,12 +2500,12 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.142.0': - resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} - '@oxc-project/types@0.150.0': resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} cpu: [arm] @@ -2974,96 +2966,98 @@ packages: resolution: {integrity: sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} - '@rolldown/binding-android-arm64@1.2.1': - resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.1': - resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.1': - resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.1': - resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.1': - resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.1': - resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.1': - resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.1': - resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.1': - resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.1': - resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.1': - resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.1': - resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.2.1': - resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - - '@rolldown/binding-win32-arm64-msvc@1.2.1': - resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.1': - resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3237,7 +3231,7 @@ packages: resolution: {integrity: sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==} peerDependencies: storybook: ^10.5.5 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: 8.2.4 '@storybook/csf-plugin@10.5.5': resolution: {integrity: sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==} @@ -3245,7 +3239,7 @@ packages: esbuild: '*' rollup: '*' storybook: ^10.5.5 - vite: '*' + vite: 8.2.4 webpack: '*' peerDependenciesMeta: esbuild: @@ -3275,7 +3269,7 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 storybook: ^10.5.5 typescript: '*' - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: 8.2.4 peerDependenciesMeta: '@types/react': optional: true @@ -3305,7 +3299,7 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 storybook: ^10.5.5 typescript: '>= 4.9.x' - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: 8.2.4 peerDependenciesMeta: typescript: optional: true @@ -3859,7 +3853,7 @@ packages: peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 - vite: ^8.0.0 + vite: 8.2.4 peerDependenciesMeta: '@rolldown/plugin-babel': optional: true @@ -3876,7 +3870,7 @@ packages: resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: 8.2.4 peerDependenciesMeta: msw: optional: true @@ -6756,6 +6750,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanospinner@1.2.2: resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} @@ -7286,6 +7285,10 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -7701,8 +7704,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.2.1: - resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -8642,23 +8645,23 @@ packages: peerDependencies: next: ^14.1.0 || ^15.0.0 || ^16.0.0 storybook: ^0.0.0-0 || ^9.0.0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: 8.2.4 vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: - vite: '*' + vite: 8.2.4 peerDependenciesMeta: vite: optional: true - vite@8.2.0: - resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + vite@8.2.4: + resolution: {integrity: sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -8711,7 +8714,6 @@ packages: '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -9438,12 +9440,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@2.0.0-alpha.3': - dependencies: - '@emnapi/wasi-threads': 2.0.1 - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -9464,11 +9460,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@2.0.0-alpha.3': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -9479,11 +9470,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@2.0.1': - dependencies: - tslib: 2.8.1 - optional: true - '@envelop/instrumentation@1.0.0': dependencies: '@whatwg-node/promise-helpers': 1.3.2 @@ -9959,11 +9945,11 @@ snapshots: dependencies: minipass: 7.1.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) optionalDependencies: typescript: 5.9.3 @@ -10016,7 +10002,7 @@ snapshots: '@microsoft/fetch-event-source@2.0.1': {} - '@modelcontextprotocol/sdk@1.30.1(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.32.0(zod@4.4.3)': dependencies: '@hono/node-server': 2.1.4(hono@4.13.7) ajv: 8.20.0 @@ -10068,13 +10054,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': - dependencies: - '@emnapi/core': 2.0.0-alpha.3 - '@emnapi/runtime': 2.0.0-alpha.3 - '@tybys/wasm-util': 0.10.3 - optional: true - '@netlify/ai@0.4.4': dependencies: '@netlify/api': 15.1.1 @@ -11056,10 +11035,10 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.142.0': {} - '@oxc-project/types@0.150.0': {} + '@oxc-project/types@0.155.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -11535,53 +11514,49 @@ snapshots: transitivePeerDependencies: - supports-color - '@rolldown/binding-android-arm64@1.2.1': + '@rolldown/binding-android-arm-eabi@1.2.15': optional: true - '@rolldown/binding-darwin-arm64@1.2.1': + '@rolldown/binding-android-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-x64@1.2.1': + '@rolldown/binding-darwin-arm64@1.2.15': optional: true - '@rolldown/binding-freebsd-x64@1.2.1': + '@rolldown/binding-darwin-x64@1.2.15': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + '@rolldown/binding-freebsd-x64@1.2.15': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.1': + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.1': + '@rolldown/binding-linux-arm64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.1': + '@rolldown/binding-linux-arm64-musl@1.2.15': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.1': + '@rolldown/binding-linux-ppc64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.1': + '@rolldown/binding-linux-s390x-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-musl@1.2.1': + '@rolldown/binding-linux-x64-gnu@1.2.15': optional: true - '@rolldown/binding-openharmony-arm64@1.2.1': + '@rolldown/binding-linux-x64-musl@1.2.15': optional: true - '@rolldown/binding-wasm32-wasi@1.2.1': - dependencies: - '@emnapi/core': 2.0.0-alpha.3 - '@emnapi/runtime': 2.0.0-alpha.3 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + '@rolldown/binding-openharmony-arm64@1.2.15': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.1': + '@rolldown/binding-win32-arm64-msvc@1.2.15': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.1': + '@rolldown/binding-win32-x64-msvc@1.2.15': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -11765,10 +11740,10 @@ snapshots: axe-core: 4.12.1 storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) - '@storybook/addon-docs@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': + '@storybook/addon-docs@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.3.0) - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) '@storybook/icons': 2.1.0(react@19.3.0) '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.3.0 @@ -11784,35 +11759,35 @@ snapshots: - vite - webpack - '@storybook/addon-vitest@10.5.5(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)))': + '@storybook/addon-vitest@10.5.5(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1))': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@vitest/runner': 4.1.11 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1) transitivePeerDependencies: - react - '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': + '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': dependencies: - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': + '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': dependencies: storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) '@storybook/global@5.0.0': {} @@ -11824,18 +11799,18 @@ snapshots: dependencies: react: 19.3.0 - '@storybook/nextjs-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': + '@storybook/nextjs-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': dependencies: - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) '@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3) - '@storybook/react-vite': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + '@storybook/react-vite': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) next: 16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) - vite-plugin-storybook-nextjs: 3.3.1(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite-plugin-storybook-nextjs: 3.3.1(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) @@ -11866,11 +11841,11 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': + '@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) '@rollup/pluginutils': 5.4.0 - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) '@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -11880,7 +11855,7 @@ snapshots: resolve: 1.22.12 storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -12449,10 +12424,10 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': + '@vitejs/plugin-react@6.0.5(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) '@vitest/expect@3.2.4': dependencies: @@ -12471,14 +12446,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': + '@vitest/mocker@4.1.11(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.15.0(@types/node@24.13.3)(typescript@5.9.3) - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -15479,6 +15454,8 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.20: {} + nanospinner@1.2.2: dependencies: picocolors: 1.1.1 @@ -16230,6 +16207,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postcss@8.5.31: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -16622,26 +16605,26 @@ snapshots: rfdc@1.4.1: {} - rolldown@1.2.1: + rolldown@1.2.15: dependencies: - '@oxc-project/types': 0.142.0 + '@oxc-project/types': 0.155.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.2.1 - '@rolldown/binding-darwin-arm64': 1.2.1 - '@rolldown/binding-darwin-x64': 1.2.1 - '@rolldown/binding-freebsd-x64': 1.2.1 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 - '@rolldown/binding-linux-arm64-gnu': 1.2.1 - '@rolldown/binding-linux-arm64-musl': 1.2.1 - '@rolldown/binding-linux-ppc64-gnu': 1.2.1 - '@rolldown/binding-linux-s390x-gnu': 1.2.1 - '@rolldown/binding-linux-x64-gnu': 1.2.1 - '@rolldown/binding-linux-x64-musl': 1.2.1 - '@rolldown/binding-openharmony-arm64': 1.2.1 - '@rolldown/binding-wasm32-wasi': 1.2.1 - '@rolldown/binding-win32-arm64-msvc': 1.2.1 - '@rolldown/binding-win32-x64-msvc': 1.2.1 + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 router@2.2.0: dependencies: @@ -17651,7 +17634,7 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - vite-plugin-storybook-nextjs@3.3.1(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)): + vite-plugin-storybook-nextjs@3.3.1(next@16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)): dependencies: '@next/env': 16.0.0 image-size: 2.0.4 @@ -17660,29 +17643,29 @@ snapshots: next: 16.3.8(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) - vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)): dependencies: debug: 4.4.3(supports-color@10.2.2) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) transitivePeerDependencies: - supports-color - typescript - vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1): + vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.2.1 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 @@ -17692,10 +17675,10 @@ snapshots: tsx: 4.23.1 yaml: 2.9.1 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.1): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) + '@vitest/mocker': 4.1.11(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -17712,14 +17695,25 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) + vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.3 jsdom: 30.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml w3c-xmlserializer@5.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8fc77c90..3d37208a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,7 +23,7 @@ catalog: react-dom: 19.2.8 stripe: 22.4.0 typescript: 5.9.3 - vite: 8.2.0 + vite: 8.2.4 vitest: 4.1.11 zod: 4.4.3 diff --git a/scripts/dependency-advisories.test.mjs b/scripts/dependency-advisories.test.mjs index 8c45fe24..d755cef7 100644 --- a/scripts/dependency-advisories.test.mjs +++ b/scripts/dependency-advisories.test.mjs @@ -13,10 +13,6 @@ const workspaceRoot = fileURLToPath(new URL("../", import.meta.url)); // ignoreGhsas`; a test below holds the two lists together, so silencing an // advisory in the manifest without recording why fails the suite. const acceptedAdvisories = new Map([ - [ - "GHSA-6qxp-vccf-f47h", - "@modelcontextprotocol/sdk OAuth client could send stored credentials to an authorization server the MCP server names; fixed in 1.31.0, which is not yet two weeks old (released 2026-09-28). The advisory states MCP servers built with the SDK are not affected. Commerce uses only the SDK's server and web-standard transport for /api/mcp and never imports its client or OAuth code; a check below fails if any source file does. Upgrade to 1.31.0 or later and drop this entry once it is two weeks old.", - ], [ "GHSA-vfj7-8cjw-p6xm", "braces deeply nested glob stack exhaustion: no published fix through 3.0.3. Reached only through root development dependencies (Netlify CLI and Secretlint via micromatch/fast-glob). Commerce accepts no untrusted glob patterns through those tools, and the package is absent from the production dependency graph. A regression check below fails if it becomes a production dependency.", @@ -61,8 +57,12 @@ const pinnedTransitives = [ { name: "smol-toml", minimum: "1.9.0", override: "smol-toml@<1.9.0" }, ]; -// Direct catalog dependency, upgraded rather than overridden. -const catalogFloors = [{ name: "hono", minimum: "4.13.7" }]; +// Direct catalog dependencies. Vite also has an override so test tooling's +// transitive copy cannot retain a vulnerable version. +const catalogFloors = [ + { name: "hono", minimum: "4.13.7" }, + { name: "vite", minimum: "8.2.4" }, +]; function compareSemver(left, right) { const parse = (value) => value.split(".").map((part) => Number(part)); @@ -150,40 +150,12 @@ test("the unpatched braces package stays outside production dependencies", async ); }); -test("the MCP SDK's client and OAuth code stay unused while its advisory is accepted", async () => { - const { stdout } = await execFileAsync( - "git", - [ - "grep", - "-l", - "-E", - "@modelcontextprotocol/sdk/(client|server/auth|shared/auth)", - "--", - "apps", - "packages", - ], - { cwd: workspaceRoot }, - ).catch((error) => { - // git grep exits 1 when nothing matches. - if (error.code === 1) return { stdout: "" }; - throw error; - }); - assert.equal( - stdout.trim(), - "", - "Re-review GHSA-6qxp-vccf-f47h: Commerce must not use the MCP SDK client or OAuth code", - ); -}); - -test("the MCP SDK advisory acceptance expires on 2026-10-26", async () => { +test("the MCP SDK resolves above the patched server-security floor", async () => { const versions = await resolvedVersions("@modelcontextprotocol/sdk"); assert.ok(versions.length > 0, "@modelcontextprotocol/sdk is missing"); - const unpatched = versions.filter( - (version) => compareSemver(version, "1.31.0") < 0, - ); - if (unpatched.length === 0) return; - assert.ok( - Date.now() < Date.parse("2026-10-27T00:00:00Z"), - `@modelcontextprotocol/sdk@${unpatched.join(", ")} is still below 1.31.0: upgrade it (1.31.0 is two weeks old from 2026-10-12) and drop GHSA-6qxp-vccf-f47h`, - ); + for (const version of versions) + assert.ok( + compareSemver(version, "1.32.0") >= 0, + `@modelcontextprotocol/sdk@${version} is below the patched floor 1.32.0`, + ); }); From b4b23e021a52dabf6ccd06b68ebdc48de66817a0 Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 12:38:44 -0400 Subject: [PATCH 3/6] Authenticate CI database image pulls to avoid registry throttling --- .github/workflows/ci.yml | 15 +++++++++++++-- scripts/release-artifacts.test.mjs | 19 ++++++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 981560e7..263de059 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,9 @@ jobs: release-shards: name: Release / ${{ matrix.shard }} runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: read timeout-minutes: 30 strategy: fail-fast: false @@ -112,8 +115,9 @@ jobs: # registry is public.ecr.aws, whose anonymous pull rate is shared by # every runner behind the same egress address — it answers # "toomanyrequests: Rate exceeded" and the database shards never start. - # The CLI mirrors the identical tags on ghcr.io, which needs no registry - # credential and so no repository secret. + # The CLI mirrors the identical tags on ghcr.io. Authenticate below with + # the job's read-only token: anonymous GHCR pulls now hit the same shared + # runner-IP limit. No long-lived repository credential is needed. SUPABASE_INTERNAL_IMAGE_REGISTRY: ghcr.io steps: - name: Start release budget clock @@ -191,6 +195,13 @@ jobs: fi echo "CLOCKWORK_UPGRADE_REPLAY=$replay" >> "$GITHUB_ENV" echo "Populated upgrade replay: $replay" + - name: Authenticate database image pulls + if: matrix.shard == 'integration' || matrix.shard == 'proof' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Start freshly migrated and seeded database if: matrix.shard == 'integration' || matrix.shard == 'proof' run: | diff --git a/scripts/release-artifacts.test.mjs b/scripts/release-artifacts.test.mjs index fb5294be..69ee910a 100644 --- a/scripts/release-artifacts.test.mjs +++ b/scripts/release-artifacts.test.mjs @@ -1213,13 +1213,22 @@ test("raises the CI heap ceiling above the measured type-aware lint peak", () => ); }); -test("pulls the database shard images from the mirrored registry", () => { - // The Supabase CLI defaults to public.ecr.aws, whose anonymous pull rate is - // shared across runners and answers "toomanyrequests: Rate exceeded". The - // ghcr.io mirror serves byte-identical manifests without a credential, so no - // repository secret is involved. +test("authenticates database image pulls to avoid shared anonymous throttling", () => { + // Both ECR and anonymous GHCR pulls hit shared runner-IP rate limits. + // The pinned CLI invokes docker pull, so it uses the job-scoped login. assert.match( checkedInWorkflow, /SUPABASE_INTERNAL_IMAGE_REGISTRY:\s*ghcr\.io/, ); + assert.match(checkedInWorkflow, /packages: read/); + const login = checkedInWorkflow.indexOf("uses: docker/login-action@v3"); + const database = checkedInWorkflow.indexOf("name: Start freshly migrated"); + assert.ok( + login > 0 && login < database, + "login must precede database startup", + ); + assert.match( + checkedInWorkflow.slice(login, database), + /registry: ghcr\.io[\s\S]*password: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, + ); }); From 87c6d9323169891266664397a5e445a5092ac692 Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 12:50:20 -0400 Subject: [PATCH 4/6] Keep the distributor quote fixture protection current --- supabase/seed.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/supabase/seed.sql b/supabase/seed.sql index 9689cf62..c5d792a0 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -407,10 +407,12 @@ insert into deletion_certificates (id, termination_id, document_id, scope, metho insert into novations (id, account_id, former_partner_account_id, source_order_id, new_agreement_id, new_order_id, reason, continuity_confirmed_at) values ('93800000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000004','10000000-0000-4000-8000-000000000003','80000000-0000-4000-8000-000000000003','51000000-0000-4000-8000-000000000007','80000000-0000-4000-8000-000000000007','agreed_handoff','2026-07-31T16:00:00Z'); +-- Referral and distributor quote fixtures must remain protected at database +-- now(), which the row policies use even when a command has a fixed test date. insert into deal_registrations (id, partner_account_id, end_client_account_id, workload, expected_volume, status, protection_starts_at, protection_ends_at) values ('94000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000004','Referral archive modernization',40,'registered','2026-07-01T16:00:00Z',(date_trunc('day', now() at time zone 'UTC') at time zone 'UTC') + interval '90 days 16 hours'), ('94000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000003','10000000-0000-4000-8000-000000000004','Resale managed archive',20,'registered','2026-07-15T16:00:00Z','2026-10-15T16:00:00Z'), -('94000000-0000-4000-8000-000000000003','10000000-0000-4000-8000-000000000005','10000000-0000-4000-8000-000000000004','Two-tier distributor archive',30,'registered','2026-07-16T16:00:00Z','2026-10-16T16:00:00Z'), +('94000000-0000-4000-8000-000000000003','10000000-0000-4000-8000-000000000005','10000000-0000-4000-8000-000000000004','Two-tier distributor archive',30,'registered','2026-07-16T16:00:00Z',(date_trunc('day', now() at time zone 'UTC') at time zone 'UTC') + interval '90 days 16 hours'), ('94000000-0000-4000-8000-000000000004','10000000-0000-4000-8000-000000000007','10000000-0000-4000-8000-000000000004','White-label embedded archive',25,'registered','2026-07-17T16:00:00Z','2026-10-17T16:00:00Z'), ('94000000-0000-4000-8000-000000000005','10000000-0000-4000-8000-000000000008','10000000-0000-4000-8000-000000000004','Marketplace archive listing',35,'registered','2026-07-18T16:00:00Z','2026-10-18T16:00:00Z'); -- Registrations start registered (or rejected); decisions are transitions, so From 4d6cce3632dd90b51a3b7fffffd2cb7e69437f8d Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 12:55:33 -0400 Subject: [PATCH 5/6] Preserve automatic database image registry fallback in CI --- .github/workflows/ci.yml | 13 +++++-------- scripts/release-artifacts.test.mjs | 10 +++++----- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 263de059..81762cb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,14 +111,6 @@ jobs: .artifacts/release-cache/${{ matrix.shard }}/playwright-browsers LOCAL_DB_HOST: 127.0.0.1 LOCAL_DB_PASSWORD: postgres - # `supabase start` pulls nine service images, and the CLI's default - # registry is public.ecr.aws, whose anonymous pull rate is shared by - # every runner behind the same egress address — it answers - # "toomanyrequests: Rate exceeded" and the database shards never start. - # The CLI mirrors the identical tags on ghcr.io. Authenticate below with - # the job's read-only token: anonymous GHCR pulls now hit the same shared - # runner-IP limit. No long-lived repository credential is needed. - SUPABASE_INTERNAL_IMAGE_REGISTRY: ghcr.io steps: - name: Start release budget clock run: echo "CLOCKWORK_RELEASE_STARTED_AT=$(date +%s000)" >> "$GITHUB_ENV" @@ -195,6 +187,11 @@ jobs: fi echo "CLOCKWORK_UPGRADE_REPLAY=$replay" >> "$GITHUB_ENV" echo "Populated upgrade replay: $replay" + # The pinned CLI falls back from ECR to GHCR to Docker Hub when an image + # pull fails. Do not force SUPABASE_INTERNAL_IMAGE_REGISTRY: doing so + # disables that fallback, and even authenticated GHCR can be throttled. + # Give its GHCR fallback the job's read-only token, without a long-lived + # repository credential. - name: Authenticate database image pulls if: matrix.shard == 'integration' || matrix.shard == 'proof' uses: docker/login-action@v3 diff --git a/scripts/release-artifacts.test.mjs b/scripts/release-artifacts.test.mjs index 69ee910a..3c38856c 100644 --- a/scripts/release-artifacts.test.mjs +++ b/scripts/release-artifacts.test.mjs @@ -1213,12 +1213,12 @@ test("raises the CI heap ceiling above the measured type-aware lint peak", () => ); }); -test("authenticates database image pulls to avoid shared anonymous throttling", () => { - // Both ECR and anonymous GHCR pulls hit shared runner-IP rate limits. - // The pinned CLI invokes docker pull, so it uses the job-scoped login. - assert.match( +test("preserves registry fallback and authenticates database image pulls", () => { + // Both ECR and GHCR can hit shared rate limits. The pinned CLI falls back + // between mirrors only without an override; docker pull uses this login. + assert.doesNotMatch( checkedInWorkflow, - /SUPABASE_INTERNAL_IMAGE_REGISTRY:\s*ghcr\.io/, + /^\s+SUPABASE_INTERNAL_IMAGE_REGISTRY:/m, ); assert.match(checkedInWorkflow, /packages: read/); const login = checkedInWorkflow.indexOf("uses: docker/login-action@v3"); From e7a6ca251b9505a3a8db25a0a71cdc28c1fc86b1 Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 13:08:03 -0400 Subject: [PATCH 6/6] Keep approved registration fixtures valid for ownership tests --- supabase/seed.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/supabase/seed.sql b/supabase/seed.sql index c5d792a0..bbaff0d4 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -407,14 +407,14 @@ insert into deletion_certificates (id, termination_id, document_id, scope, metho insert into novations (id, account_id, former_partner_account_id, source_order_id, new_agreement_id, new_order_id, reason, continuity_confirmed_at) values ('93800000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000004','10000000-0000-4000-8000-000000000003','80000000-0000-4000-8000-000000000003','51000000-0000-4000-8000-000000000007','80000000-0000-4000-8000-000000000007','agreed_handoff','2026-07-31T16:00:00Z'); --- Referral and distributor quote fixtures must remain protected at database --- now(), which the row policies use even when a command has a fixed test date. +-- Approved registration fixtures must remain protected at database now(), +-- so quote and extension scenarios do not expire with the calendar. insert into deal_registrations (id, partner_account_id, end_client_account_id, workload, expected_volume, status, protection_starts_at, protection_ends_at) values ('94000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000004','Referral archive modernization',40,'registered','2026-07-01T16:00:00Z',(date_trunc('day', now() at time zone 'UTC') at time zone 'UTC') + interval '90 days 16 hours'), ('94000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000003','10000000-0000-4000-8000-000000000004','Resale managed archive',20,'registered','2026-07-15T16:00:00Z','2026-10-15T16:00:00Z'), ('94000000-0000-4000-8000-000000000003','10000000-0000-4000-8000-000000000005','10000000-0000-4000-8000-000000000004','Two-tier distributor archive',30,'registered','2026-07-16T16:00:00Z',(date_trunc('day', now() at time zone 'UTC') at time zone 'UTC') + interval '90 days 16 hours'), -('94000000-0000-4000-8000-000000000004','10000000-0000-4000-8000-000000000007','10000000-0000-4000-8000-000000000004','White-label embedded archive',25,'registered','2026-07-17T16:00:00Z','2026-10-17T16:00:00Z'), -('94000000-0000-4000-8000-000000000005','10000000-0000-4000-8000-000000000008','10000000-0000-4000-8000-000000000004','Marketplace archive listing',35,'registered','2026-07-18T16:00:00Z','2026-10-18T16:00:00Z'); +('94000000-0000-4000-8000-000000000004','10000000-0000-4000-8000-000000000007','10000000-0000-4000-8000-000000000004','White-label embedded archive',25,'registered','2026-07-17T16:00:00Z',(date_trunc('day', now() at time zone 'UTC') at time zone 'UTC') + interval '90 days 16 hours'), +('94000000-0000-4000-8000-000000000005','10000000-0000-4000-8000-000000000008','10000000-0000-4000-8000-000000000004','Marketplace archive listing',35,'registered','2026-07-18T16:00:00Z',(date_trunc('day', now() at time zone 'UTC') at time zone 'UTC') + interval '90 days 16 hours'); -- Registrations start registered (or rejected); decisions are transitions, so -- the ownership guard sees each one. update deal_registrations set status = 'approved' where id in (