Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 9 additions & 18 deletions frontend/src/app/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -253,25 +254,15 @@ export default function VerifyPage() {
const [state, setState] = useState<PageState>({ kind: "idle" });
const fileInputRef = useRef<HTMLInputElement>(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<string> {
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<HTMLInputElement>) {
Expand Down
107 changes: 105 additions & 2 deletions frontend/src/lib/passport.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<typeof vi.fn>).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,
);
});
});
41 changes: 41 additions & 0 deletions frontend/src/lib/passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading