From f2bf061a3e027297a401ac4eb686cb4b5fffdd01 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:41:07 +0200 Subject: [PATCH] fix(judging): relax per-action signing + accept publish statement server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from the must-sign work (#173): 1. REGRESSION: the client signs award/publish/mark-paid with new SIWS statements that were never added to the server's VALID_STATEMENTS allowlist, so the server rejected them with 'Invalid statement in SIWS message' — admins couldn't award prizes or publish. (Mock tests couldn't catch it: mock mode never hits the real server validator.) 2. The per-action signing was too much friction. New model: sign once to open the admin session (the LOAD ADMIN DATA step mints a cached bearer), then every action rides it — EXCEPT publishing results, which keeps a deliberate fresh signature (irreversible + public). - ProgramJudgingSection: award + mark-paid now use the cached session (getAuth); only publish uses a fresh signature (signPublishAction prop, was signWinnerAction). - m2 confirm-payment (AdminPage M1/M2 handlers + WinnersTable) reverts to the cached bearer; removed the signPaymentAction prop. - siwsUtils: drop the unused mark-paid/award-prize/confirm-payment actions, keep publish-results. - server/api/auth/statements.js: add 'Publish results on Stadium' to VALID_STATEMENTS so the one remaining fresh-signed action is accepted. Covered by statements.test.js. Server suite green (397); client build + lint clean. --- .../admin/ProgramJudgingSection.tsx | 26 +++++++++---------- client/src/components/admin/WinnersTable.tsx | 11 ++------ client/src/lib/siwsUtils.ts | 12 +++------ client/src/pages/AdminPage.tsx | 9 ++----- client/src/pages/AdminProgramPage.tsx | 2 +- server/api/auth/__tests__/statements.test.js | 5 ++++ server/api/auth/statements.js | 2 ++ 7 files changed, 27 insertions(+), 40 deletions(-) diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index c29a684..cfbc4af 100644 --- a/client/src/components/admin/ProgramJudgingSection.tsx +++ b/client/src/components/admin/ProgramJudgingSection.tsx @@ -39,7 +39,7 @@ const prizeText = (amount?: number | null, currency?: string | null) => export function ProgramJudgingSection({ programSlug, getAuth, - signWinnerAction, + signPublishAction, canSelectWinners = false, prizeTiers, resultsPublishedAt = null, @@ -48,13 +48,12 @@ export function ProgramJudgingSection({ programSlug: string; getAuth: () => Promise; /** - * Fresh-signature source for the high-stakes winner/payout actions - * (award prize, publish results, mark paid). When provided, these actions - * pop a wallet signature every time instead of riding the cached admin - * session — a deliberate human confirmation. Reads still use `getAuth`. - * Only wired on the wallet-admin path (`canSelectWinners`). + * Fresh-signature source for PUBLISHING results only — the one irreversible, + * public action we still gate behind a deliberate wallet signature. Everything + * else (scoring, award, mark paid) rides the cached admin session opened at + * sign-in. When omitted, publish falls back to the cached session too. */ - signWinnerAction?: (action: "mark-paid" | "award-prize" | "publish-results") => Promise; + signPublishAction?: () => Promise; canSelectWinners?: boolean; prizeTiers?: ApiPrizeTier[] | null; resultsPublishedAt?: string | null; @@ -216,17 +215,16 @@ export function ProgramJudgingSection({ } }; - // Auth for the high-stakes winner/payout actions: a fresh signature each - // time when wired (wallet admins), else the cached admin session. - const winnerAuth = (action: "mark-paid" | "award-prize" | "publish-results") => - signWinnerAction ? signWinnerAction(action) : getAuth(); + // Publish re-signs fresh (the one action still gated). Everything else rides + // the cached admin session opened at sign-in. + const publishAuth = () => (signPublishAction ? signPublishAction() : getAuth()); // Platform admin: assign a prize tier (winner) to a submission, or clear it. const award = async (submissionId: string, amountRaw: string) => { const tier = amountRaw === "" ? null : tiers.find((t) => t.amount === Number(amountRaw)) ?? null; setAwardingId(submissionId); try { - const auth = await winnerAuth("award-prize"); + const auth = await getAuth(); await api.awardPrize(programSlug, submissionId, tier, auth); setBoard((prev) => prev && !prev.locked @@ -256,7 +254,7 @@ export function ProgramJudgingSection({ const markPaid = async (submissionId: string, paid: boolean) => { setPaidId(submissionId); try { - const auth = await winnerAuth("mark-paid"); + const auth = await getAuth(); await api.setSubmissionPaid(programSlug, submissionId, paid, auth); setBoard((prev) => prev && !prev.locked @@ -278,7 +276,7 @@ export function ProgramJudgingSection({ const togglePublish = async () => { setPublishing(true); try { - const auth = await winnerAuth("publish-results"); + const auth = await publishAuth(); const res = await api.publishResults(programSlug, !publishedAt, auth); setPublishedAt(res.data.resultsPublishedAt); onPublishedChange?.(res.data.resultsPublishedAt); diff --git a/client/src/components/admin/WinnersTable.tsx b/client/src/components/admin/WinnersTable.tsx index 545ca12..f24a8b7 100644 --- a/client/src/components/admin/WinnersTable.tsx +++ b/client/src/components/admin/WinnersTable.tsx @@ -93,17 +93,11 @@ interface WinnersTableProps { * write below to share the cache. */ signAdminAction: () => Promise; - /** - * Auth for the payout action specifically. Pops a FRESH signature every - * time (never the cached session) so confirming a payout is a deliberate, - * separately-signed step. Falls back to signAdminAction if not provided. - */ - signPaymentAction?: () => Promise; } type WinnerFilter = "all" | "main-track" | "bounty"; -export function WinnersTable({ projects, onRefresh, connectedAddress, signAdminAction, signPaymentAction }: WinnersTableProps) { +export function WinnersTable({ projects, onRefresh, connectedAddress, signAdminAction }: WinnersTableProps) { const { toast } = useToast(); const [saving, setSaving] = useState(null); const [winnerFilter, setWinnerFilter] = useState("all"); @@ -372,8 +366,7 @@ export function WinnersTable({ projects, onRefresh, connectedAddress, signAdminA setSaving("payment"); try { - // Confirming a payout re-signs fresh every time (not the cached session). - const authHeader = await (signPaymentAction ?? signAdminAction)(); + const authHeader = await signAdminAction(); // Validate if (!manageModal.paymentAmount || manageModal.paymentAmount <= 0) { diff --git a/client/src/lib/siwsUtils.ts b/client/src/lib/siwsUtils.ts index 5a4a5c0..b70b3ef 100644 --- a/client/src/lib/siwsUtils.ts +++ b/client/src/lib/siwsUtils.ts @@ -3,7 +3,7 @@ */ export interface SiwsContext { - action: 'update-team' | 'submit-deliverable' | 'update-project' | 'register-address' | 'admin-action' | 'create-project' | 'delete-project' | 'review-project' | 'approve-project' | 'reject-project' | 'post-update' | 'update-funding-signal' | 'apply-to-program' | 'create-program' | 'update-program' | 'review-application' | 'update-program-sponsors' | 'import-program-signups' | 'submit-continuation' | 'update-notifications' | 'mark-paid' | 'award-prize' | 'publish-results' | 'confirm-payment'; + action: 'update-team' | 'submit-deliverable' | 'update-project' | 'register-address' | 'admin-action' | 'create-project' | 'delete-project' | 'review-project' | 'approve-project' | 'reject-project' | 'post-update' | 'update-funding-signal' | 'apply-to-program' | 'create-program' | 'update-program' | 'review-application' | 'update-program-sponsors' | 'import-program-signups' | 'submit-continuation' | 'update-notifications' | 'publish-results'; projectId?: string; projectTitle?: string; programTitle?: string; @@ -77,16 +77,10 @@ export function generateSiwsStatement(context: SiwsContext): string { case 'update-notifications': return `Update notification preferences for wallet on Stadium`; - // High-stakes payout/results actions: each re-signs fresh (never the - // cached admin session) so the human deliberately confirms it. - case 'mark-paid': - return `Mark a payout as sent on ${baseDomain}`; - case 'award-prize': - return `Select a winner on ${baseDomain}`; + // Publishing results is the one judging action still gated by a fresh + // signature (irreversible + public). Everything else rides the session. case 'publish-results': return `Publish results on ${baseDomain}`; - case 'confirm-payment': - return `Confirm a milestone payout on ${baseDomain}`; default: return `Sign in to ${baseDomain}`; diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 2550ba7..c4c71d9 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -109,9 +109,7 @@ const AdminPage = () => { const handleConfirmM1Payout = async (data: any) => { if (!selectedProject) return; try { - // Payout confirmation moves real funds — re-sign fresh every time - // (never the cached admin session) so the human deliberately approves it. - const authHeaders = { "x-siws-auth": await auth.signAction("confirm-payment") }; + const authHeaders = await auth.getAdminBearerHeaders(); const response = await fetch(`/api/m2-program/${selectedProject.id}/confirm-payment`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, @@ -148,9 +146,7 @@ const AdminPage = () => { const handleConfirmPayment = async (data: any) => { if (!selectedProject) return; try { - // Payout confirmation moves real funds — re-sign fresh every time - // (never the cached admin session) so the human deliberately approves it. - const authHeaders = { "x-siws-auth": await auth.signAction("confirm-payment") }; + const authHeaders = await auth.getAdminBearerHeaders(); const response = await fetch(`/api/m2-program/${selectedProject.id}/confirm-payment`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, @@ -503,7 +499,6 @@ const AdminPage = () => { onRefresh={loadData} connectedAddress={BYPASS_ADMIN_CHECK ? ADMIN_ADDRESSES[0]?.address : auth.account?.address} signAdminAction={auth.getAdminBearerHeaders} - signPaymentAction={() => auth.signAction("confirm-payment")} /> diff --git a/client/src/pages/AdminProgramPage.tsx b/client/src/pages/AdminProgramPage.tsx index a2deadd..7bf2edd 100644 --- a/client/src/pages/AdminProgramPage.tsx +++ b/client/src/pages/AdminProgramPage.tsx @@ -357,7 +357,7 @@ const AdminProgramPage = () => { auth.signAction(a)} + signPublishAction={() => auth.signAction("publish-results")} canSelectWinners={isGlobalAdmin} prizeTiers={program.prizeTiers} resultsPublishedAt={program.resultsPublishedAt} diff --git a/server/api/auth/__tests__/statements.test.js b/server/api/auth/__tests__/statements.test.js index bf6172f..003bd86 100644 --- a/server/api/auth/__tests__/statements.test.js +++ b/server/api/auth/__tests__/statements.test.js @@ -8,6 +8,11 @@ describe('validateStatement', () => { } }); + it('accepts the publish-results statement the client signs (client↔server contract)', () => { + // generateSiwsStatement('publish-results') in client/src/lib/siwsUtils.ts. + expect(validateStatement('Publish results on Stadium')).toBe(true); + }); + it('accepts project-specific statements via pattern match', () => { expect(validateStatement('Update team members for Acme Rocket on Stadium')).toBe(true); expect(validateStatement('Approve project Acme Rocket on Stadium')).toBe(true); diff --git a/server/api/auth/statements.js b/server/api/auth/statements.js index a3bb363..a39d4e8 100644 --- a/server/api/auth/statements.js +++ b/server/api/auth/statements.js @@ -39,6 +39,8 @@ export const VALID_STATEMENTS = [ 'Submit project continuation on Stadium', // Phase 2 revamp: wallet contacts (#67) 'Update notification preferences for wallet on Stadium', + // Publishing results is the one judging action still gated by a fresh signature. + 'Publish results on Stadium', ]; // Patterns for project-specific statements that interpolate a project/program name.