From fcc19e7275225589280f19c4b5c4a0c6719bff73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valent=C3=ADn=20Quiroz=20de=20la=20Sierra?= <85706426+vqsierra@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:41:43 -0500 Subject: [PATCH] Check the saved key file before publishing The publish steps now ask the researcher to open the key file they saved. session-client 0.2.0's verifySessionFile confirms it opens this session's responses and matches the session in memory. Publishing waits for that check and the second copy. --- app/components/console/publish-steps.tsx | 43 +++++++++++++++++++++--- app/package-lock.json | 13 ++++--- app/package.json | 2 +- app/tests/console-smoke.mjs | 4 ++- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/app/components/console/publish-steps.tsx b/app/components/console/publish-steps.tsx index ed098d6..e799bcf 100644 --- a/app/components/console/publish-steps.tsx +++ b/app/components/console/publish-steps.tsx @@ -1,22 +1,25 @@ "use client"; import { useState } from "react"; import downloadIcon from "@jetbrains/icons/download"; +import folderIcon from "@jetbrains/icons/folder"; import Banner from "@jetbrains/ring-ui-built/components/banner/banner"; import Button from "@jetbrains/ring-ui-built/components/button/button"; import Checkbox from "@jetbrains/ring-ui-built/components/checkbox/checkbox"; import { H1, H2 } from "@jetbrains/ring-ui-built/components/heading/heading"; import Input, { Size } from "@jetbrains/ring-ui-built/components/input/input"; -import { ErrorMessage, withIcon } from "@malverdesoftware/ui/controls"; -import { publishSession, renderSessionFile, type SessionFile } from "@malverdesoftware/session-client"; +import { ErrorMessage, FileButton, withIcon } from "@malverdesoftware/ui/controls"; +import { publishSession, renderSessionFile, verifySessionFile, type SessionFile } from "@malverdesoftware/session-client"; import { Frame } from "../frame"; import { download, readKey, removeKey, writeKey } from "@/lib/client"; import { CREATION_KEY_STORAGE, isProductionRelay, keyFileName } from "@/lib/console"; import { describe } from "./errors"; import { useUnsavedGuard } from "./use-unsaved-guard"; -// Save the key file (twice), then publish. Publishing is safe to retry: the request is rebuilt from the file. +// Save the key file (twice), open the saved copy to prove it works, then publish. Publishing is safe to retry: the request is rebuilt from the file. export default function PublishSteps({ file, onPublished }: { file: SessionFile; onPublished: () => void }) { const [saved, setSaved] = useState(false); + const [verified, setVerified] = useState(false); + const [checkError, setCheckError] = useState(""); const [secondCopy, setSecondCopy] = useState(false); const [creationKey, setCreationKey] = useState(() => readKey(CREATION_KEY_STORAGE) ?? ""); const [remember, setRemember] = useState(true); @@ -24,6 +27,20 @@ export default function PublishSteps({ file, onPublished }: { file: SessionFile; const [error, setError] = useState(""); useUnsavedGuard(!saved); + // Opens the file the researcher actually saved, not the copy in memory, so a damaged or wrong file is caught + // while the session can still be prepared again. + async function check(picked: File | undefined) { + if (!picked) return; + setCheckError(""); + setVerified(false); + try { + await verifySessionFile(await picked.text(), file); + setVerified(true); + } catch (e) { + setCheckError((e as Error).message); + } + } + async function publish(e: React.FormEvent) { e.preventDefault(); setBusy(true); @@ -70,6 +87,20 @@ export default function PublishSteps({ file, onPublished }: { file: SessionFile; {withIcon(downloadIcon, saved ? "Download again" : "Download the key file")} + {saved && ( + <> +

Then open the file you saved, to check it can open the responses. Nothing leaves this computer.

+
+ void check(f)}> + Check the saved key file + +
+ + )} + {verified && ( + The saved key file opens this session's responses. + )} + setSecondCopy(e.target.checked)} @@ -94,11 +125,13 @@ export default function PublishSteps({ file, onPublished }: { file: SessionFile; />
-
- {(!saved || !secondCopy) &&

Save the key file and its second copy first.

} + {(!verified || !secondCopy) && ( +

Save the key file, check it, and save its second copy first.

+ )} diff --git a/app/package-lock.json b/app/package-lock.json index 9c857eb..847ebbd 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -14,7 +14,7 @@ "@jetbrains/ring-ui-built": "7.0.133", "@malverdesoftware/relay": "0.1.1", "@malverdesoftware/sealed-envelope": "0.1.2", - "@malverdesoftware/session-client": "0.1.4", + "@malverdesoftware/session-client": "0.2.0", "@malverdesoftware/ui": "0.1.1", "next": "16.4.6", "react": "19.3.1", @@ -38,7 +38,7 @@ "wrangler": "4.162.0" }, "engines": { - "node": ">=22.18.0" + "node": ">=24 <25" } }, "node_modules/@babel/code-frame": { @@ -1907,12 +1907,15 @@ "license": "Apache-2.0" }, "node_modules/@malverdesoftware/session-client": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@malverdesoftware/session-client/-/session-client-0.1.4.tgz", - "integrity": "sha512-RQYuSTS3haapSk7yu7neMT6mv+AJAoHSyEjQrpHGScMPHEKLRvDaypILm3Twambt3stdyCxhmm33LTwQMcyTSQ==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@malverdesoftware/session-client/-/session-client-0.2.0.tgz", + "integrity": "sha512-UXlfEIIsIrmxzr8wl9OTVyLz8CK8FyZh+qiC9hnpmriMcxX03I/QOR5WlHbic/dA34RGQ8z9KYqu8rz4fXhpKg==", "license": "Apache-2.0", "dependencies": { "@malverdesoftware/sealed-envelope": "0.1.2" + }, + "bin": { + "malverdesoftware-session": "dist/cli.js" } }, "node_modules/@malverdesoftware/ui": { diff --git a/app/package.json b/app/package.json index 1f4a0d4..f66b33e 100644 --- a/app/package.json +++ b/app/package.json @@ -31,7 +31,7 @@ "@jetbrains/ring-ui-built": "7.0.133", "@malverdesoftware/relay": "0.1.1", "@malverdesoftware/sealed-envelope": "0.1.2", - "@malverdesoftware/session-client": "0.1.4", + "@malverdesoftware/session-client": "0.2.0", "@malverdesoftware/ui": "0.1.1", "next": "16.4.6", "react": "19.3.1", diff --git a/app/tests/console-smoke.mjs b/app/tests/console-smoke.mjs index 78ce09b..f18ce46 100644 --- a/app/tests/console-smoke.mjs +++ b/app/tests/console-smoke.mjs @@ -1,5 +1,5 @@ // Drives the researcher console in headless Chrome against a local console server (synthetic data only): -// create, save the key file, publish, reopen the saved file, add and remove a participant, collect, export, +// create, save and check the key file, publish, reopen the saved file, add and remove a participant, collect, export, // close, delete, then reopen the deleted session's file. Participants act through session-client in between. // npm run console (in another terminal) then npm run test:console import assert from "node:assert/strict"; @@ -51,6 +51,8 @@ try { const savedKeyFile = await download(keyFile); const file = parseSessionFile(readFileSync(savedKeyFile, "utf8")); assert.deepEqual(file.participants.map((p) => p.label), ["Y01", "Y02"]); + await pickFile('input[type="file"]', savedKeyFile); + await waitFor(hasText("opens this session"), "saved key file checked"); await tap("label", "saved a second copy"); await type('input[type="password"]', creationKey); await screenshot("console-prepared");