From 4aad897a766a642c24ec607ae1c0470c8b36ac30 Mon Sep 17 00:00:00 2001 From: Sergi Torres Albert Date: Tue, 28 Jul 2026 19:18:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(front):=20close=20the=20Passport=20downloa?= =?UTF-8?q?d=20=E2=86=92=20/verify=20loop=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Download Passport button and the on-screen formatted JSON block already landed with #42 and #118. What was missing is the other half of the MVP §10 Definition of Done — "issued, downloaded, and verifies with a valid signature": nothing proved the downloaded file could be verified back. /verify parsed uploaded files with its own inline copy of the token extraction, unexported and untested, free to drift from what downloadPassport writes. Lift it to lib/passport.extractJwsToken, next to its inverse, and have /verify delegate to it. Behaviour is unchanged. Lock the round trip with tests over a genuinely signed passport (real ES256 key, real autoria_ai.passport.signer): downloadPassport → file bytes → extractJwsToken returns the token byte-for-byte, including with a trailing newline and as a bare pasted token. Verified end to end outside the suite: the token the frontend extracts from the downloaded file was fed back to the real autoria_ai.passport.verifier and returned valid: true. Editing the pretty-printed json_payload does not affect the verdict (the signature covers the compact JSON inside the JWS); editing the signed payload is correctly rejected as invalid_signature. Co-Authored-By: Claude Opus 5 --- frontend/src/app/verify/page.tsx | 27 +++----- frontend/src/lib/passport.test.ts | 107 +++++++++++++++++++++++++++++- frontend/src/lib/passport.ts | 41 ++++++++++++ 3 files changed, 155 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/verify/page.tsx b/frontend/src/app/verify/page.tsx index 1f63315..0bde5b7 100644 --- a/frontend/src/app/verify/page.tsx +++ b/frontend/src/app/verify/page.tsx @@ -11,6 +11,7 @@ import { useRef, useState } from "react"; import { BadgeCheck, Clock, ShieldAlert } from "lucide-react"; import { verifyPassport } from "@/lib/api"; +import { extractJwsToken } from "@/lib/passport"; import type { VerifyResponse, PassportPayload, VerifyError } from "@/lib/types"; import { en } from "@/lib/i18n/en"; import { FitScoreBar } from "@/components/FitScoreBar"; @@ -253,25 +254,15 @@ export default function VerifyPage() { const [state, setState] = useState({ kind: "idle" }); const fileInputRef = useRef(null); - /** Extract a JWS token string from a .jws/.json file's text. */ + /** + * Extract a JWS token string from a .jws/.json file's text. + * + * Delegates to `lib/passport.extractJwsToken`, the inverse of the studio's + * `downloadPassport`, so a passport downloaded from the studio is guaranteed + * to be readable here (MVP §10 Definition of Done). + */ async function readFileToken(file: File): Promise { - const text = await file.text(); - const trimmed = text.trim(); - // Try to parse as JSON with a jws_token field; fall back to bare token. - try { - const parsed: unknown = JSON.parse(trimmed); - if ( - parsed !== null && - typeof parsed === "object" && - "jws_token" in parsed && - typeof (parsed as { jws_token: unknown }).jws_token === "string" - ) { - return (parsed as { jws_token: string }).jws_token; - } - } catch { - // Not JSON — treat the whole content as a bare token. - } - return trimmed; + return extractJwsToken(await file.text()); } function handleFileChange(e: React.ChangeEvent) { diff --git a/frontend/src/lib/passport.test.ts b/frontend/src/lib/passport.test.ts index 96550dc..611a0f1 100644 --- a/frontend/src/lib/passport.test.ts +++ b/frontend/src/lib/passport.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { downloadPassport } from "./passport"; -import type { PassportEnvelope } from "./types"; +import { downloadPassport, extractJwsToken } from "./passport"; +import type { PassportEnvelope, PassportPayload } from "./types"; // --------------------------------------------------------------------------- // Fixtures @@ -167,3 +167,106 @@ describe("downloadPassport — happy path", () => { expect(parsed.jws_token).toBe(MOCK_PASSPORT.jws_token); }); }); + +// --------------------------------------------------------------------------- +// Round trip: downloadPassport → file bytes → extractJwsToken +// --------------------------------------------------------------------------- + +/** + * A REAL Authorship Passport, signed with a genuine ephemeral ES256 key by + * `autoria_ai.passport.signer.sign_passport` (the same code path the backend + * uses). It is checked in as a fixed artifact so this test proves the property + * that MVP §10 makes a Definition of Done criterion: + * + * "Authorship Passport issued, downloaded, and verifies with a valid + * signature" + * + * The signature covers the compact JSON *inside* the JWS, so the only thing + * that must survive the download → upload round trip is `jws_token`, byte for + * byte. The pretty-printed `json_payload` beside it is a human-readable copy + * that verification never consults. + * + * The extracted token was verified back to `valid: true` by the real + * `autoria_ai.passport.verifier.verify_passport` against the matching public + * key — that is the half of the loop TypeScript cannot assert on its own, + * since the frontend deliberately does no JWS parsing in the browser. + */ +const SIGNED_PASSPORT: PassportEnvelope = { + jws_token: + "eyJhbGciOiJFUzI1NiIsImtpZCI6ImF1dG9yaWEtcm91bmR0cmlwLXRlc3QiLCJ0eXAiOiJwYXNzcG9ydCtqd3MifQ.eyJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInBhc3Nwb3J0X2lkIjoiNjNhYWEyNGYtODI5NC00Y2ViLTlmNTEtYTU4ODQyN2RjNTAwIiwiZ2VuZXJhdGVkX2F0IjoiMjAyNi0wNy0yOFQxNzoxMjo0OFoiLCJhdXRob3Jfdm9pY2UiOnsiaWQiOiJkaWNrZW5zIiwic3R5bGVfcHJvZmlsZV9oYXNoIjoic2hhMjU2OjE1YmJkMWJmYjFmYTUwMmQzZmU3YjAzNmI1YjQ5NzYzYTU1M2YyMzU2NmUyNzJkZDE0YWQxNGQzOWM4MWI3MzgiLCJzdHlsZV9wcm9maWxlX3ZlcnNpb24iOiIxLjAifSwiZ2VuZXJhdGlvbiI6eyJtb2RlbF9wcm92aWRlciI6ImlibS93YXRzb254IiwibW9kZWxfaWQiOiJtZXRhLWxsYW1hL2xsYW1hLTMtMy03MGItaW5zdHJ1Y3QiLCJ1c2VyX3Byb21wdF9oYXNoIjoic2hhMjU2OjFmMDE3NTIzMTZkMDljMjUzYTM3ZThiZTI2OGFlMTg2YWI3MzcwYjIwMGVjYWFlODM1MjQ2ZDllZWU1MGM2YzMiLCJvdXRwdXRfaGFzaCI6InNoYTI1NjowNDkwMmJkYjc0YmEzOTViOTMwODZlOWEzM2ZiOWMwZjg3ODcyZmY0ZThlMzE5N2Y3MTBjNTM3N2UzNDQ4MjBiIiwib3V0cHV0X2xlbmd0aF90b2tlbnMiOjEyMH0sInJhZ19zb3VyY2VzIjpbeyJkb2NfaWQiOiJibGVhay1ob3VzZSIsImNodW5rX2lkIjozLCJzbmlwcGV0X2hhc2giOiJzaGEyNTY6MDFjYjJiN2FhYjJlM2M4ZWM5OTAwZWQ5ZDI1MDdlOGFhMWE0YWJjOTQzYjVhMzZmNGNkYzIxODQ1YjYyMWFlOCJ9XSwiY29udHJpYnV0aW9uIjp7Imh1bWFuX3BjdCI6MCwiYWlfcGN0IjoxMDB9LCJmaXRfc2NvcmUiOjg3LCJ2ZXJpZmllcl91cmwiOiJodHRwczovL2F1dG9yaWEuYXBwL3ZlcmlmeSJ9.T58cOpv4sHwm_jDsG2-EbEXLPV2ip2_F21t0tIDRgaI5CVgVeEGY_Uwb7mGQEJIoyd4xutWYJbAEV68PQggB9w", + json_payload: { + schema_version: "1.0", + passport_id: "63aaa24f-8294-4ceb-9f51-a588427dc500", + generated_at: "2026-07-28T17:12:48Z", + author_voice: { + id: "dickens", + style_profile_hash: + "sha256:15bbd1bfb1fa502d3fe7b036b5b49763a553f23566e272dd14ad14d39c81b738", + style_profile_version: "1.0", + }, + generation: { + model_provider: "ibm/watsonx", + model_id: "meta-llama/llama-3-3-70b-instruct", + user_prompt_hash: + "sha256:1f01752316d09c253a37e8be268ae186ab7370b200ecaae835246d9eee50c6c3", + output_hash: + "sha256:04902bdb74ba395b93086e9a33fb9c0f87872ff4e8e3197f710c5377e344820b", + output_length_tokens: 120, + }, + rag_sources: [ + { + doc_id: "bleak-house", + chunk_id: 3, + snippet_hash: + "sha256:01cb2b7aab2e3c8ec9900ed9d2507e8aa1a4abc943b5a36f4cdc21845b621ae8", + }, + ], + contribution: { + human_pct: 0, + ai_pct: 100, + }, + fit_score: 87, + verifier_url: "https://autoria.app/verify", + } as PassportPayload, +}; + +/** Capture exactly the bytes `downloadPassport` hands to the Blob. */ +function capturedDownloadBytes(envelope: PassportEnvelope): string { + downloadPassport(envelope); + const blobArg = (URL.createObjectURL as ReturnType).mock + .calls[0][0] as { parts: BlobPart[] }; + return blobArg.parts[0] as string; +} + +describe("downloadPassport → extractJwsToken round trip", () => { + it("recovers the JWS token byte-for-byte from a downloaded passport file", () => { + const fileContents = capturedDownloadBytes(SIGNED_PASSPORT); + + // This is precisely what /verify does with an uploaded .json file. + expect(extractJwsToken(fileContents)).toBe(SIGNED_PASSPORT.jws_token); + }); + + it("does not reformat the signed material even though the file is pretty-printed", () => { + const fileContents = capturedDownloadBytes(SIGNED_PASSPORT); + + // The file is indented for humans... + expect(fileContents).toContain("\n "); + // ...but the token itself is never re-wrapped, re-indented or re-encoded. + expect(fileContents).toContain(SIGNED_PASSPORT.jws_token); + expect(extractJwsToken(fileContents).split(".")).toHaveLength(3); + }); + + it("survives a trailing newline, as added by editors and some downloads", () => { + const fileContents = capturedDownloadBytes(SIGNED_PASSPORT); + + expect(extractJwsToken(`${fileContents}\n`)).toBe( + SIGNED_PASSPORT.jws_token, + ); + }); + + it("accepts a bare compact JWS token pasted into a plain file", () => { + expect(extractJwsToken(` ${SIGNED_PASSPORT.jws_token} `)).toBe( + SIGNED_PASSPORT.jws_token, + ); + }); +}); diff --git a/frontend/src/lib/passport.ts b/frontend/src/lib/passport.ts index b42cca7..353c1bc 100644 --- a/frontend/src/lib/passport.ts +++ b/frontend/src/lib/passport.ts @@ -37,3 +37,44 @@ export function downloadPassport(passport: PassportEnvelope): void { URL.revokeObjectURL(url); } + +/** + * Extract the compact JWS token from the text of an uploaded `.json`/`.jws` + * file — the exact inverse of {@link downloadPassport}. + * + * This is the join between the two halves of the MVP §10 Definition of Done + * ("Passport issued, downloaded, and verifies with a valid signature"): what + * `downloadPassport` writes to disk must be what `/verify` can read back. It + * lives here, next to its inverse, so that contract is covered by one + * round-trip test instead of two inline copies that can drift apart. + * + * Accepts either shape: + * - a full `PassportEnvelope` JSON (what we download) → returns `jws_token` + * - a bare compact JWS token in a plain text file → returned trimmed + * + * The token is returned **verbatim**. Nothing here re-serialises, re-indents or + * otherwise normalises the signed material: the signature covers the compact + * JSON embedded inside the JWS, not the pretty-printed `json_payload` copy that + * sits beside it in the file for humans to read. Reformatting the token would + * invalidate it. + */ +export function extractJwsToken(fileText: string): string { + const trimmed = fileText.trim(); + + // Try to parse as JSON with a jws_token field; fall back to a bare token. + try { + const parsed: unknown = JSON.parse(trimmed); + if ( + parsed !== null && + typeof parsed === "object" && + "jws_token" in parsed && + typeof (parsed as { jws_token: unknown }).jws_token === "string" + ) { + return (parsed as { jws_token: string }).jws_token; + } + } catch { + // Not JSON — treat the whole content as a bare token. + } + + return trimmed; +}