diff --git a/decrypt-saved-deck-export/decrypt.mjs b/decrypt-saved-deck-export/decrypt.mjs
new file mode 100644
index 000000000..6b434448c
--- /dev/null
+++ b/decrypt-saved-deck-export/decrypt.mjs
@@ -0,0 +1,231 @@
+#!/usr/bin/env node
+/**
+ * Standalone decrypt tool for a ProxyPrints saved-decks export bundle
+ * (docs/proposals/proposal-g-user-accounts-saved-decks.md, "PR-6, post-v1: deck portability" -
+ * see this directory's readme.md for the full format writeup). This file is the trust anchor for
+ * "if this site vanishes tomorrow, your decks are still yours": it runs without this site, this
+ * codebase, or any server existing at all - only Node's own built-in `node:crypto` WebCrypto
+ * implementation (no npm dependencies whatsoever).
+ *
+ * Usage:
+ * node decrypt.mjs --passphrase "..."
+ * node decrypt.mjs --recovery-key "base64..."
+ * node decrypt.mjs # prompts for a passphrase
+ *
+ * Prints every decrypted deck's plaintext JSON to stdout (one bundle -> one JSON array), or use
+ * --out to write one file per deck instead.
+ *
+ * License: MIT (mirrors federation-hash-tool/'s precedent for a standalone, dependency-free
+ * tool meant to run independently of this GPL-3.0 repository's own codebase).
+ */
+
+import { webcrypto } from "node:crypto";
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { createInterface } from "node:readline";
+import { join } from "node:path";
+
+const { subtle } = webcrypto;
+
+const AES_ALGO = "AES-GCM";
+const AES_KEY_LENGTH = 256;
+const PBKDF2_HASH = "SHA-256";
+
+//# region base64 <-> bytes - identical wire format to the browser's own savedDeckCrypto.ts
+
+function base64ToBytes(base64) {
+ return new Uint8Array(Buffer.from(base64, "base64"));
+}
+
+//# endregion
+
+//# region key derivation/unwrap - MUST match frontend/src/common/savedDeckCrypto.ts exactly
+
+async function derivePassphraseKey(passphrase, salt, iterations) {
+ const baseKey = await subtle.importKey(
+ "raw",
+ new TextEncoder().encode(passphrase),
+ "PBKDF2",
+ false,
+ ["deriveKey"]
+ );
+ return subtle.deriveKey(
+ { name: "PBKDF2", salt, iterations, hash: PBKDF2_HASH },
+ baseKey,
+ { name: AES_ALGO, length: AES_KEY_LENGTH },
+ false,
+ ["unwrapKey"]
+ );
+}
+
+async function importRecoveryKey(recoveryKeyBytes) {
+ return subtle.importKey("raw", recoveryKeyBytes, AES_ALGO, false, [
+ "unwrapKey",
+ ]);
+}
+
+async function unwrapKey(wrapped, nonce, wrappingKey, unwrappedKeyUsages) {
+ return subtle.unwrapKey(
+ "raw",
+ wrapped,
+ wrappingKey,
+ { name: AES_ALGO, iv: nonce },
+ { name: AES_ALGO, length: AES_KEY_LENGTH },
+ true,
+ unwrappedKeyUsages
+ );
+}
+
+async function decryptPayload(ciphertext, nonce, dek) {
+ const plaintext = await subtle.decrypt(
+ { name: AES_ALGO, iv: nonce },
+ dek,
+ ciphertext
+ );
+ return new TextDecoder().decode(plaintext);
+}
+
+//# endregion
+
+/** Unwraps the bundle's own master key, via either its passphrase or its recovery key -
+ * whichever the caller supplies. Throws (AES-GCM authentication failure) on a wrong one. */
+async function unlockBundleMasterKey(
+ bundle,
+ { passphrase, recoveryKeyBase64 }
+) {
+ const profile = bundle.cryptoProfile;
+ if (passphrase != null) {
+ const wrappingKey = await derivePassphraseKey(
+ passphrase,
+ base64ToBytes(profile.salt),
+ profile.kdfIterations
+ );
+ return unwrapKey(
+ base64ToBytes(profile.passphraseWrappedMasterKey),
+ base64ToBytes(profile.passphraseWrappedMasterKeyNonce),
+ wrappingKey,
+ ["unwrapKey"]
+ );
+ }
+ const wrappingKey = await importRecoveryKey(base64ToBytes(recoveryKeyBase64));
+ return unwrapKey(
+ base64ToBytes(profile.recoveryWrappedMasterKey),
+ base64ToBytes(profile.recoveryWrappedMasterKeyNonce),
+ wrappingKey,
+ ["unwrapKey"]
+ );
+}
+
+/** Decrypts every deck in the bundle, given its already-unwrapped master key. Returns an array
+ * of `{ key, kind, createdAt, updatedAt, payload }` - `payload` is the plaintext DeckPayload
+ * object exactly as frontend/src/features/savedDecks/deckPayload.ts defines it (v1 or v2). */
+export async function decryptBundle(bundle, masterKey) {
+ const results = [];
+ for (const deck of bundle.decks) {
+ const dek = await unwrapKey(
+ base64ToBytes(deck.wrappedDek),
+ base64ToBytes(deck.wrappedDekNonce),
+ masterKey,
+ ["decrypt"]
+ );
+ const plaintext = await decryptPayload(
+ base64ToBytes(deck.ciphertext),
+ base64ToBytes(deck.ciphertextNonce),
+ dek
+ );
+ results.push({
+ key: deck.key,
+ kind: deck.kind,
+ createdAt: deck.createdAt,
+ updatedAt: deck.updatedAt,
+ payload: JSON.parse(plaintext),
+ });
+ }
+ return results;
+}
+
+function promptHidden(question) {
+ return new Promise((resolve) => {
+ const rl = createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+ rl.question(question, (answer) => {
+ rl.close();
+ resolve(answer);
+ });
+ });
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const filePath = args.find((arg) => !arg.startsWith("--"));
+ if (filePath == null) {
+ console.error(
+ 'Usage: node decrypt.mjs [--passphrase "..."] [--recovery-key "..."] [--out ]'
+ );
+ process.exit(1);
+ }
+
+ const passphraseIndex = args.indexOf("--passphrase");
+ const recoveryKeyIndex = args.indexOf("--recovery-key");
+ const outIndex = args.indexOf("--out");
+ let passphrase = passphraseIndex !== -1 ? args[passphraseIndex + 1] : null;
+ const recoveryKeyBase64 =
+ recoveryKeyIndex !== -1 ? args[recoveryKeyIndex + 1] : null;
+ const outDir = outIndex !== -1 ? args[outIndex + 1] : null;
+
+ const bundle = JSON.parse(readFileSync(filePath, "utf-8"));
+ if (bundle.formatVersion !== 1) {
+ console.error(
+ `Unsupported saved-deck export format version: ${bundle.formatVersion} (this tool understands version 1)`
+ );
+ process.exit(1);
+ }
+
+ if (passphrase == null && recoveryKeyBase64 == null) {
+ passphrase = await promptHidden("Passphrase: ");
+ }
+
+ let masterKey;
+ try {
+ masterKey = await unlockBundleMasterKey(bundle, {
+ passphrase,
+ recoveryKeyBase64,
+ });
+ } catch (e) {
+ console.error(
+ recoveryKeyBase64 != null
+ ? "That recovery key doesn't match this file."
+ : "That passphrase doesn't match this file."
+ );
+ process.exit(1);
+ }
+
+ const decrypted = await decryptBundle(bundle, masterKey);
+
+ if (outDir != null) {
+ mkdirSync(outDir, { recursive: true });
+ for (const deck of decrypted) {
+ const safeName = (deck.payload.name || deck.key).replace(
+ /[^a-zA-Z0-9_-]+/g,
+ "_"
+ );
+ writeFileSync(
+ join(outDir, `${safeName}.json`),
+ JSON.stringify(deck, null, 2)
+ );
+ }
+ console.error(`Wrote ${decrypted.length} deck(s) to ${outDir}`);
+ } else {
+ console.log(JSON.stringify(decrypted, null, 2));
+ }
+}
+
+// Only run as a CLI entrypoint - importing this module (e.g. from this directory's own tests)
+// must not trigger stdin prompts or process.exit.
+if (import.meta.url === `file://${process.argv[1]}`) {
+ main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+ });
+}
diff --git a/decrypt-saved-deck-export/readme.md b/decrypt-saved-deck-export/readme.md
new file mode 100644
index 000000000..231b04764
--- /dev/null
+++ b/decrypt-saved-deck-export/readme.md
@@ -0,0 +1,165 @@
+# decrypt-saved-deck-export
+
+Standalone decrypt tool for a ProxyPrints saved-decks export file
+(docs/proposals/proposal-g-user-accounts-saved-decks.md, "PR-6, post-v1: deck
+portability"). This is the trust anchor for the claim "if this site vanishes
+tomorrow, your decks are still yours" — it runs without ProxyPrints, this
+codebase, or any server existing at all. Zero npm dependencies: it uses only
+Node's own built-in `node:crypto` WebCrypto implementation, the exact same
+primitives (AES-256-GCM, PBKDF2-SHA256) the browser itself used to encrypt
+your decks in the first place.
+
+**Status**: works today, standalone. The "Export my decks" button that
+produces the file this tool reads is `frontend/src/features/savedDecks/MyDecksPage.tsx`'s
+Export action.
+
+## Requirements
+
+Node.js 20 or later (needs `node:crypto`'s `webcrypto` export). Nothing else
+— no `npm install` required for the tool itself. `node --test` (built into
+Node, no dependency) runs this directory's own test suite.
+
+## Usage
+
+```
+node decrypt.mjs --passphrase "your passphrase"
+```
+
+```
+node decrypt.mjs --recovery-key "your-recovery-key-base64"
+```
+
+```
+node decrypt.mjs
+# prompts for a passphrase interactively if neither flag is given
+```
+
+Prints every decrypted deck as one JSON array to stdout. Add `--out ` to
+write one `.json` file per deck into `` instead.
+
+Run the test suite:
+
+```
+node --test tests/decrypt.test.mjs
+```
+
+## The export format (public, versioned)
+
+This is the actual portability contract — documented here specifically so a
+fork, or a completely independent reimplementation, can read a ProxyPrints
+saved-decks export without needing this codebase at all. The file is one
+JSON object:
+
+```jsonc
+{
+ "formatVersion": 1,
+ "exportedAt": "2026-01-02T00:00:00.000Z",
+ "cryptoProfile": {
+ "salt": "",
+ "kdfIterations": 600000,
+ "passphraseWrappedMasterKey": "",
+ "passphraseWrappedMasterKeyNonce": "",
+ "recoveryWrappedMasterKey": "",
+ "recoveryWrappedMasterKeyNonce": ""
+ },
+ "decks": [
+ {
+ "key": "",
+ "kind": "deck", // or "snapshot"
+ "ciphertext": "",
+ "ciphertextNonce": "",
+ "wrappedDek": "",
+ "wrappedDekNonce": "",
+ "createdAt": "2026-01-01",
+ "updatedAt": "2026-01-02"
+ }
+ // ...
+ ]
+}
+```
+
+Every field here is exactly the same opaque bytes the ProxyPrints server
+itself stores — nothing in this outer envelope is ever plaintext deck
+content. `formatVersion` is this bundle's own public wire-format version,
+distinct from the PRIVATE per-deck `version` field described below (which
+only exists once decrypted, inside the ciphertext).
+
+### Unwrapping the master key
+
+Exactly one of `passphrase` or `recoveryKeyBase64` is used:
+
+- **Passphrase**: PBKDF2-SHA256 over the passphrase, using `salt` and
+ `kdfIterations` above, produces an AES-256-GCM key. That key unwraps
+ `passphraseWrappedMasterKey` (AES-GCM, IV = `passphraseWrappedMasterKeyNonce`)
+ to recover the master key.
+- **Recovery key**: the recovery key IS the key material already (no KDF) —
+ import it directly as a raw AES-256-GCM key, then unwrap
+ `recoveryWrappedMasterKey` (IV = `recoveryWrappedMasterKeyNonce`) the same
+ way.
+
+### Decrypting each deck
+
+For every entry in `decks`:
+
+1. Unwrap `wrappedDek` (AES-GCM, IV = `wrappedDekNonce`) using the master key
+ from above, to get that deck's DEK (Data Encryption Key).
+2. Decrypt `ciphertext` (AES-GCM, IV = `ciphertextNonce`) using the DEK.
+3. The result is UTF-8 JSON text — a `DeckPayload` object:
+
+```jsonc
+// v1 (legacy, pre "Revision tracking")
+{
+ "version": 1,
+ "name": "...",
+ "members": [
+ /* ... */
+ ],
+ "cardback": null,
+ "manualOverrides": {},
+ "finishSettings": { "cardstock": "...", "foil": false }
+}
+```
+
+```jsonc
+// v2 (current - adds revision tracking)
+{
+ "version": 2,
+ "name": "...",
+ "members": [
+ /* ... */
+ ],
+ "cardback": null,
+ "manualOverrides": {},
+ "finishSettings": { "cardstock": "...", "foil": false },
+ "revision": 3,
+ "modifiedAt": "2026-01-01T00:00:00.000Z"
+}
+```
+
+`revision` (an integer, incremented on every save of that same row) and
+`modifiedAt` (an ISO 8601 timestamp) make an export/import round-trip
+self-describing: comparing an imported bundle's `revision`/`modifiedAt`
+against a server's current copy of "the same" deck (if you're tracking that
+manually — ProxyPrints itself never matches decks by name or key on import,
+see the main feature doc) tells you which copy is newer, without either side
+needing to compare plaintext.
+
+A future version (`version: 3`, PR-7 "art provenance") is expected to add an
+optional per-slot provenance record to each member; this tool's own
+`decrypt.mjs` doesn't special-case any particular version — it just decrypts
+the ciphertext and returns whatever JSON comes out, so it will keep working
+unmodified once that ships.
+
+## Design notes
+
+- **No key material ever touches disk** beyond what's already in the export
+ file itself — this tool holds the master key and each DEK in memory only,
+ for the duration of one run.
+- **A wrong passphrase or recovery key fails loudly** (an AES-GCM
+ authentication error) — this tool never silently returns corrupted or
+ wrong plaintext.
+- **An exported file is offline-attackable** by design (same exposure a
+ server breach of ProxyPrints' own database already has) — its real
+ protection is passphrase strength plus PBKDF2 at a high iteration count.
+ Treat an export file with the same care you'd give a password manager
+ export.
diff --git a/decrypt-saved-deck-export/tests/decrypt.test.mjs b/decrypt-saved-deck-export/tests/decrypt.test.mjs
new file mode 100644
index 000000000..108b1cb1f
--- /dev/null
+++ b/decrypt-saved-deck-export/tests/decrypt.test.mjs
@@ -0,0 +1,184 @@
+/**
+ * Zero-dependency test for decrypt.mjs, using Node's own built-in test runner (`node --test`) -
+ * no npm install needed to verify this tool, matching its own "dependency-minimal" promise.
+ * Builds a bundle independently (via raw WebCrypto calls mirroring
+ * frontend/src/common/savedDeckCrypto.ts's own wrap/encrypt logic) rather than importing
+ * anything from the frontend, so this test also serves as a live cross-check that decrypt.mjs's
+ * understanding of the wire format hasn't drifted from the browser's.
+ *
+ * Run with: node --test decrypt-saved-deck-export/tests/decrypt.test.mjs
+ */
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { webcrypto } from "node:crypto";
+
+import { decryptBundle } from "../decrypt.mjs";
+
+const { subtle } = webcrypto;
+const getRandomValues = webcrypto.getRandomValues.bind(webcrypto);
+
+function bytesToBase64(bytes) {
+ return Buffer.from(bytes).toString("base64");
+}
+
+async function buildTestBundle(passphrase, deckPayloads) {
+ const salt = getRandomValues(new Uint8Array(16));
+ const iterations = 100;
+ const baseKey = await subtle.importKey(
+ "raw",
+ new TextEncoder().encode(passphrase),
+ "PBKDF2",
+ false,
+ ["deriveKey"]
+ );
+ const passphraseKey = await subtle.deriveKey(
+ { name: "PBKDF2", salt, iterations, hash: "SHA-256" },
+ baseKey,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["wrapKey"]
+ );
+
+ const masterKey = await subtle.generateKey(
+ { name: "AES-GCM", length: 256 },
+ true,
+ ["wrapKey", "unwrapKey"]
+ );
+ const passphraseWrapNonce = getRandomValues(new Uint8Array(12));
+ const passphraseWrappedMasterKey = await subtle.wrapKey(
+ "raw",
+ masterKey,
+ passphraseKey,
+ { name: "AES-GCM", iv: passphraseWrapNonce }
+ );
+
+ // Recovery slot isn't exercised by these tests but the format requires it to be present.
+ const recoveryKeyBytes = getRandomValues(new Uint8Array(32));
+ const recoveryKey = await subtle.importKey(
+ "raw",
+ recoveryKeyBytes,
+ "AES-GCM",
+ false,
+ ["wrapKey"]
+ );
+ const recoveryWrapNonce = getRandomValues(new Uint8Array(12));
+ const recoveryWrappedMasterKey = await subtle.wrapKey(
+ "raw",
+ masterKey,
+ recoveryKey,
+ { name: "AES-GCM", iv: recoveryWrapNonce }
+ );
+
+ const decks = [];
+ for (const [index, payload] of deckPayloads.entries()) {
+ const dek = await subtle.generateKey(
+ { name: "AES-GCM", length: 256 },
+ true,
+ ["encrypt", "decrypt", "wrapKey", "unwrapKey"]
+ );
+ const dekWrapNonce = getRandomValues(new Uint8Array(12));
+ const wrappedDek = await subtle.wrapKey("raw", dek, masterKey, {
+ name: "AES-GCM",
+ iv: dekWrapNonce,
+ });
+ const ciphertextNonce = getRandomValues(new Uint8Array(12));
+ const ciphertext = await subtle.encrypt(
+ { name: "AES-GCM", iv: ciphertextNonce },
+ dek,
+ new TextEncoder().encode(JSON.stringify(payload))
+ );
+ decks.push({
+ key: `deck-${index}`,
+ kind: "deck",
+ ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
+ ciphertextNonce: bytesToBase64(ciphertextNonce),
+ wrappedDek: bytesToBase64(new Uint8Array(wrappedDek)),
+ wrappedDekNonce: bytesToBase64(dekWrapNonce),
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-02",
+ });
+ }
+
+ return {
+ formatVersion: 1,
+ exportedAt: "2026-01-02T00:00:00.000Z",
+ cryptoProfile: {
+ salt: bytesToBase64(salt),
+ kdfIterations: iterations,
+ passphraseWrappedMasterKey: bytesToBase64(
+ new Uint8Array(passphraseWrappedMasterKey)
+ ),
+ passphraseWrappedMasterKeyNonce: bytesToBase64(passphraseWrapNonce),
+ recoveryWrappedMasterKey: bytesToBase64(
+ new Uint8Array(recoveryWrappedMasterKey)
+ ),
+ recoveryWrappedMasterKeyNonce: bytesToBase64(recoveryWrapNonce),
+ },
+ decks,
+ };
+}
+
+async function unlockWithPassphrase(bundle, passphrase) {
+ const baseKey = await subtle.importKey(
+ "raw",
+ new TextEncoder().encode(passphrase),
+ "PBKDF2",
+ false,
+ ["deriveKey"]
+ );
+ const passphraseKey = await subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt: Buffer.from(bundle.cryptoProfile.salt, "base64"),
+ iterations: bundle.cryptoProfile.kdfIterations,
+ hash: "SHA-256",
+ },
+ baseKey,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["unwrapKey"]
+ );
+ return subtle.unwrapKey(
+ "raw",
+ Buffer.from(bundle.cryptoProfile.passphraseWrappedMasterKey, "base64"),
+ passphraseKey,
+ {
+ name: "AES-GCM",
+ iv: Buffer.from(
+ bundle.cryptoProfile.passphraseWrappedMasterKeyNonce,
+ "base64"
+ ),
+ },
+ { name: "AES-GCM", length: 256 },
+ true,
+ ["unwrapKey", "decrypt"]
+ );
+}
+
+test("decryptBundle recovers every deck's plaintext payload given the bundle's own master key", async () => {
+ const bundle = await buildTestBundle("the real passphrase", [
+ { version: 2, name: "Aggro", revision: 3, modifiedAt: "2026-01-01" },
+ { version: 1, name: "Legacy Deck" },
+ ]);
+ const masterKey = await unlockWithPassphrase(bundle, "the real passphrase");
+
+ const decrypted = await decryptBundle(bundle, masterKey);
+ assert.equal(decrypted.length, 2);
+ assert.equal(decrypted[0].payload.name, "Aggro");
+ assert.equal(decrypted[0].payload.revision, 3);
+ assert.equal(decrypted[1].payload.name, "Legacy Deck");
+ assert.equal(decrypted[1].payload.version, 1);
+});
+
+test("a wrong master key fails to decrypt (AES-GCM authentication failure), never returns silently-wrong plaintext", async () => {
+ const bundle = await buildTestBundle("the real passphrase", [
+ { version: 2, name: "Aggro", revision: 1, modifiedAt: "2026-01-01" },
+ ]);
+ const wrongMasterKey = await subtle.generateKey(
+ { name: "AES-GCM", length: 256 },
+ true,
+ ["wrapKey", "unwrapKey"]
+ );
+ await assert.rejects(() => decryptBundle(bundle, wrongMasterKey));
+});
diff --git a/docs/README.md b/docs/README.md
index d91d0e392..5c1f23848 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -89,8 +89,9 @@ The methodology and the systems it governs.
backend `LOCAL_FILE` catalog source type.
- [`features/saved-decks.md`](features/saved-decks.md) — zero-knowledge
user accounts + server-side saved decks: the crypto design, backend
- endpoints, frontend wiring, the shipped PR-5 per-deck share links, and
- the still-design-only PR-6/7 addenda.
+ endpoints, frontend wiring, the shipped PR-5 per-deck share links and
+ PR-6 deck-portability addendum (export/import + standalone decrypt
+ tool), and the still-design-only PR-7 addendum.
- [`features/consent-toast.md`](features/consent-toast.md) — the reusable,
permission-triggered contextual consent toast (issue #204): a
bottom-corner accept/decline prompt shown only right before an action
@@ -129,16 +130,16 @@ Deployment, incidents, and cross-session lessons.
One-word status per doc; see each file for the full survey/spec.
-| Doc | Status |
-| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
-| [`proposals/proposal-b-bleed-normalization.md`](proposals/proposal-b-bleed-normalization.md) — export-time per-side bleed normalization | BUILDING |
-| [`proposals/proposal-c-context-menu-restyle.md`](proposals/proposal-c-context-menu-restyle.md) — right-click/long-press context menu (shipped); restyle direction (HOLD) | PARTIAL |
-| [`proposals/proposal-f-public-stats-page.md`](proposals/proposal-f-public-stats-page.md) — public `/stats` transparency page | HOLD |
-| [`proposals/proposal-g-user-accounts-saved-decks.md`](proposals/proposal-g-user-accounts-saved-decks.md) — user accounts + saved decks via Discord OAuth (core build + PR-5 share links shipped; see [`features/saved-decks.md`](features/saved-decks.md) — PR-6/7 addenda still HOLD) | PARTIAL |
-| [`proposals/proposal-h-unified-display-page.md`](proposals/proposal-h-unified-display-page.md) — one page merging the "Choose Art" editor and PDF export into a live print-sheet preview + card-details rail | PARTIAL |
-| [`proposals/proposal-i-docs-as-site-source.md`](proposals/proposal-i-docs-as-site-source.md) — extends the docs/-to-wiki publish pipeline with a second target: rendered site pages + build-time JSON data extracts | BUILDING |
-| [`proposals/proposal-i-readme-pipeline.md`](proposals/proposal-i-readme-pipeline.md) — folds `readme.md` into the same pipeline as a third (`readme`) emit mode: content merge map, owner GO decision, and what shipped | SHIPPED |
-| [`federation/public-export-v1.md`](federation/public-export-v1.md) — publish-first federation: signed verdict export consumable by mpc-autofill forks and the MIT-lineage proxy tools, no peer required | HOLD |
+| Doc | Status |
+| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
+| [`proposals/proposal-b-bleed-normalization.md`](proposals/proposal-b-bleed-normalization.md) — export-time per-side bleed normalization | PARTIAL |
+| [`proposals/proposal-c-context-menu-restyle.md`](proposals/proposal-c-context-menu-restyle.md) — right-click/long-press context menu (shipped); restyle direction (HOLD) | PARTIAL |
+| [`proposals/proposal-f-public-stats-page.md`](proposals/proposal-f-public-stats-page.md) — public `/stats` transparency page | HOLD |
+| [`proposals/proposal-g-user-accounts-saved-decks.md`](proposals/proposal-g-user-accounts-saved-decks.md) — user accounts + saved decks via Discord OAuth (core build + PR-5 share links + PR-6 deck portability shipped; see [`features/saved-decks.md`](features/saved-decks.md) — PR-7 addendum still HOLD) | PARTIAL |
+| [`proposals/proposal-h-unified-display-page.md`](proposals/proposal-h-unified-display-page.md) — one page merging the "Choose Art" editor and PDF export into a live print-sheet preview + card-details rail | PARTIAL |
+| [`proposals/proposal-i-docs-as-site-source.md`](proposals/proposal-i-docs-as-site-source.md) — extends the docs/-to-wiki publish pipeline with a second target: rendered site pages + build-time JSON data extracts | BUILDING |
+| [`proposals/proposal-i-readme-pipeline.md`](proposals/proposal-i-readme-pipeline.md) — folds `readme.md` into the same pipeline as a third (`readme`) emit mode: content merge map, owner GO decision, and what shipped | SHIPPED |
+| [`federation/public-export-v1.md`](federation/public-export-v1.md) — publish-first federation: signed verdict export consumable by mpc-autofill forks and the MIT-lineage proxy tools, no peer required | HOLD |
Not every shipped proposal-lettered feature has a survey doc here — some
(e.g. Proposal A, Proposal D) went straight from idea to shipped PR without
diff --git a/docs/features/saved-decks.md b/docs/features/saved-decks.md
index e59fd4cf4..fd3bf6bac 100644
--- a/docs/features/saved-decks.md
+++ b/docs/features/saved-decks.md
@@ -8,8 +8,10 @@ Spec: [`proposals/proposal-g-user-accounts-saved-decks.md`](../proposals/proposa
the opaque-blob API (#94, a recreation after #88's stacked-PR base-deletion
auto-close — see [`../lessons.md`](../lessons.md)), the client-side crypto
module (#89), and the frontend UI wiring (#93). PR-5 (per-deck share links,
-the spec's own "PR-5, post-v1" section) landed as a follow-up — see "Per-deck
-share links" below.
+the spec's own "PR-5, post-v1" section) landed as a follow-up — see
+"Per-deck share links" below. "PR-6, post-v1: deck portability"
+(export/import + the standalone decrypt tool) shipped as a later, separate
+frontend-only change — see its own section below.
## Where it's wired in
@@ -104,6 +106,14 @@ cannot decrypt, by design, not by policy.
slot's identifier is device-specific and meaningless elsewhere, so only a
flag survives on save; the card grid's existing empty-slot/re-search UI
is the honest "needs re-picking" placeholder on load, not a bespoke tile.
+ Currently at `version: 2` (PR-6 "Revision tracking" — see below); a v1
+ payload is upgraded forward on load (`parseDeckPayload`), never rejected.
+ `buildDeckPayload`'s own return (`DeckPayloadContent`) deliberately omits
+ `version`/`revision`/`modifiedAt` so it stays byte-identical across calls
+ with unchanged input — those bookkeeping fields are stamped on only at
+ the moment of encryption (`encryptDeckPayloadForSave`), otherwise the
+ dirty-check baseline (`selectors.ts`) would see a fresh `modifiedAt` on
+ every render and permanently misreport "dirty."
- `PassphraseSetupModal.tsx` / `UnlockModal.tsx` / `RecoveryKeyDisplay.tsx`:
first-save passphrase creation (with the verbatim-spirit unrecoverability
warning), once-per-session unlock, and the show-once recovery-key
@@ -198,12 +208,65 @@ receives.
`frontend/src/features/savedDecks/deckShare.test.ts`, endpoint-level in
`MPCAutofill/cardpicker/tests/test_saved_deck_share_views.py`.
+## Deck portability (PR-6)
+
+Formalizes what the zero-knowledge, server-unbound design already implies:
+since no key material is ever held by the server, a user's saved decks are
+portable by construction. Frontend-only — no backend schema or endpoint
+changes, since export/import are fully served by the existing
+`getSavedDecks`/`getCryptoProfile`/`saveDeck`/`saveCryptoProfile` endpoints.
+
+- `deckExportImport.ts`: `buildExportBundle`/`downloadExportBundle` (export;
+ requires no unlock — it's the same opaque bytes the server already holds,
+ reshaped into one `.json` file) and `unlockBundleMasterKeyWithPassphrase`/
+ `unlockBundleMasterKeyWithRecoveryKey`/`decryptBundleDecks` (import;
+ decrypts using the **bundle's own** crypto profile, not necessarily the
+ live session's — a bundle may come from a different account or a
+ different, compatible instance entirely). `EXPORT_FORMAT_VERSION` (starts
+ at 1) is this bundle's own public wire-format version — distinct from
+ `deckPayload.ts`'s private, per-deck `version` field, which lives inside
+ the ciphertext and is never visible in the outer envelope.
+- `ImportDeckModal.tsx`: file picker + passphrase-or-recovery-key prompt for
+ the bundle, then re-encrypts every decrypted deck under the **current**
+ session's own master key and calls `saveDeck` with `key: null` for each —
+ always import-as-new (never overwrites an existing deck by matching key
+ or name; there's no server-visible name to match against anyway once
+ titles are encrypted). Uses `encryptFinalizedDeckPayload` (not
+ `encryptDeckPayloadForSave`) specifically so each imported deck's own
+ `revision`/`modifiedAt` survive verbatim — importing is a restore, not a
+ new save. Requires the current session to already be unlocked (an
+ honest, stated scope limit — importing into a brand-new account with no
+ crypto profile yet isn't handled by this modal).
+- `MyDecksPage.tsx`: "Export my decks" (enabled whenever any deck exists,
+ even while locked) and "Import decks" (enabled only once unlocked, since
+ importing needs somewhere to persist the decrypted decks to) sit above
+ the deck list.
+- **Revision tracking**: `deckPayload.ts`'s `DeckPayloadV2` adds `revision`
+ (an integer, incremented on every save of the SAME server-side row) and
+ `modifiedAt` (an ISO 8601 timestamp), both PRIVATE — inside the encrypted
+ payload, never server-visible. A brand-new row (a fresh deck, "Save as
+ new snapshot," or an imported deck) always starts its own chain at
+ `revision: 1`; only an update to an already-saved row continues it
+ (tracked client-side via `savedDeckSessionSlice`'s `lastSavedRevision`).
+ Bumping to `version: 2` for this addition establishes the "PR-6/PR-7
+ shared versioning rule" the spec describes — PR-7's art-provenance
+ addition (not built) is expected to become `version: 3` of this same
+ counter, via the same upgrade-dispatch pattern `parseDeckPayload` already
+ uses for v1 → v2.
+- **Standalone decrypt tool** (`decrypt-saved-deck-export/` at the repo
+ root, mirroring `federation-hash-tool/`'s precedent): a zero-npm-dependency
+ Node.js script (`decrypt.mjs`) using only `node:crypto`'s built-in
+ WebCrypto implementation — the exact same primitives the browser used to
+ encrypt. This is the trust anchor for "if this site vanishes tomorrow,
+ your decks are still yours": it runs without this site, this codebase, or
+ any server existing at all. Declared MIT-licensed (a deviation from this
+ repository's own GPL-3.0, following `federation-hash-tool/`'s existing
+ precedent for a standalone tool meant to be freely reusable by forks or
+ independent reimplementations) — see that directory's own readme.md for
+ the full public wire-format writeup and usage.
+
## Not yet built (design-only addenda in the spec doc)
-- **PR-6, deck portability**: export/import of the complete encrypted
- bundle (no unlock required to export), a versioned public format, and a
- standalone decrypt tool as the trust anchor for "if this site vanishes
- tomorrow, your decks are still yours." Nothing built.
- **PR-7, art provenance**: per-slot provenance (`driveId`, `sourceName`,
`sourceType`, optional `contentPhash`, `indexedBy`) in a future
`deckPayload` version, so an un-indexed slot renders a direct-from-drive
diff --git a/docs/proposals/proposal-g-user-accounts-saved-decks.md b/docs/proposals/proposal-g-user-accounts-saved-decks.md
index 0612cccd5..bd06bfcc2 100644
--- a/docs/proposals/proposal-g-user-accounts-saved-decks.md
+++ b/docs/proposals/proposal-g-user-accounts-saved-decks.md
@@ -10,9 +10,12 @@ recreated after #88's stacked-PR base-deletion auto-close — see
docs/lessons.md), the client-side ZK crypto module (#89), and the frontend
UI wiring (#93). **Spec CLOSED: no open decisions remain** (see Decisions).
§7 (authed vote tier) is fully specified but remains a deliberately separate,
-later build — not part of this HOLD's core scope. PR-5 (share links),
-PR-6 (deck portability), and PR-7 (art provenance) are design-only,
-post-v1 addenda — nothing built for any of them yet.
+later build — not part of this HOLD's core scope. PR-5 (share links) and
+PR-7 (art provenance) are still design-only, post-v1 addenda — nothing
+built for either yet. **PR-6 (deck portability) has since shipped**
+(frontend-only — export/import + the standalone decrypt tool; see
+docs/features/saved-decks.md's "Deck portability (PR-6)" section) —
+nothing else in this file's own scope changed as a result.
## Context — prior art outside this codebase
@@ -780,7 +783,7 @@ already built in PR-1's schema.
ciphertext; a leaked `shareKey` cannot decrypt or unwrap anything for
any _other_ deck, shared or not.
-### PR-6, post-v1: deck portability (design only — owner-directed addendum, 2026-07-18; nothing built here)
+### PR-6, post-v1: deck portability (SHIPPED — owner-directed addendum, spec written 2026-07-18; built as a frontend-only change, see docs/features/saved-decks.md's "Deck portability (PR-6)" section for what actually landed)
Formalizes what the ZK envelope already implies rather than adding new
capability: the crypto is deliberately **server-unbound** — no key
diff --git a/docs/upstreaming/extractable-primitives.md b/docs/upstreaming/extractable-primitives.md
index 4e0aca7ed..62897c4e1 100644
--- a/docs/upstreaming/extractable-primitives.md
+++ b/docs/upstreaming/extractable-primitives.md
@@ -117,11 +117,12 @@ coupling to the vote system is.
## Docs tooling & federation
-| Primitive | File(s) | Problem solved | Candidate consumers | Entanglement | License note |
-| ------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------------- |
-| Docs single-transform pipeline | `.github/scripts/publish_wiki.py`, `publish_site.py` | One shared link-rewrite transform (`transform_links()`) publishing the same `docs/` markdown to both a GitHub wiki and a static site, with a marker-based "only delete pages I generated" safety property | upstream, proxies-at-home, federation peers (any project with `docs/` + wiki + site) | CLEAN | — |
-| Upstream wiki drift tracker | `.github/scripts/upstream_wiki_drift.py` | Diffs an external GitHub wiki's git history against a last-seen-SHA table, updates it in place — detection only, never copies wiki prose | proxies-at-home, any fork tracking an upstream project's wiki | CLEAN | — |
-| Federation hash tool | `federation-hash-tool/hash_my_cards.py` | Computes a stable perceptual hash of a card image using this fork's crop/classify recipe, so a peer can independently reproduce the same hash and join against a published federation export without transmitting raw images | federation peers | CLEAN (narrowly scoped by design — see note) | MIT, deliberately distinct from the ODbL-licensed export _data_ it joins against |
+| Primitive | File(s) | Problem solved | Candidate consumers | Entanglement | License note |
+| ------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| Docs single-transform pipeline | `.github/scripts/publish_wiki.py`, `publish_site.py` | One shared link-rewrite transform (`transform_links()`) publishing the same `docs/` markdown to both a GitHub wiki and a static site, with a marker-based "only delete pages I generated" safety property | upstream, proxies-at-home, federation peers (any project with `docs/` + wiki + site) | CLEAN | — |
+| Upstream wiki drift tracker | `.github/scripts/upstream_wiki_drift.py` | Diffs an external GitHub wiki's git history against a last-seen-SHA table, updates it in place — detection only, never copies wiki prose | proxies-at-home, any fork tracking an upstream project's wiki | CLEAN | — |
+| Federation hash tool | `federation-hash-tool/hash_my_cards.py` | Computes a stable perceptual hash of a card image using this fork's crop/classify recipe, so a peer can independently reproduce the same hash and join against a published federation export without transmitting raw images | federation peers | CLEAN (narrowly scoped by design — see note) | MIT, deliberately distinct from the ODbL-licensed export _data_ it joins against |
+| Saved-deck export decrypt tool | `decrypt-saved-deck-export/decrypt.mjs` | Decrypts a ProxyPrints saved-decks export bundle (PBKDF2-SHA256 + AES-256-GCM, via Node's own `node:crypto` WebCrypto) without this codebase, this site, or any server existing at all — zero imports from anywhere in this repo, zero npm dependencies | upstream, proxies-at-home (any zero-knowledge saved-deck implementation using the same wire format) | CLEAN (zero imports at all, narrowly scoped by design — see note) | MIT, same precedent as the federation hash tool row above |
## Detail notes
@@ -202,6 +203,23 @@ publisher-only posture) in `docs/federation-v1.md`, which genuinely is
fork-only by definition — the hash tool is the one piece of the federation
program that cleanly separates from that layer.
+**Saved-deck export decrypt tool** — zero imports of any kind, not just zero
+fork-specific ones: `decrypt.mjs` re-implements the (tiny) AES-256-GCM/
+PBKDF2-SHA256 wrap/unwrap logic itself using only Node's built-in
+`node:crypto`, rather than importing `frontend/src/common/savedDeckCrypto.ts`
+
+- deliberately, since the whole point is running with none of this
+ repository's own code present. What's narrow by design is the wire format
+ (docs/proposals/proposal-g-user-accounts-saved-decks.md's "PR-6, post-v1:
+ deck portability" section, also reproduced in the tool's own readme.md) -
+ an arbitrary-but-fixed convention this fork chose for its saved-decks
+ export, not a universal format. `frontend/src/common/savedDeckCrypto.ts`
+ and `frontend/src/features/savedDecks/deckPayload.ts` (the in-app
+ counterparts this tool's logic mirrors) are themselves NOT rowed here -
+ they predate this ledger's 2026-07-19 sweep and haven't been audited for
+ it yet; not rowing this tool's own dependencies-that-aren't-actually-
+ dependencies (see above) doesn't change that gap.
+
**Back-face name lookup** — `get_back_face_names`/`is_back_face`/
`DOUBLE_FACED_LAYOUTS` touch only `Path`/pydantic parsing of the raw bulk
JSON, no fork-only symbol at all in their own bodies — but they live in
diff --git a/frontend/src/features/savedDecks/ImportDeckModal.test.tsx b/frontend/src/features/savedDecks/ImportDeckModal.test.tsx
new file mode 100644
index 000000000..4956363e3
--- /dev/null
+++ b/frontend/src/features/savedDecks/ImportDeckModal.test.tsx
@@ -0,0 +1,232 @@
+/**
+ * "PR-6, post-v1: deck portability" import flow: a bundle exported under one crypto profile
+ * (its own passphrase) gets decrypted, then persisted under a DIFFERENT (the current session's)
+ * master key - always as new rows, preserving each deck's own revision/modifiedAt verbatim.
+ */
+
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import React from "react";
+import { Provider } from "react-redux";
+
+import { bytesToBase64, createCryptoProfile } from "@/common/savedDeckCrypto";
+import { localBackend, localBackendURL } from "@/common/test-constants";
+import { buildMockSavedDeckSummary } from "@/features/savedDecks/cryptoTestHandlers";
+import {
+ buildExportBundle,
+ serializeExportBundle,
+} from "@/features/savedDecks/deckExportImport";
+import { decryptSavedDeckSummary } from "@/features/savedDecks/deckPayload";
+import { ImportDeckModal } from "@/features/savedDecks/ImportDeckModal";
+import { server } from "@/mocks/server";
+import { setupStore } from "@/store/store";
+
+function renderModal(props: React.ComponentProps) {
+ const store = setupStore({ backend: localBackend });
+ render(
+
+
+
+ );
+}
+
+const TEST_ITERATIONS = 100;
+const BUNDLE_PASSPHRASE = "the exported passphrase";
+
+function buildRoute(path: string): string {
+ return `${localBackendURL}/${path}`;
+}
+
+test("importing a bundle decrypts it with ITS OWN passphrase, then persists every deck as new under the current session's master key, preserving revision/modifiedAt", async () => {
+ const bundleProfile = await createCryptoProfile(
+ BUNDLE_PASSPHRASE,
+ TEST_ITERATIONS
+ );
+ const currentSessionProfile = await createCryptoProfile(
+ "a completely different passphrase",
+ TEST_ITERATIONS
+ );
+
+ const deckA = await buildMockSavedDeckSummary(
+ "deck-a",
+ "deck",
+ {
+ version: 2,
+ name: "Aggro Deck",
+ members: [],
+ cardback: null,
+ manualOverrides: {},
+ finishSettings: { cardstock: "(S30) Standard Smooth", foil: false },
+ revision: 5,
+ modifiedAt: "2025-06-01T00:00:00.000Z",
+ },
+ bundleProfile.masterKey,
+ { createdAt: "2025-01-01", updatedAt: "2025-06-01" }
+ );
+ const deckB = await buildMockSavedDeckSummary(
+ "deck-b",
+ "snapshot",
+ {
+ version: 2,
+ name: "Backup",
+ members: [],
+ cardback: null,
+ manualOverrides: {},
+ finishSettings: { cardstock: "(S30) Standard Smooth", foil: false },
+ revision: 1,
+ modifiedAt: "2025-06-02T00:00:00.000Z",
+ },
+ bundleProfile.masterKey,
+ { createdAt: "2025-06-02", updatedAt: "2025-06-02" }
+ );
+
+ const realBundle = buildExportBundle(
+ {
+ exists: true,
+ salt: bytesToBase64(bundleProfile.salt),
+ kdfIterations: bundleProfile.iterations,
+ passphraseWrappedMasterKey: bytesToBase64(
+ bundleProfile.passphraseWrapped.wrapped
+ ),
+ passphraseWrappedMasterKeyNonce: bytesToBase64(
+ bundleProfile.passphraseWrapped.nonce
+ ),
+ recoveryWrappedMasterKey: bytesToBase64(
+ bundleProfile.recoveryWrapped.wrapped
+ ),
+ recoveryWrappedMasterKeyNonce: bytesToBase64(
+ bundleProfile.recoveryWrapped.nonce
+ ),
+ },
+ [deckA, deckB]
+ );
+
+ const requests: Array = [];
+ server.use(
+ http.post(buildRoute("2/saveDeck/"), async ({ request }) => {
+ const body = await request.json();
+ requests.push(body);
+ return HttpResponse.json(
+ { key: `imported-${requests.length}` },
+ {
+ status: 200,
+ }
+ );
+ })
+ );
+
+ const onImported = jest.fn();
+ renderModal({
+ show: true,
+ onCancel: jest.fn(),
+ onImported,
+ masterKey: currentSessionProfile.masterKey,
+ });
+
+ const file = new File([serializeExportBundle(realBundle)], "export.json", {
+ type: "application/json",
+ });
+ fireEvent.change(screen.getByLabelText("import-file"), {
+ target: { files: [file] },
+ });
+
+ await screen.findByText(/2 decks found/);
+ fireEvent.change(screen.getByLabelText("import-passphrase"), {
+ target: { value: BUNDLE_PASSPHRASE },
+ });
+ fireEvent.click(screen.getByText("Import"));
+
+ await waitFor(() => expect(onImported).toHaveBeenCalledWith(2));
+ expect(requests).toHaveLength(2);
+ expect(requests.every((r) => r.key === null)).toBe(true);
+ expect(requests.map((r) => r.kind).sort()).toEqual(["deck", "snapshot"]);
+
+ // Every persisted row decrypts under the CURRENT session's master key (not the bundle's own),
+ // and keeps its original revision/modifiedAt (deck-portability's whole point).
+ const decryptedRequests = await Promise.all(
+ requests.map((body) =>
+ decryptSavedDeckSummary(
+ {
+ key: "unused",
+ kind: body.kind,
+ ciphertext: body.ciphertext,
+ ciphertextNonce: body.ciphertextNonce,
+ wrappedDek: body.wrappedDek,
+ wrappedDekNonce: body.wrappedDekNonce,
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-01",
+ },
+ currentSessionProfile.masterKey
+ )
+ )
+ );
+ const names = decryptedRequests.map((d) => d.name).sort();
+ expect(names).toEqual(["Aggro Deck", "Backup"]);
+ const aggro = decryptedRequests.find((d) => d.name === "Aggro Deck")!;
+ expect(aggro.payload.revision).toEqual(5);
+ expect(aggro.payload.modifiedAt).toEqual("2025-06-01T00:00:00.000Z");
+});
+
+test("a wrong passphrase for the bundle shows an error, without persisting anything", async () => {
+ const bundleProfile = await createCryptoProfile(
+ BUNDLE_PASSPHRASE,
+ TEST_ITERATIONS
+ );
+ const currentSessionProfile = await createCryptoProfile(
+ "current session passphrase",
+ TEST_ITERATIONS
+ );
+ const realBundle = buildExportBundle(
+ {
+ exists: true,
+ salt: bytesToBase64(bundleProfile.salt),
+ kdfIterations: bundleProfile.iterations,
+ passphraseWrappedMasterKey: bytesToBase64(
+ bundleProfile.passphraseWrapped.wrapped
+ ),
+ passphraseWrappedMasterKeyNonce: bytesToBase64(
+ bundleProfile.passphraseWrapped.nonce
+ ),
+ recoveryWrappedMasterKey: bytesToBase64(
+ bundleProfile.recoveryWrapped.wrapped
+ ),
+ recoveryWrappedMasterKeyNonce: bytesToBase64(
+ bundleProfile.recoveryWrapped.nonce
+ ),
+ },
+ []
+ );
+
+ let saveDeckCalled = false;
+ server.use(
+ http.post(buildRoute("2/saveDeck/"), async () => {
+ saveDeckCalled = true;
+ return HttpResponse.json({ key: "should-not-happen" }, { status: 200 });
+ })
+ );
+
+ const onImported = jest.fn();
+ renderModal({
+ show: true,
+ onCancel: jest.fn(),
+ onImported,
+ masterKey: currentSessionProfile.masterKey,
+ });
+
+ const file = new File([serializeExportBundle(realBundle)], "export.json", {
+ type: "application/json",
+ });
+ fireEvent.change(screen.getByLabelText("import-file"), {
+ target: { files: [file] },
+ });
+
+ await screen.findByText(/0 decks found/);
+ fireEvent.change(screen.getByLabelText("import-passphrase"), {
+ target: { value: "definitely wrong" },
+ });
+ fireEvent.click(screen.getByText("Import"));
+
+ await screen.findByText("That passphrase doesn't match this file.");
+ expect(onImported).not.toHaveBeenCalled();
+ expect(saveDeckCalled).toBe(false);
+});
diff --git a/frontend/src/features/savedDecks/ImportDeckModal.tsx b/frontend/src/features/savedDecks/ImportDeckModal.tsx
new file mode 100644
index 000000000..5045c9eaa
--- /dev/null
+++ b/frontend/src/features/savedDecks/ImportDeckModal.tsx
@@ -0,0 +1,238 @@
+/**
+ * Deck portability's import half (docs/proposals/proposal-g-user-accounts-saved-decks.md,
+ * "PR-6, post-v1: deck portability"): reads a previously-exported bundle (see
+ * deckExportImport.ts), decrypts it client-side using the BUNDLE's own passphrase or recovery
+ * key (never the live session's, since a bundle may come from a different account or a
+ * different, compatible instance entirely), then persists every deck it contains under the
+ * CURRENT signed-in account.
+ *
+ * Conflict rule (spec's own words): always import-as-new. Every imported deck lands as its own
+ * row (`key: null`), never overwriting an existing deck by matching key or name - there's no
+ * server-visible name to match against anyway once titles are encrypted, and overwriting would
+ * risk destroying newer data with a stale export. Each imported deck keeps its OWN
+ * `revision`/`modifiedAt` verbatim (via `encryptFinalizedDeckPayload`, not
+ * `encryptDeckPayloadForSave`) - importing isn't itself "a save", it's a restore, and
+ * overwriting those fields would break the whole point of tracking them (comparing an imported
+ * bundle's revision against what's already saved to tell which copy is newer).
+ *
+ * Assumes the current session is already unlocked (SavedDeckPanel/MyDecksPage are responsible
+ * for that, same convention as SaveDeckModal) - importing into a brand-new account with no
+ * crypto profile yet isn't handled here; the "Import decks" entry point is disabled until then.
+ */
+
+import React, { ChangeEvent, FormEvent, useState } from "react";
+import Button from "react-bootstrap/Button";
+import Form from "react-bootstrap/Form";
+import Modal from "react-bootstrap/Modal";
+
+import {
+ decryptBundleDecks,
+ ExportBundleV1,
+ parseExportBundle,
+ unlockBundleMasterKeyWithPassphrase,
+ unlockBundleMasterKeyWithRecoveryKey,
+} from "@/features/savedDecks/deckExportImport";
+import { encryptFinalizedDeckPayload } from "@/features/savedDecks/deckPayload";
+import { useSaveDeckMutation } from "@/store/api";
+
+interface ImportDeckModalProps {
+ show: boolean;
+ onCancel: () => void;
+ /** Called once every deck in the bundle has been persisted, with the count imported. */
+ onImported: (count: number) => void;
+ /** The CURRENT (already-unlocked) session's master key - every imported deck is re-encrypted
+ * under this key, never the bundle's own. */
+ masterKey: CryptoKey;
+}
+
+type UnlockMode = "passphrase" | "recovery";
+
+export function ImportDeckModal({
+ show,
+ onCancel,
+ onImported,
+ masterKey,
+}: ImportDeckModalProps) {
+ const [saveDeck] = useSaveDeckMutation();
+
+ const [bundle, setBundle] = useState(null);
+ const [fileError, setFileError] = useState(null);
+ const [mode, setMode] = useState("passphrase");
+ const [passphrase, setPassphrase] = useState("");
+ const [recoveryKeyInput, setRecoveryKeyInput] = useState("");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const reset = () => {
+ setBundle(null);
+ setFileError(null);
+ setMode("passphrase");
+ setPassphrase("");
+ setRecoveryKeyInput("");
+ setError(null);
+ setSubmitting(false);
+ };
+
+ const handleFileChange = (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ setBundle(null);
+ setFileError(null);
+ setError(null);
+ if (file == null) {
+ return;
+ }
+ // FileReader rather than Blob.text() - broader runtime support (the latter isn't universally
+ // available, e.g. in this project's own jsdom test environment).
+ const reader = new FileReader();
+ reader.onload = () => {
+ try {
+ setBundle(parseExportBundle(reader.result as string));
+ } catch (thrown) {
+ setFileError(
+ thrown instanceof Error
+ ? thrown.message
+ : "That file isn't a valid saved-deck export."
+ );
+ }
+ };
+ reader.onerror = () =>
+ setFileError("That file isn't a valid saved-deck export.");
+ reader.readAsText(file);
+ };
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ if (bundle == null) {
+ return;
+ }
+ setError(null);
+ setSubmitting(true);
+ const unlockBundle =
+ mode === "passphrase"
+ ? unlockBundleMasterKeyWithPassphrase(bundle, passphrase)
+ : unlockBundleMasterKeyWithRecoveryKey(bundle, recoveryKeyInput.trim());
+ unlockBundle
+ .then((bundleMasterKey) => decryptBundleDecks(bundle, bundleMasterKey))
+ .then((decryptedDecks) =>
+ Promise.all(
+ decryptedDecks.map((decrypted) =>
+ encryptFinalizedDeckPayload(decrypted.payload, masterKey).then(
+ (encrypted) =>
+ saveDeck({
+ key: null,
+ kind: decrypted.kind,
+ ciphertext: encrypted.ciphertext,
+ ciphertextNonce: encrypted.ciphertextNonce,
+ wrappedDek: encrypted.wrappedDek,
+ wrappedDekNonce: encrypted.wrappedDekNonce,
+ }).unwrap()
+ )
+ )
+ )
+ )
+ .then((saved) => {
+ setSubmitting(false);
+ onImported(saved.length);
+ })
+ .catch(() => {
+ setSubmitting(false);
+ setError(
+ mode === "passphrase"
+ ? "That passphrase doesn't match this file."
+ : "That recovery key doesn't match this file."
+ );
+ });
+ };
+
+ return (
+
+
+ Import decks
+
+
+
+ Choose a previously-exported saved-decks file. Every deck it contains
+ is imported as a brand-new deck here - nothing existing is ever
+ overwritten.
+
+
+ Export file
+
+
+ {fileError != null &&
{fileError}
}
+ {bundle != null && (
+ <>
+
+ {bundle.decks.length} deck
+ {bundle.decks.length === 1 ? "" : "s"} found - enter the
+ passphrase (or recovery key) it was exported with to decrypt
+ them.
+
}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/savedDecks/LoadSafetyModal.test.tsx b/frontend/src/features/savedDecks/LoadSafetyModal.test.tsx
index 7cca44ec3..2f28c905f 100644
--- a/frontend/src/features/savedDecks/LoadSafetyModal.test.tsx
+++ b/frontend/src/features/savedDecks/LoadSafetyModal.test.tsx
@@ -10,6 +10,7 @@ import {
useCryptoSession,
} from "@/features/savedDecks/cryptoSession";
import { existingProfileHandler } from "@/features/savedDecks/cryptoTestHandlers";
+import { decryptSavedDeckSummary } from "@/features/savedDecks/deckPayload";
import { LoadSafetyModal } from "@/features/savedDecks/LoadSafetyModal";
import { whoamiSignedInNotModerator } from "@/mocks/handlers";
import { server } from "@/mocks/server";
@@ -18,6 +19,24 @@ import { setupStore } from "@/store/store";
const TEST_ITERATIONS = 100;
const PASSPHRASE = "the real one";
+/** Decrypts a captured saveDeck request body to assert on the PR-6 "Revision tracking" fields
+ * living inside its ciphertext. */
+async function decryptSavedRequest(savedRequest: any, masterKey: CryptoKey) {
+ return decryptSavedDeckSummary(
+ {
+ key: savedRequest.key ?? "unused-in-test",
+ kind: savedRequest.kind,
+ ciphertext: savedRequest.ciphertext,
+ ciphertextNonce: savedRequest.ciphertextNonce,
+ wrappedDek: savedRequest.wrappedDek,
+ wrappedDekNonce: savedRequest.wrappedDekNonce,
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-01",
+ },
+ masterKey
+ );
+}
+
function TestUnlockButton() {
const session = useCryptoSession();
return (
@@ -55,6 +74,7 @@ function renderModal(
currentDeckKey: string | null;
currentDeckName: string | null;
lastSavedSerialized: string | null;
+ lastSavedRevision: number | null;
}
) {
const store = setupStore({
@@ -92,6 +112,7 @@ test("never-saved project: only a single 'save backup and continue' action, no '
currentDeckKey: null,
currentDeckName: null,
lastSavedSerialized: null,
+ lastSavedRevision: null,
});
await unlockSession();
@@ -123,6 +144,7 @@ test("already-saved deck: offers Update-in-place vs Save-as-new-snapshot", async
currentDeckKey: "existing-deck-key",
currentDeckName: "My Existing Deck",
lastSavedSerialized: "stale",
+ lastSavedRevision: 2,
});
await unlockSession();
@@ -135,6 +157,10 @@ test("already-saved deck: offers Update-in-place vs Save-as-new-snapshot", async
expect(requests).toHaveLength(1);
expect(requests[0].key).toBeNull();
expect(requests[0].kind).toEqual("snapshot");
+ // PR-6 "Revision tracking" - a brand-new snapshot row starts at 1, never inheriting the
+ // dirty editor's prior saved row's revision (2, from the preloaded state above).
+ const decrypted = await decryptSavedRequest(requests[0], profile.masterKey);
+ expect(decrypted.payload.revision).toEqual(1);
});
test("already-saved deck: choosing Update sends the existing deck's key, kind deck", async () => {
@@ -153,6 +179,7 @@ test("already-saved deck: choosing Update sends the existing deck's key, kind de
currentDeckKey: "existing-deck-key",
currentDeckName: "My Existing Deck",
lastSavedSerialized: "stale",
+ lastSavedRevision: 2,
});
await unlockSession();
@@ -161,4 +188,7 @@ test("already-saved deck: choosing Update sends the existing deck's key, kind de
await waitFor(() => expect(onSafetyCompleted).toHaveBeenCalledTimes(1));
expect(requests[0].key).toEqual("existing-deck-key");
expect(requests[0].kind).toEqual("deck");
+ // PR-6 "Revision tracking" - updating the SAME row continues its chain (2 -> 3).
+ const decrypted = await decryptSavedRequest(requests[0], profile.masterKey);
+ expect(decrypted.payload.revision).toEqual(3);
});
diff --git a/frontend/src/features/savedDecks/LoadSafetyModal.tsx b/frontend/src/features/savedDecks/LoadSafetyModal.tsx
index 2bbd8f06c..c52cdd0bc 100644
--- a/frontend/src/features/savedDecks/LoadSafetyModal.tsx
+++ b/frontend/src/features/savedDecks/LoadSafetyModal.tsx
@@ -79,14 +79,22 @@ export function LoadSafetyModal({
finishSettings,
cardDocuments
);
- encryptDeckPayloadForSave(payload, session.masterKey)
+ // "Save as new snapshot" always starts a brand-new row (PR-6 "Revision tracking" - a new
+ // row's revision chain never inherits from the dirty editor's prior saved row).
+ const previousRevision = asUpdate
+ ? currentSavedDeck.lastSavedRevision
+ : null;
+ encryptDeckPayloadForSave(payload, session.masterKey, previousRevision)
.then((encrypted) =>
saveDeck({
key: asUpdate ? currentSavedDeck.currentDeckKey : null,
kind: asUpdate
? LoadDeckResponseKind.Deck
: LoadDeckResponseKind.Snapshot,
- ...encrypted,
+ ciphertext: encrypted.ciphertext,
+ ciphertextNonce: encrypted.ciphertextNonce,
+ wrappedDek: encrypted.wrappedDek,
+ wrappedDekNonce: encrypted.wrappedDekNonce,
}).unwrap()
)
.then(() => {
diff --git a/frontend/src/features/savedDecks/MyDecksPage.test.tsx b/frontend/src/features/savedDecks/MyDecksPage.test.tsx
index c4d5e1fbf..0dd4d722d 100644
--- a/frontend/src/features/savedDecks/MyDecksPage.test.tsx
+++ b/frontend/src/features/savedDecks/MyDecksPage.test.tsx
@@ -148,6 +148,9 @@ test("opening a deck loads it into the project store and navigates to the editor
expect(selectCurrentSavedDeck(store.getState())).toMatchObject({
currentDeckKey: "deck-42",
currentDeckName: "Control Deck",
+ // v1 legacy payload (emptyDeckPayload's `version: 1`) upgraded on load - PR-6 "Revision
+ // tracking" backfills revision 0 for a pre-existing row that never tracked one.
+ lastSavedRevision: 0,
});
});
@@ -210,3 +213,110 @@ test("resetting saved decks requires a second confirming click before calling th
fireEvent.click(screen.getByTestId("reset-saved-decks"));
await waitFor(() => expect(resetBody).toEqual({ confirm: true }));
});
+
+test("PR-6 deck portability: Export my decks is disabled with zero decks, enabled once decks exist, and triggers a download", async () => {
+ const profile = await createCryptoProfile("the real one", TEST_ITERATIONS);
+ server.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(profile),
+ getSavedDecksHandler([])
+ );
+ renderPage();
+
+ await screen.findByTestId("unlock-modal");
+ fireEvent.change(screen.getByLabelText("unlock-passphrase"), {
+ target: { value: "the real one" },
+ });
+ fireEvent.click(screen.getByText("Unlock"));
+
+ const exportButton = await screen.findByTestId("export-my-decks");
+ expect(exportButton).toBeDisabled();
+});
+
+test("PR-6 deck portability: Export my decks works while STILL LOCKED - the spec's own headline scenario (a user who's forgotten their passphrase can still export)", async () => {
+ const profile = await createCryptoProfile("the real one", TEST_ITERATIONS);
+ const namedDeck = await buildMockSavedDeckSummary(
+ "deck-1",
+ "deck",
+ emptyDeckPayload("Standard Aggro"),
+ profile.masterKey,
+ { createdAt: "2026-01-01", updatedAt: "2026-01-02" }
+ );
+ server.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(profile),
+ getSavedDecksHandler([namedDeck])
+ );
+ renderPage();
+
+ // Deliberately never unlocks - the unlock modal auto-shows (locked state), but Export must
+ // still work without it, per the spec's own explicit requirement.
+ await screen.findByTestId("unlock-modal");
+
+ const exportButton = await screen.findByTestId("export-my-decks");
+ await waitFor(() => expect(exportButton).not.toBeDisabled());
+
+ const clickSpy = jest
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => undefined);
+ fireEvent.click(exportButton);
+
+ await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1));
+ clickSpy.mockRestore();
+});
+
+test("PR-6 deck portability: Export my decks downloads a bundle once decks exist", async () => {
+ const profile = await createCryptoProfile("the real one", TEST_ITERATIONS);
+ const namedDeck = await buildMockSavedDeckSummary(
+ "deck-1",
+ "deck",
+ emptyDeckPayload("Standard Aggro"),
+ profile.masterKey,
+ { createdAt: "2026-01-01", updatedAt: "2026-01-02" }
+ );
+ server.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(profile),
+ getSavedDecksHandler([namedDeck])
+ );
+ renderPage();
+
+ await screen.findByTestId("unlock-modal");
+ fireEvent.change(screen.getByLabelText("unlock-passphrase"), {
+ target: { value: "the real one" },
+ });
+ fireEvent.click(screen.getByText("Unlock"));
+
+ const exportButton = await screen.findByTestId("export-my-decks");
+ await waitFor(() => expect(exportButton).not.toBeDisabled());
+
+ const clickSpy = jest
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => undefined);
+ fireEvent.click(exportButton);
+
+ await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1));
+ clickSpy.mockRestore();
+});
+
+test("PR-6 deck portability: Import decks is disabled while locked, enabled once unlocked", async () => {
+ const profile = await createCryptoProfile("the real one", TEST_ITERATIONS);
+ server.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(profile),
+ getSavedDecksHandler([])
+ );
+ renderPage();
+
+ await screen.findByTestId("unlock-modal");
+ expect(screen.getByTestId("open-import-decks")).toBeDisabled();
+
+ fireEvent.change(screen.getByLabelText("unlock-passphrase"), {
+ target: { value: "the real one" },
+ });
+ fireEvent.click(screen.getByText("Unlock"));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("open-import-decks")).not.toBeDisabled()
+ );
+});
diff --git a/frontend/src/features/savedDecks/MyDecksPage.tsx b/frontend/src/features/savedDecks/MyDecksPage.tsx
index ea6360332..bac6d5cc1 100644
--- a/frontend/src/features/savedDecks/MyDecksPage.tsx
+++ b/frontend/src/features/savedDecks/MyDecksPage.tsx
@@ -16,17 +16,24 @@ import { useAppDispatch, useAppSelector } from "@/common/types";
import { RightPaddedIcon } from "@/components/icon";
import { useCryptoSession } from "@/features/savedDecks/cryptoSession";
import {
+ buildExportBundle,
+ downloadExportBundle,
+} from "@/features/savedDecks/deckExportImport";
+import {
+ deckContentForComparison,
DecryptedSavedDeck,
decryptSavedDeckSummary,
projectFromDeckPayload,
serializeDeckPayload,
} from "@/features/savedDecks/deckPayload";
+import { ImportDeckModal } from "@/features/savedDecks/ImportDeckModal";
import { LoadSafetyModal } from "@/features/savedDecks/LoadSafetyModal";
import { selectIsCurrentProjectDirty } from "@/features/savedDecks/selectors";
import { ShareDeckModal } from "@/features/savedDecks/ShareDeckModal";
import { UnlockModal } from "@/features/savedDecks/UnlockModal";
import {
useDeleteDeckMutation,
+ useGetCryptoProfileQuery,
useGetSavedDecksQuery,
useGetWhoamiQuery,
useResetSavedDecksMutation,
@@ -96,6 +103,11 @@ export function MyDecksPage() {
const shouldFetchDecks =
session.status === "locked" || session.status === "unlocked";
const savedDecksQuery = useGetSavedDecksQuery({ skip: !shouldFetchDecks });
+ // Export requires no unlock (docs/proposals/.../PR-6's own explicit requirement) - fetched
+ // independently of the crypto session's unlocked state, same gate as savedDecksQuery above.
+ const cryptoProfileQuery = useGetCryptoProfileQuery({
+ skip: !shouldFetchDecks,
+ });
const [deleteDeck] = useDeleteDeckMutation();
const [resetSavedDecks] = useResetSavedDecksMutation();
@@ -109,6 +121,9 @@ export function MyDecksPage() {
const [confirmingReset, setConfirmingReset] = useState(false);
const [pendingLoadDeck, setPendingLoadDeck] =
useState(null);
+ const [showImport, setShowImport] = useState(false);
+ const [exportError, setExportError] = useState(null);
+ const [importMessage, setImportMessage] = useState(null);
const [sharingDeck, setSharingDeck] = useState(
null
);
@@ -176,12 +191,34 @@ export function MyDecksPage() {
setCurrentSavedDeck({
key: deck.key,
name,
- serialized: serializeDeckPayload(deck.payload),
+ // Content-only (deckPayload.ts's `deckContentForComparison`) - matches the shape
+ // `buildDeckPayload` produces, since the dirty-check compares the two directly. The
+ // full payload's `revision`/`modifiedAt` (PR-6) live separately, in `lastSavedRevision`.
+ serialized: serializeDeckPayload(
+ deckContentForComparison(deck.payload)
+ ),
+ revision: deck.payload.revision,
})
);
router.push("/editor");
};
+ const handleExport = () => {
+ if (savedDecksQuery.data == null || cryptoProfileQuery.data == null) {
+ return;
+ }
+ setExportError(null);
+ try {
+ const bundle = buildExportBundle(
+ cryptoProfileQuery.data,
+ savedDecksQuery.data.decks
+ );
+ downloadExportBundle(bundle);
+ } catch (thrown) {
+ setExportError(thrown instanceof Error ? thrown.message : String(thrown));
+ }
+ };
+
// Loss-proof by construction (frontend spec §4): an empty or clean editor loads immediately,
// but a dirty one always gets a safety copy saved first - never silently discarded, and never
// skippable for a logged-in user (this page requires being logged in to reach at all).
@@ -265,6 +302,20 @@ export function MyDecksPage() {
}
}}
/>
+ {session.masterKey != null && (
+ setShowImport(false)}
+ onImported={(count) => {
+ setShowImport(false);
+ setImportMessage(
+ `Imported ${count} deck${count === 1 ? "" : "s"} as new.`
+ );
+ savedDecksQuery.refetch();
+ }}
+ masterKey={session.masterKey}
+ />
+ )}
{sharingDeck != null && sharingDeckSummary != null && (
)}
+ {shouldFetchDecks && (
+
+ {/* Export requires no unlock (docs/proposals/.../PR-6) - it's the same opaque bytes
+ the server already holds, so it works whether or not this session has ever unlocked. */}
+
+ {/* Import needs somewhere to PERSIST the decrypted decks to, so it needs THIS
+ session's own master key unlocked - unlike export. */}
+
+
+ )}
+ {exportError != null &&
{exportError}
}
+ {importMessage != null &&
{importMessage}
}
{decrypting && }
{decryptError != null &&
{decryptError}
}
{session.status === "unlocked" && !decrypting && (
diff --git a/frontend/src/features/savedDecks/SaveDeckModal.test.tsx b/frontend/src/features/savedDecks/SaveDeckModal.test.tsx
index 4fb170d28..1ac7f3d64 100644
--- a/frontend/src/features/savedDecks/SaveDeckModal.test.tsx
+++ b/frontend/src/features/savedDecks/SaveDeckModal.test.tsx
@@ -4,19 +4,38 @@ import React from "react";
import { Provider } from "react-redux";
import { createCryptoProfile } from "@/common/savedDeckCrypto";
-import { SourceType } from "@/common/schema_types";
+import { LoadDeckResponseKind, SourceType } from "@/common/schema_types";
import { localBackend, projectSelectedImage1 } from "@/common/test-constants";
import {
CryptoSessionProvider,
useCryptoSession,
} from "@/features/savedDecks/cryptoSession";
import { existingProfileHandler } from "@/features/savedDecks/cryptoTestHandlers";
+import { decryptSavedDeckSummary } from "@/features/savedDecks/deckPayload";
import { SaveDeckModal } from "@/features/savedDecks/SaveDeckModal";
import { whoamiSignedInNotModerator } from "@/mocks/handlers";
import { server } from "@/mocks/server";
import { selectCurrentSavedDeck } from "@/store/slices/savedDeckSessionSlice";
import { setupStore } from "@/store/store";
+/** Decrypts a captured saveDeck request body the same way the server-stored ciphertext would be
+ * decrypted on load, to assert on the PR-6 "Revision tracking" fields living inside it. */
+async function decryptSavedRequest(savedRequest: any, masterKey: CryptoKey) {
+ return decryptSavedDeckSummary(
+ {
+ key: savedRequest.key ?? "unused-in-test",
+ kind: savedRequest.kind ?? LoadDeckResponseKind.Deck,
+ ciphertext: savedRequest.ciphertext,
+ ciphertextNonce: savedRequest.ciphertextNonce,
+ wrappedDek: savedRequest.wrappedDek,
+ wrappedDekNonce: savedRequest.wrappedDekNonce,
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-01",
+ },
+ masterKey
+ );
+}
+
const TEST_ITERATIONS = 100;
const PASSPHRASE = "the real one";
@@ -96,7 +115,12 @@ test("saving a brand-new deck (no prior key) records the key the server returns"
expect(selectCurrentSavedDeck(store.getState())).toMatchObject({
currentDeckKey: "new-deck-key",
currentDeckName: "My New Deck",
+ lastSavedRevision: 1,
});
+ // PR-6 "Revision tracking" - a brand-new row always starts at revision 1.
+ const decrypted = await decryptSavedRequest(savedRequest, profile.masterKey);
+ expect(decrypted.payload.revision).toEqual(1);
+ expect(decrypted.payload.modifiedAt).toBeTruthy();
});
test("saving over an already-loaded deck sends its existing key (an update, not a new deck)", async () => {
@@ -117,6 +141,7 @@ test("saving over an already-loaded deck sends its existing key (an update, not
currentDeckKey: "existing-key",
currentDeckName: "Existing Deck",
lastSavedSerialized: null,
+ lastSavedRevision: 3,
},
});
render(
@@ -135,6 +160,10 @@ test("saving over an already-loaded deck sends its existing key (an update, not
await waitFor(() => expect(savedRequest).not.toBeNull());
expect(savedRequest.key).toEqual("existing-key");
+ // PR-6 "Revision tracking" - continuing the SAME row's chain increments from its last known
+ // revision (3, from the preloaded savedDeckSession state above), never restarting at 1.
+ const decrypted = await decryptSavedRequest(savedRequest, profile.masterKey);
+ expect(decrypted.payload.revision).toEqual(4);
});
test("warns about local-file-sourced cards that won't restore on another device", async () => {
diff --git a/frontend/src/features/savedDecks/SaveDeckModal.tsx b/frontend/src/features/savedDecks/SaveDeckModal.tsx
index 65b8adb24..6270e7c3d 100644
--- a/frontend/src/features/savedDecks/SaveDeckModal.tsx
+++ b/frontend/src/features/savedDecks/SaveDeckModal.tsx
@@ -78,20 +78,32 @@ export function SaveDeckModal({ show, onCancel, onSaved }: SaveDeckModalProps) {
finishSettings,
cardDocuments
);
- encryptDeckPayloadForSave(payload, session.masterKey)
+ // Only an update to the SAME already-saved row continues its revision chain (PR-6
+ // "Revision tracking") - a brand-new row (no currentDeckKey yet) always starts at 1.
+ const previousRevision =
+ currentSavedDeck.currentDeckKey != null
+ ? currentSavedDeck.lastSavedRevision
+ : null;
+ encryptDeckPayloadForSave(payload, session.masterKey, previousRevision)
.then((encrypted) =>
saveDeck({
key: currentSavedDeck.currentDeckKey,
kind: LoadDeckResponseKind.Deck,
- ...encrypted,
- }).unwrap()
+ ciphertext: encrypted.ciphertext,
+ ciphertextNonce: encrypted.ciphertextNonce,
+ wrappedDek: encrypted.wrappedDek,
+ wrappedDekNonce: encrypted.wrappedDekNonce,
+ })
+ .unwrap()
+ .then((response) => ({ response, revision: encrypted.revision }))
)
- .then((response) => {
+ .then(({ response, revision }) => {
dispatch(
setCurrentSavedDeck({
key: response.key,
name: finalName,
serialized: serializeDeckPayload(payload),
+ revision,
})
);
onSaved();
diff --git a/frontend/src/features/savedDecks/SavedDeckPanel.test.tsx b/frontend/src/features/savedDecks/SavedDeckPanel.test.tsx
index 28a6dfc02..100a1821f 100644
--- a/frontend/src/features/savedDecks/SavedDeckPanel.test.tsx
+++ b/frontend/src/features/savedDecks/SavedDeckPanel.test.tsx
@@ -51,6 +51,7 @@ test("authenticated, a saved deck is loaded: shows the reverse breadcrumb", asyn
currentDeckKey: "some-key",
currentDeckName: "My Deck",
lastSavedSerialized: null,
+ lastSavedRevision: null,
},
});
diff --git a/frontend/src/features/savedDecks/SharedDeckViewer.tsx b/frontend/src/features/savedDecks/SharedDeckViewer.tsx
index 3b004fe22..cff01594f 100644
--- a/frontend/src/features/savedDecks/SharedDeckViewer.tsx
+++ b/frontend/src/features/savedDecks/SharedDeckViewer.tsx
@@ -13,7 +13,7 @@ import { getWorkerImageURL } from "@/common/image";
import { CardDocument, CardDocuments } from "@/common/types";
import {
DeckPayloadMemberFace,
- DeckPayloadV1,
+ DeckPayloadV2,
} from "@/features/savedDecks/deckPayload";
import { APIGetCards } from "@/store/api";
@@ -21,7 +21,9 @@ interface SharedDeckViewerProps {
backendURL: string;
name: string;
sharedAt: string;
- payload: DeckPayloadV1;
+ // decryptSharedDeck (deckShare.ts) always upgrades to the latest payload shape (v2) before
+ // handing it back - a recipient never sees a raw, un-upgraded v1 payload.
+ payload: DeckPayloadV2;
}
function SlotFace({
diff --git a/frontend/src/features/savedDecks/cryptoTestHandlers.ts b/frontend/src/features/savedDecks/cryptoTestHandlers.ts
index 21018b53d..0d2033318 100644
--- a/frontend/src/features/savedDecks/cryptoTestHandlers.ts
+++ b/frontend/src/features/savedDecks/cryptoTestHandlers.ts
@@ -11,8 +11,8 @@ import { bytesToBase64 } from "@/common/savedDeckCrypto";
import { LoadDeckResponseKind, SavedDeckSummary } from "@/common/schema_types";
import { localBackendURL } from "@/common/test-constants";
import {
- DeckPayloadV1,
- encryptDeckPayloadForSave,
+ DeckPayload,
+ encryptFinalizedDeckPayload,
} from "@/features/savedDecks/deckPayload";
function buildRoute(path: string): string {
@@ -104,11 +104,11 @@ export function resetSavedDecksHandler(onReset: (body: any) => void) {
export async function buildMockSavedDeckSummary(
key: string,
kind: "deck" | "snapshot",
- payload: DeckPayloadV1,
+ payload: DeckPayload,
masterKey: CryptoKey,
timestamps: { createdAt: string; updatedAt: string }
): Promise {
- const encrypted = await encryptDeckPayloadForSave(payload, masterKey);
+ const encrypted = await encryptFinalizedDeckPayload(payload, masterKey);
return {
key,
kind:
diff --git a/frontend/src/features/savedDecks/deckExportImport.test.ts b/frontend/src/features/savedDecks/deckExportImport.test.ts
new file mode 100644
index 000000000..f8bf3b305
--- /dev/null
+++ b/frontend/src/features/savedDecks/deckExportImport.test.ts
@@ -0,0 +1,131 @@
+/**
+ * "PR-6, post-v1: deck portability" (docs/proposals/proposal-g-user-accounts-saved-decks.md) -
+ * export/import bundle round-trip, both unlock paths (passphrase and recovery key), and the
+ * format-version guard that keeps a future incompatible bundle from being silently misread.
+ */
+
+import { createCryptoProfile } from "@/common/savedDeckCrypto";
+import { bytesToBase64 } from "@/common/savedDeckCrypto";
+import { CryptoProfileResponse } from "@/common/schema_types";
+import { buildMockSavedDeckSummary } from "@/features/savedDecks/cryptoTestHandlers";
+import {
+ buildExportBundle,
+ decryptBundleDecks,
+ EXPORT_FORMAT_VERSION,
+ parseExportBundle,
+ serializeExportBundle,
+ unlockBundleMasterKeyWithPassphrase,
+ unlockBundleMasterKeyWithRecoveryKey,
+} from "@/features/savedDecks/deckExportImport";
+
+const TEST_ITERATIONS = 100;
+const PASSPHRASE = "the real one";
+
+function cryptoProfileResponse(profile: {
+ salt: Uint8Array;
+ iterations: number;
+ passphraseWrapped: {
+ wrapped: Uint8Array;
+ nonce: Uint8Array;
+ };
+ recoveryWrapped: {
+ wrapped: Uint8Array;
+ nonce: Uint8Array;
+ };
+}): CryptoProfileResponse {
+ return {
+ exists: true,
+ salt: bytesToBase64(profile.salt),
+ kdfIterations: profile.iterations,
+ passphraseWrappedMasterKey: bytesToBase64(
+ profile.passphraseWrapped.wrapped
+ ),
+ passphraseWrappedMasterKeyNonce: bytesToBase64(
+ profile.passphraseWrapped.nonce
+ ),
+ recoveryWrappedMasterKey: bytesToBase64(profile.recoveryWrapped.wrapped),
+ recoveryWrappedMasterKeyNonce: bytesToBase64(profile.recoveryWrapped.nonce),
+ };
+}
+
+test("buildExportBundle refuses to export without a real crypto profile", () => {
+ expect(() =>
+ buildExportBundle(
+ {
+ exists: false,
+ salt: null,
+ kdfIterations: null,
+ passphraseWrappedMasterKey: null,
+ passphraseWrappedMasterKeyNonce: null,
+ recoveryWrappedMasterKey: null,
+ recoveryWrappedMasterKeyNonce: null,
+ },
+ []
+ )
+ ).toThrow(/No saved-deck crypto profile/);
+});
+
+test("parseExportBundle rejects an unsupported formatVersion and malformed bundles", () => {
+ expect(() =>
+ parseExportBundle(JSON.stringify({ formatVersion: 99, decks: [] }))
+ ).toThrow(/Unsupported saved-deck export format version: 99/);
+ expect(() =>
+ parseExportBundle(JSON.stringify({ formatVersion: EXPORT_FORMAT_VERSION }))
+ ).toThrow(/Malformed/);
+});
+
+test("export -> serialize -> parse -> unlock (passphrase) -> decrypt round-trips every deck, including its revision", async () => {
+ const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS);
+ const deck = await buildMockSavedDeckSummary(
+ "deck-1",
+ "deck",
+ {
+ version: 2,
+ name: "Commander Deck",
+ members: [],
+ cardback: null,
+ manualOverrides: {},
+ finishSettings: { cardstock: "(S30) Standard Smooth", foil: false },
+ revision: 3,
+ modifiedAt: "2026-01-01T00:00:00.000Z",
+ },
+ profile.masterKey,
+ { createdAt: "2026-01-01", updatedAt: "2026-01-02" }
+ );
+
+ const bundle = buildExportBundle(cryptoProfileResponse(profile), [deck]);
+ expect(bundle.formatVersion).toEqual(EXPORT_FORMAT_VERSION);
+
+ const reparsed = parseExportBundle(serializeExportBundle(bundle));
+ expect(reparsed.decks).toHaveLength(1);
+
+ const bundleMasterKey = await unlockBundleMasterKeyWithPassphrase(
+ reparsed,
+ PASSPHRASE
+ );
+ const decrypted = await decryptBundleDecks(reparsed, bundleMasterKey);
+ expect(decrypted).toHaveLength(1);
+ expect(decrypted[0].name).toEqual("Commander Deck");
+ expect(decrypted[0].payload.revision).toEqual(3);
+ expect(decrypted[0].payload.modifiedAt).toEqual("2026-01-01T00:00:00.000Z");
+});
+
+test("unlockBundleMasterKeyWithRecoveryKey also unwraps the bundle's master key", async () => {
+ const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS);
+ const bundle = buildExportBundle(cryptoProfileResponse(profile), []);
+
+ const bundleMasterKey = await unlockBundleMasterKeyWithRecoveryKey(
+ bundle,
+ bytesToBase64(profile.recoveryKeyBytes)
+ );
+ expect(bundleMasterKey).toBeDefined();
+});
+
+test("a wrong passphrase fails to unlock the bundle's master key", async () => {
+ const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS);
+ const bundle = buildExportBundle(cryptoProfileResponse(profile), []);
+
+ await expect(
+ unlockBundleMasterKeyWithPassphrase(bundle, "wrong passphrase")
+ ).rejects.toThrow();
+});
diff --git a/frontend/src/features/savedDecks/deckExportImport.ts b/frontend/src/features/savedDecks/deckExportImport.ts
new file mode 100644
index 000000000..2002ad415
--- /dev/null
+++ b/frontend/src/features/savedDecks/deckExportImport.ts
@@ -0,0 +1,182 @@
+/**
+ * Deck portability (docs/proposals/proposal-g-user-accounts-saved-decks.md, "PR-6, post-v1:
+ * deck portability"): export/import of the complete encrypted bundle a saved-decks account
+ * holds. Every field in the bundle is exactly the same opaque, already-encrypted bytes the
+ * server itself stores - `buildExportBundle` never sees deck contents in plaintext, and export
+ * requires no unlock (see MyDecksPage's "Export my decks" wiring). Import DOES need a
+ * passphrase or recovery key, but it's the BUNDLE's own (via `unlockBundleMasterKeyWith*`
+ * below) - not necessarily the live session's - since a bundle may be re-imported on a
+ * different account or a different (compatible) instance entirely.
+ *
+ * `EXPORT_FORMAT_VERSION` is this bundle's own PUBLIC wire-format version (starts at 1) -
+ * distinct from deckPayload.ts's PRIVATE, per-deck `version` field, which lives inside each
+ * deck's encrypted ciphertext and is never visible here. This format is documented publicly
+ * (this file, plus docs/features/saved-decks.md) specifically so a fork, or a completely
+ * independent reimplementation, could read an exported bundle without this codebase at all -
+ * see the standalone decrypt tool at `tools/decrypt-saved-deck-export/`, the trust anchor for
+ * that claim.
+ */
+
+import {
+ base64ToBytes,
+ unlockWithPassphrase,
+ unlockWithRecoveryKey,
+ WrappedKey,
+} from "@/common/savedDeckCrypto";
+import { CryptoProfileResponse, SavedDeckSummary } from "@/common/schema_types";
+import {
+ DecryptedSavedDeck,
+ decryptSavedDeckSummary,
+} from "@/features/savedDecks/deckPayload";
+
+export const EXPORT_FORMAT_VERSION = 1;
+
+export interface ExportBundleCryptoProfile {
+ salt: string;
+ kdfIterations: number;
+ passphraseWrappedMasterKey: string;
+ passphraseWrappedMasterKeyNonce: string;
+ recoveryWrappedMasterKey: string;
+ recoveryWrappedMasterKeyNonce: string;
+}
+
+/** Identical wire shape to `SavedDeckSummary` - literally the same opaque bytes the server
+ * already holds for this row, just also written into the exported file. */
+export type ExportBundleDeck = SavedDeckSummary;
+
+export interface ExportBundleV1 {
+ formatVersion: 1;
+ exportedAt: string;
+ cryptoProfile: ExportBundleCryptoProfile;
+ decks: Array;
+}
+
+/**
+ * Builds the export bundle. Requires NO unlock (docs/proposals/.../PR-6's own explicit
+ * requirement) - a user who's forgotten their passphrase can still export, since this is just
+ * the same opaque bytes the server already stores, reshaped into one file.
+ */
+export function buildExportBundle(
+ cryptoProfile: CryptoProfileResponse,
+ decks: Array
+): ExportBundleV1 {
+ if (
+ !cryptoProfile.exists ||
+ cryptoProfile.salt == null ||
+ cryptoProfile.kdfIterations == null ||
+ cryptoProfile.passphraseWrappedMasterKey == null ||
+ cryptoProfile.passphraseWrappedMasterKeyNonce == null ||
+ cryptoProfile.recoveryWrappedMasterKey == null ||
+ cryptoProfile.recoveryWrappedMasterKeyNonce == null
+ ) {
+ throw new Error(
+ "No saved-deck crypto profile to export yet - save a deck first."
+ );
+ }
+ return {
+ formatVersion: EXPORT_FORMAT_VERSION,
+ exportedAt: new Date().toISOString(),
+ cryptoProfile: {
+ salt: cryptoProfile.salt,
+ kdfIterations: cryptoProfile.kdfIterations,
+ passphraseWrappedMasterKey: cryptoProfile.passphraseWrappedMasterKey,
+ passphraseWrappedMasterKeyNonce:
+ cryptoProfile.passphraseWrappedMasterKeyNonce,
+ recoveryWrappedMasterKey: cryptoProfile.recoveryWrappedMasterKey,
+ recoveryWrappedMasterKeyNonce:
+ cryptoProfile.recoveryWrappedMasterKeyNonce,
+ },
+ decks: decks.map((deck) => ({
+ key: deck.key,
+ kind: deck.kind,
+ ciphertext: deck.ciphertext,
+ ciphertextNonce: deck.ciphertextNonce,
+ wrappedDek: deck.wrappedDek,
+ wrappedDekNonce: deck.wrappedDekNonce,
+ createdAt: deck.createdAt,
+ updatedAt: deck.updatedAt,
+ })),
+ };
+}
+
+export function serializeExportBundle(bundle: ExportBundleV1): string {
+ return JSON.stringify(bundle, null, 2);
+}
+
+export function parseExportBundle(serialized: string): ExportBundleV1 {
+ const parsed = JSON.parse(serialized);
+ if (parsed?.formatVersion !== EXPORT_FORMAT_VERSION) {
+ throw new Error(
+ `Unsupported saved-deck export format version: ${parsed?.formatVersion}`
+ );
+ }
+ if (!Array.isArray(parsed.decks) || parsed.cryptoProfile == null) {
+ throw new Error("Malformed saved-deck export file.");
+ }
+ return parsed as ExportBundleV1;
+}
+
+/** Browser-only: triggers a download of the bundle as a timestamped `.json` file. */
+export function downloadExportBundle(bundle: ExportBundleV1): void {
+ const blob = new Blob([serializeExportBundle(bundle)], {
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ try {
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `proxyprints-saved-decks-${bundle.exportedAt.slice(
+ 0,
+ 10
+ )}.json`;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+/** Unwraps the BUNDLE's own master key using its own passphrase - deliberately independent of
+ * whatever crypto profile the live session (if any) already has, since a bundle can be
+ * re-imported on a different account or instance entirely. Throws on a wrong passphrase (AES-GCM
+ * authentication failure), same as the live unlock path. */
+export async function unlockBundleMasterKeyWithPassphrase(
+ bundle: ExportBundleV1,
+ passphrase: string
+): Promise {
+ const wrapped: WrappedKey = {
+ wrapped: base64ToBytes(bundle.cryptoProfile.passphraseWrappedMasterKey),
+ nonce: base64ToBytes(bundle.cryptoProfile.passphraseWrappedMasterKeyNonce),
+ };
+ return unlockWithPassphrase(
+ passphrase,
+ base64ToBytes(bundle.cryptoProfile.salt),
+ bundle.cryptoProfile.kdfIterations,
+ wrapped
+ );
+}
+
+/** As above, via the bundle's own recovery key instead of its passphrase. */
+export async function unlockBundleMasterKeyWithRecoveryKey(
+ bundle: ExportBundleV1,
+ recoveryKeyBase64: string
+): Promise {
+ const wrapped: WrappedKey = {
+ wrapped: base64ToBytes(bundle.cryptoProfile.recoveryWrappedMasterKey),
+ nonce: base64ToBytes(bundle.cryptoProfile.recoveryWrappedMasterKeyNonce),
+ };
+ return unlockWithRecoveryKey(base64ToBytes(recoveryKeyBase64), wrapped);
+}
+
+/** Decrypts every deck in a bundle using the bundle's own (already-unwrapped) master key. A
+ * failure on any single entry aborts the whole import rather than silently skipping a row -
+ * partial imports would be a confusing, hard-to-notice way to lose data. */
+export async function decryptBundleDecks(
+ bundle: ExportBundleV1,
+ bundleMasterKey: CryptoKey
+): Promise> {
+ return Promise.all(
+ bundle.decks.map((deck) => decryptSavedDeckSummary(deck, bundleMasterKey))
+ );
+}
diff --git a/frontend/src/features/savedDecks/deckPayload.test.ts b/frontend/src/features/savedDecks/deckPayload.test.ts
new file mode 100644
index 000000000..ba5ac071e
--- /dev/null
+++ b/frontend/src/features/savedDecks/deckPayload.test.ts
@@ -0,0 +1,179 @@
+/**
+ * PR-6 "Revision tracking" (docs/proposals/proposal-g-user-accounts-saved-decks.md) coverage:
+ * the v1 -> v2 upgrade path, the content/revision split that keeps the dirty-check baseline
+ * stable, and the two encrypt entry points (`encryptDeckPayloadForSave` bumps revision,
+ * `encryptFinalizedDeckPayload` preserves it verbatim - the one import needs).
+ */
+
+import { createCryptoProfile } from "@/common/savedDeckCrypto";
+import { LoadDeckResponseKind } from "@/common/schema_types";
+import { FinishSettingsState, Project } from "@/common/types";
+import {
+ buildDeckPayload,
+ countDeviceLocalSlots,
+ DECK_PAYLOAD_VERSION,
+ deckContentForComparison,
+ DeckPayloadV1,
+ decryptSavedDeckSummary,
+ encryptDeckPayloadForSave,
+ encryptFinalizedDeckPayload,
+ parseDeckPayload,
+ serializeDeckPayload,
+} from "@/features/savedDecks/deckPayload";
+
+const TEST_ITERATIONS = 100;
+
+const emptyFinishSettings: FinishSettingsState = {
+ cardstock: "(S30) Standard Smooth",
+ foil: false,
+};
+
+const emptyProject: Project = {
+ members: [],
+ nextMemberId: 0,
+ cardback: null,
+ mostRecentlySelectedSlot: null,
+ manualOverrides: {},
+};
+
+function v1Payload(name: string): DeckPayloadV1 {
+ return {
+ version: 1,
+ name,
+ members: [],
+ cardback: null,
+ manualOverrides: {},
+ finishSettings: emptyFinishSettings,
+ };
+}
+
+test("buildDeckPayload's output never carries version/revision/modifiedAt - only content", () => {
+ const content = buildDeckPayload(
+ "My Deck",
+ emptyProject,
+ emptyFinishSettings,
+ {}
+ );
+ expect(content).not.toHaveProperty("version");
+ expect(content).not.toHaveProperty("revision");
+ expect(content).not.toHaveProperty("modifiedAt");
+ expect(countDeviceLocalSlots(content)).toEqual(0);
+});
+
+test("buildDeckPayload is deterministic across calls with identical inputs - the dirty-check's own invariant", () => {
+ const first = serializeDeckPayload(
+ buildDeckPayload("My Deck", emptyProject, emptyFinishSettings, {})
+ );
+ const second = serializeDeckPayload(
+ buildDeckPayload("My Deck", emptyProject, emptyFinishSettings, {})
+ );
+ expect(first).toEqual(second);
+});
+
+test("parseDeckPayload upgrades a legacy v1 payload to v2, backfilling revision 0 and a fallback modifiedAt", () => {
+ const serialized = JSON.stringify(v1Payload("Legacy Deck"));
+ const upgraded = parseDeckPayload(serialized, "2026-01-02T00:00:00.000Z");
+ expect(upgraded.version).toEqual(2);
+ expect(upgraded.revision).toEqual(0);
+ expect(upgraded.modifiedAt).toEqual("2026-01-02T00:00:00.000Z");
+ expect(upgraded.name).toEqual("Legacy Deck");
+});
+
+test("parseDeckPayload reads a v2 payload as-is, and rejects an unrecognised version", () => {
+ const v2 = {
+ version: 2,
+ name: "Modern Deck",
+ members: [],
+ cardback: null,
+ manualOverrides: {},
+ finishSettings: emptyFinishSettings,
+ revision: 5,
+ modifiedAt: "2026-01-01T00:00:00.000Z",
+ };
+ expect(parseDeckPayload(JSON.stringify(v2))).toEqual(v2);
+ expect(() => parseDeckPayload(JSON.stringify({ version: 99 }))).toThrow(
+ "Unsupported saved deck payload version: 99"
+ );
+});
+
+test("deckContentForComparison strips version/revision/modifiedAt so a loaded deck's baseline matches a freshly-rebuilt draft", () => {
+ const content = buildDeckPayload(
+ "Round Trip",
+ emptyProject,
+ emptyFinishSettings,
+ {}
+ );
+ const finalized = {
+ ...content,
+ version: DECK_PAYLOAD_VERSION as 2,
+ revision: 7,
+ modifiedAt: "2026-01-01T00:00:00.000Z",
+ };
+ expect(serializeDeckPayload(deckContentForComparison(finalized))).toEqual(
+ serializeDeckPayload(content)
+ );
+});
+
+test("encryptDeckPayloadForSave bumps revision from previousRevision, and stamps a fresh modifiedAt", async () => {
+ const { masterKey } = await createCryptoProfile(
+ "passphrase",
+ TEST_ITERATIONS
+ );
+ const content = buildDeckPayload(
+ "Bumped",
+ emptyProject,
+ emptyFinishSettings,
+ {}
+ );
+ const freshRow = await encryptDeckPayloadForSave(content, masterKey, null);
+ expect(freshRow.revision).toEqual(1);
+
+ const continuedRow = await encryptDeckPayloadForSave(
+ content,
+ masterKey,
+ freshRow.revision
+ );
+ expect(continuedRow.revision).toEqual(2);
+
+ const decrypted = await decryptSavedDeckSummary(
+ {
+ key: "k",
+ kind: LoadDeckResponseKind.Deck,
+ ciphertext: continuedRow.ciphertext,
+ ciphertextNonce: continuedRow.ciphertextNonce,
+ wrappedDek: continuedRow.wrappedDek,
+ wrappedDekNonce: continuedRow.wrappedDekNonce,
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-01",
+ },
+ masterKey
+ );
+ expect(decrypted.payload.revision).toEqual(2);
+ expect(decrypted.payload.modifiedAt).toEqual(continuedRow.modifiedAt);
+});
+
+test("encryptFinalizedDeckPayload preserves an already-finalized payload's revision/modifiedAt verbatim - the import path's requirement", async () => {
+ const { masterKey } = await createCryptoProfile(
+ "passphrase",
+ TEST_ITERATIONS
+ );
+ const finalized = {
+ ...buildDeckPayload("Imported", emptyProject, emptyFinishSettings, {}),
+ version: DECK_PAYLOAD_VERSION as 2,
+ revision: 42,
+ modifiedAt: "2020-01-01T00:00:00.000Z",
+ };
+ const encrypted = await encryptFinalizedDeckPayload(finalized, masterKey);
+ const decrypted = await decryptSavedDeckSummary(
+ {
+ key: "k",
+ kind: LoadDeckResponseKind.Deck,
+ ...encrypted,
+ createdAt: "2026-01-01",
+ updatedAt: "2026-01-01",
+ },
+ masterKey
+ );
+ expect(decrypted.payload.revision).toEqual(42);
+ expect(decrypted.payload.modifiedAt).toEqual("2020-01-01T00:00:00.000Z");
+});
diff --git a/frontend/src/features/savedDecks/deckPayload.ts b/frontend/src/features/savedDecks/deckPayload.ts
index 1a1c0551d..2837e3e08 100644
--- a/frontend/src/features/savedDecks/deckPayload.ts
+++ b/frontend/src/features/savedDecks/deckPayload.ts
@@ -2,6 +2,14 @@
* The plaintext shape encrypted wholesale (including its own `name`) as a saved deck's
* ciphertext - see docs/proposals/proposal-g-user-accounts-saved-decks.md §8. Nothing in this
* shape, or its serialized JSON string form, is ever sent to the server unencrypted.
+ *
+ * v2 (PR-6, "Revision tracking"): adds `revision`/`modifiedAt`, both PRIVATE fields living
+ * inside the encrypted payload (never server-visible) that make an export/import round-trip
+ * self-describing - see that section for the full rationale. This `version` field doubles as
+ * the "PR-6/PR-7 shared versioning rule" the spec refers to: there is deliberately no
+ * separately-named `formatVersion` field inside this private payload - PR-7's art-provenance
+ * addition is expected to become v3 of this same counter, via the same upgrade-dispatch pattern
+ * `parseDeckPayload` already uses below for v1 -> v2.
*/
import {
@@ -26,7 +34,9 @@ import {
SlotProjectMembers,
} from "@/common/types";
-export const DECK_PAYLOAD_VERSION = 1;
+/** The current (latest) version every fresh save produces. Bump alongside adding a new
+ * `DeckPayloadVN` interface and a new `case` in `parseDeckPayload`'s upgrade dispatch below. */
+export const DECK_PAYLOAD_VERSION = 2;
export interface DeckPayloadMemberFace {
query: ProjectMember["query"];
@@ -46,15 +56,36 @@ export interface DeckPayloadMember {
back: DeckPayloadMemberFace | null;
}
-export interface DeckPayloadV1 {
- version: 1;
+/** The fields every version shares - everything a deck payload needs MINUS the version tag and
+ * MINUS any version-specific bookkeeping (v2's `revision`/`modifiedAt`). Kept as its own type so
+ * `buildDeckPayload`'s output (used for dirty-checking and previews, see below) never itself
+ * carries `modifiedAt` - a fresh timestamp on every call would otherwise make an unchanged
+ * project compare as "dirty" against its own last-saved baseline every single render. */
+export interface DeckPayloadContent {
name: string;
members: Array;
- cardback: string | null;
+ cardback: Project["cardback"];
manualOverrides: Project["manualOverrides"];
finishSettings: FinishSettingsState;
}
+export interface DeckPayloadV1 extends DeckPayloadContent {
+ version: 1;
+}
+
+export interface DeckPayloadV2 extends DeckPayloadContent {
+ version: 2;
+ /** Incremented on every save of this same server-side row (never on a brand-new row - "Save
+ * as new snapshot", and import, both always start a fresh row at revision 1). */
+ revision: number;
+ /** ISO 8601 timestamp of the save that produced this revision. */
+ modifiedAt: string;
+}
+
+/** Any version this codebase can still read (never write - `buildDeckPayload` only ever
+ * produces the latest, `DECK_PAYLOAD_VERSION`-tagged shape). */
+export type DeckPayload = DeckPayloadV1 | DeckPayloadV2;
+
function toPayloadFace(
face: ProjectMember | null,
isDeviceLocal: (identifier: string | undefined) => boolean
@@ -70,18 +101,23 @@ function toPayloadFace(
};
}
+/**
+ * Builds the CONTENT of a deck payload - no version tag, no revision/modifiedAt. Used for: the
+ * Save modal's preview (local-file warning count), the dirty-check baseline (selectors.ts), and
+ * as the input to `encryptDeckPayloadForSave` below, which stamps the version/revision/
+ * modifiedAt fields on right before encryption.
+ */
export function buildDeckPayload(
name: string,
project: Project,
finishSettings: FinishSettingsState,
cardDocuments: CardDocuments
-): DeckPayloadV1 {
+): DeckPayloadContent {
const isDeviceLocal = (identifier: string | undefined): boolean =>
identifier != null &&
cardDocuments[identifier]?.sourceType === SourceType.LocalFile;
return {
- version: DECK_PAYLOAD_VERSION,
name,
members: project.members.map((member: SlotProjectMembers) => ({
front: toPayloadFace(member.front, isDeviceLocal),
@@ -94,25 +130,67 @@ export function buildDeckPayload(
}
/**
- * Canonical string form of a payload - the encryption plaintext, and also the dirty-check
- * comparison baseline (comparing two of these strings is cheaper and simpler than a deep
- * object comparison, and is exactly as precise since both sides go through this same function).
+ * Canonical string form of a payload (or payload content) - the encryption plaintext, and also
+ * the dirty-check comparison baseline (comparing two of these strings is cheaper and simpler
+ * than a deep object comparison, and is exactly as precise since both sides go through this
+ * same function).
*/
-export function serializeDeckPayload(payload: DeckPayloadV1): string {
+export function serializeDeckPayload(
+ payload: DeckPayloadContent | DeckPayload
+): string {
return JSON.stringify(payload);
}
-export function parseDeckPayload(serialized: string): DeckPayloadV1 {
+/**
+ * Strips version/revision/modifiedAt bookkeeping from an already-parsed payload, leaving just
+ * the content fields - the same shape `buildDeckPayload` returns. Used wherever a FULL decrypted
+ * payload (e.g. freshly loaded from the server) needs to become a dirty-check baseline: without
+ * this, the baseline would carry `modifiedAt`/`revision` that a freshly-rebuilt payload from the
+ * live editor never has, so every load would immediately compare as "dirty".
+ */
+export function deckContentForComparison(
+ payload: DeckPayload
+): DeckPayloadContent {
+ const { name, members, cardback, manualOverrides, finishSettings } = payload;
+ return { name, members, cardback, manualOverrides, finishSettings };
+}
+
+/**
+ * Parses a decrypted payload string, upgrading any older version forward to the latest shape
+ * this codebase understands. Never throws on a recognised OLDER version - real, already-saved
+ * decks exist at v1 today, and rejecting them the moment v2 ships would break every one of them.
+ * `fallbackModifiedAt` (typically the deck's own server-side `updatedAt`) backfills v1's missing
+ * `modifiedAt` - the closest honest proxy available, since v1 never tracked this itself.
+ */
+export function parseDeckPayload(
+ serialized: string,
+ fallbackModifiedAt?: string
+): DeckPayloadV2 {
const parsed = JSON.parse(serialized);
- if (parsed?.version !== DECK_PAYLOAD_VERSION) {
- throw new Error(
- `Unsupported saved deck payload version: ${parsed?.version}`
- );
+ switch (parsed?.version) {
+ case 2:
+ return parsed as DeckPayloadV2;
+ case 1: {
+ const v1 = parsed as DeckPayloadV1;
+ return {
+ ...v1,
+ version: 2,
+ // Never actually revised under v1's tracking (it didn't exist) - 0 so the very next real
+ // save (revision 1) always reads as strictly newer than an untouched legacy row.
+ revision: 0,
+ modifiedAt: fallbackModifiedAt ?? new Date(0).toISOString(),
+ };
+ }
+ default:
+ throw new Error(
+ `Unsupported saved deck payload version: ${parsed?.version}`
+ );
}
- return parsed as DeckPayloadV1;
}
-export function countDeviceLocalSlots(payload: DeckPayloadV1): number {
+export function countDeviceLocalSlots(payload: {
+ members: Array;
+}): number {
return payload.members.reduce(
(count, member) =>
count +
@@ -127,7 +205,7 @@ export function countDeviceLocalSlots(payload: DeckPayloadV1): number {
* directly. `deviceLocal` slots simply have no `selectedImage` here - the card grid already
* renders that as an empty, re-pickable slot with the original search query intact.
*/
-export function projectFromDeckPayload(payload: DeckPayloadV1): {
+export function projectFromDeckPayload(payload: DeckPayload): {
project: Omit;
finishSettings: FinishSettingsState;
name: string;
@@ -171,12 +249,16 @@ export interface EncryptedDeckFields {
}
/**
- * Encrypts a payload for the wire, under a FRESH per-save DEK - simpler than tracking and
- * reusing an existing deck's DEK across updates, and the server has no preference either way
- * (post_save_deck just overwrites whatever ciphertext/wrappedDek it's given, create or update).
+ * Encrypts an ALREADY-FINALIZED payload (i.e. one that already carries its own version/
+ * revision/modifiedAt, exactly as it should be persisted) verbatim, under a FRESH per-save DEK -
+ * simpler than tracking and reusing an existing deck's DEK across updates, and the server has no
+ * preference either way (post_save_deck just overwrites whatever ciphertext/wrappedDek it's
+ * given, create or update). Used directly by deck-portability import (docs/proposals/.../PR-6):
+ * an imported deck's `revision`/`modifiedAt` must survive re-encryption unchanged, since they're
+ * what makes a later re-export/re-import round-trip self-describing.
*/
-export async function encryptDeckPayloadForSave(
- payload: DeckPayloadV1,
+export async function encryptFinalizedDeckPayload(
+ payload: DeckPayload,
masterKey: CryptoKey
): Promise {
const { dek, wrappedDek } = await createDeckKey(masterKey);
@@ -192,18 +274,50 @@ export async function encryptDeckPayloadForSave(
};
}
+export interface EncryptDeckPayloadForSaveResult extends EncryptedDeckFields {
+ revision: number;
+ modifiedAt: string;
+}
+
+/**
+ * The ordinary save path: stamps `content` with the latest version tag plus a freshly-bumped
+ * `revision`/`modifiedAt` (docs/proposals/.../PR-6 "Revision tracking"), then encrypts. Pass
+ * `previousRevision` as the row's last-known revision when overwriting the SAME server-side row
+ * (an "update"); pass `null` for a brand-new row (a fresh deck, "Save as new snapshot", or an
+ * imported-as-new deck that's being re-saved rather than persisted verbatim) so it starts at 1.
+ */
+export async function encryptDeckPayloadForSave(
+ content: DeckPayloadContent,
+ masterKey: CryptoKey,
+ previousRevision: number | null
+): Promise {
+ const revision = (previousRevision ?? 0) + 1;
+ const modifiedAt = new Date().toISOString();
+ const payload: DeckPayloadV2 = {
+ ...content,
+ version: DECK_PAYLOAD_VERSION,
+ revision,
+ modifiedAt,
+ };
+ const encrypted = await encryptFinalizedDeckPayload(payload, masterKey);
+ return { ...encrypted, revision, modifiedAt };
+}
+
export interface DecryptedSavedDeck {
key: string;
kind: LoadDeckResponseKind;
name: string;
createdAt: string;
updatedAt: string;
- payload: DeckPayloadV1;
+ payload: DeckPayloadV2;
}
-/** Reverses encryptDeckPayloadForSave - unwraps the deck's DEK with the (already-unlocked)
- * master key, then decrypts and parses its payload. Throws (AES-GCM auth failure) on a wrong
- * master key or any tampered ciphertext/wrapped-DEK byte. */
+/** Reverses encryptDeckPayloadForSave/encryptFinalizedDeckPayload - unwraps the deck's DEK with
+ * the given (already-unlocked) master key, then decrypts and parses its payload. Throws
+ * (AES-GCM auth failure) on a wrong master key or any tampered ciphertext/wrapped-DEK byte.
+ * `masterKey` need not be the LIVE session's master key - deck-portability import
+ * (docs/proposals/.../PR-6) calls this with a bundle's own (possibly different-account) master
+ * key to decrypt an imported entry before re-encrypting it under the current session's key. */
export async function decryptSavedDeckSummary(
summary: SavedDeckSummary,
masterKey: CryptoKey
@@ -218,7 +332,7 @@ export async function decryptSavedDeckSummary(
base64ToBytes(summary.ciphertextNonce),
dek
);
- const payload = parseDeckPayload(plaintext);
+ const payload = parseDeckPayload(plaintext, summary.updatedAt);
return {
key: summary.key,
kind: summary.kind,
diff --git a/frontend/src/features/savedDecks/deckShare.ts b/frontend/src/features/savedDecks/deckShare.ts
index df98f2391..7b66ee8d2 100644
--- a/frontend/src/features/savedDecks/deckShare.ts
+++ b/frontend/src/features/savedDecks/deckShare.ts
@@ -28,7 +28,7 @@ import {
} from "@/common/savedDeckCrypto";
import { GetSharedDeckResponse } from "@/common/schema_types";
import {
- DeckPayloadV1,
+ DeckPayloadV2,
parseDeckPayload,
} from "@/features/savedDecks/deckPayload";
@@ -74,7 +74,9 @@ export function buildShareUrl(
export interface DecryptedSharedDeck {
name: string;
- payload: DeckPayloadV1;
+ // parseDeckPayload always upgrades to the latest shape (currently v2) - a recipient never
+ // sees a raw, un-upgraded v1 payload.
+ payload: DeckPayloadV2;
sharedAt: string;
}
diff --git a/frontend/src/store/slices/savedDeckSessionSlice.ts b/frontend/src/store/slices/savedDeckSessionSlice.ts
index 9ed81bfcf..496cacbd9 100644
--- a/frontend/src/store/slices/savedDeckSessionSlice.ts
+++ b/frontend/src/store/slices/savedDeckSessionSlice.ts
@@ -14,14 +14,22 @@ import { RootState } from "@/store/store";
export interface SavedDeckSessionState {
currentDeckKey: string | null;
currentDeckName: string | null;
- /** The serialized deck payload (see deckPayload.ts) at the moment of the last load/save. */
+ /** The serialized deck CONTENT (deckPayload.ts's `DeckPayloadContent` - no version/revision/
+ * modifiedAt) at the moment of the last load/save - the dirty-check baseline. */
lastSavedSerialized: string | null;
+ /** The last-known `revision` (deckPayload.ts's PR-6 "Revision tracking") for `currentDeckKey` -
+ * null when there's no saved row yet, or it predates revision tracking (a legacy v1 payload,
+ * upgraded to revision 0 on load - see parseDeckPayload). The next save of this SAME row
+ * increments from here; a brand-new row (Save As New / import) always starts fresh at 1 and
+ * never reads this value. */
+ lastSavedRevision: number | null;
}
const initialState: SavedDeckSessionState = {
currentDeckKey: null,
currentDeckName: null,
lastSavedSerialized: null,
+ lastSavedRevision: null,
};
export const savedDeckSessionSlice = createAppSlice({
@@ -34,16 +42,19 @@ export const savedDeckSessionSlice = createAppSlice({
key: string;
name: string;
serialized: string;
+ revision: number | null;
}>
) => {
state.currentDeckKey = action.payload.key;
state.currentDeckName = action.payload.name;
state.lastSavedSerialized = action.payload.serialized;
+ state.lastSavedRevision = action.payload.revision;
},
clearCurrentSavedDeck: (state) => {
state.currentDeckKey = null;
state.currentDeckName = null;
state.lastSavedSerialized = null;
+ state.lastSavedRevision = null;
},
},
});
diff --git a/frontend/tests/SavedDecks.spec.ts b/frontend/tests/SavedDecks.spec.ts
index 6b84363c8..51c08ff37 100644
--- a/frontend/tests/SavedDecks.spec.ts
+++ b/frontend/tests/SavedDecks.spec.ts
@@ -1,6 +1,16 @@
import { expect } from "@playwright/test";
+import * as fs from "fs/promises";
+import { http, HttpResponse } from "msw";
+import * as os from "os";
+import * as path from "path";
-import { noProfileHandler } from "@/features/savedDecks/cryptoTestHandlers";
+import { createCryptoProfile } from "@/common/savedDeckCrypto";
+import {
+ buildMockSavedDeckSummary,
+ existingProfileHandler,
+ getSavedDecksHandler,
+ noProfileHandler,
+} from "@/features/savedDecks/cryptoTestHandlers";
import {
cardDocumentsThreeResults,
defaultHandlers,
@@ -12,6 +22,9 @@ import {
import { test } from "../playwright.setup";
import { importText, loadPageWithDefaultBackend } from "./test-utils";
+const TEST_ITERATIONS = 100;
+const PASSPHRASE = "the real one";
+
const threeCardHandlers = [
cardDocumentsThreeResults,
sourceDocumentsOneResult,
@@ -117,4 +130,150 @@ test.describe("saved decks", () => {
page.getByTestId("display-toolbar").getByRole("button", { name: "Save" })
).not.toBeVisible();
});
+
+ // PR-6, post-v1 "deck portability" (docs/proposals/proposal-g-user-accounts-saved-decks.md) -
+ // real-browser coverage for the one piece jsdom can't exercise faithfully: an actual file
+ // download (Export) and an actual selection (Import), both driven by the
+ // browser's real WebCrypto rather than jest's jsdom polyfill.
+ test("Export my decks downloads a bundle the standalone tool's own wire format understands", async ({
+ page,
+ network,
+ }) => {
+ const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS);
+ const namedDeck = await buildMockSavedDeckSummary(
+ "deck-1",
+ "deck",
+ {
+ version: 2,
+ name: "Standard Aggro",
+ members: [],
+ cardback: null,
+ manualOverrides: {},
+ finishSettings: { cardstock: "(S30) Standard Smooth", foil: false },
+ revision: 1,
+ modifiedAt: "2026-01-01T00:00:00.000Z",
+ },
+ profile.masterKey,
+ { createdAt: "2026-01-01", updatedAt: "2026-01-02" }
+ );
+ network.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(profile),
+ getSavedDecksHandler([namedDeck]),
+ ...defaultHandlers
+ );
+ await loadPageWithDefaultBackend(page, "editor");
+
+ await page.getByRole("link", { name: "My Decks" }).click();
+ await page.getByLabel("unlock-passphrase").fill(PASSPHRASE);
+ await page.getByRole("button", { name: "Unlock" }).click();
+ await expect(page.getByTestId("named-decks-list")).toContainText(
+ "Standard Aggro"
+ );
+
+ // Documentary screenshot of the shipped Export/Import UI (see PR body for the path).
+ await page.screenshot({
+ path: path.join(os.tmpdir(), "pr6-my-decks-export-import.png"),
+ });
+
+ const [download] = await Promise.all([
+ page.waitForEvent("download"),
+ page.getByTestId("export-my-decks").click(),
+ ]);
+ const downloadPath = await download.path();
+ const bundleText = await fs.readFile(downloadPath, "utf-8");
+ const bundle = JSON.parse(bundleText);
+ expect(bundle.formatVersion).toEqual(1);
+ expect(bundle.decks).toHaveLength(1);
+ expect(bundle.cryptoProfile.kdfIterations).toEqual(TEST_ITERATIONS);
+ });
+
+ test("Import decks persists every deck in a selected export file as new, under the current session's key", async ({
+ page,
+ network,
+ }) => {
+ const bundleProfile = await createCryptoProfile(
+ "the bundle's own passphrase",
+ TEST_ITERATIONS
+ );
+ const currentProfile = await createCryptoProfile(
+ PASSPHRASE,
+ TEST_ITERATIONS
+ );
+ const { buildExportBundle, serializeExportBundle } = await import(
+ "@/features/savedDecks/deckExportImport"
+ );
+ const { bytesToBase64 } = await import("@/common/savedDeckCrypto");
+ const importedDeck = await buildMockSavedDeckSummary(
+ "imported-deck",
+ "deck",
+ {
+ version: 2,
+ name: "Imported Deck",
+ members: [],
+ cardback: null,
+ manualOverrides: {},
+ finishSettings: { cardstock: "(S30) Standard Smooth", foil: false },
+ revision: 2,
+ modifiedAt: "2025-01-01T00:00:00.000Z",
+ },
+ bundleProfile.masterKey,
+ { createdAt: "2025-01-01", updatedAt: "2025-01-02" }
+ );
+ const bundle = buildExportBundle(
+ {
+ exists: true,
+ salt: bytesToBase64(bundleProfile.salt),
+ kdfIterations: bundleProfile.iterations,
+ passphraseWrappedMasterKey: bytesToBase64(
+ bundleProfile.passphraseWrapped.wrapped
+ ),
+ passphraseWrappedMasterKeyNonce: bytesToBase64(
+ bundleProfile.passphraseWrapped.nonce
+ ),
+ recoveryWrappedMasterKey: bytesToBase64(
+ bundleProfile.recoveryWrapped.wrapped
+ ),
+ recoveryWrappedMasterKeyNonce: bytesToBase64(
+ bundleProfile.recoveryWrapped.nonce
+ ),
+ },
+ [importedDeck]
+ );
+ const bundleFilePath = path.join(os.tmpdir(), "pr6-import-fixture.json");
+ await fs.writeFile(bundleFilePath, serializeExportBundle(bundle));
+
+ const saveDeckRequests: Array = [];
+ network.use(
+ whoamiSignedInNotModerator,
+ existingProfileHandler(currentProfile),
+ getSavedDecksHandler([]),
+ http.post("http://127.0.0.1:8000/2/saveDeck/", async ({ request }) => {
+ saveDeckRequests.push(await request.json());
+ return HttpResponse.json(
+ { key: `new-${saveDeckRequests.length}` },
+ { status: 200 }
+ );
+ }),
+ ...defaultHandlers
+ );
+ await loadPageWithDefaultBackend(page, "editor");
+
+ await page.getByRole("link", { name: "My Decks" }).click();
+ await page.getByLabel("unlock-passphrase").fill(PASSPHRASE);
+ await page.getByRole("button", { name: "Unlock" }).click();
+ await expect(page.getByTestId("open-import-decks")).toBeEnabled();
+
+ await page.getByTestId("open-import-decks").click();
+ await page.getByLabel("import-file").setInputFiles(bundleFilePath);
+ await expect(page.getByText("1 deck found")).toBeVisible();
+ await page
+ .getByLabel("import-passphrase")
+ .fill("the bundle's own passphrase");
+ await page.getByRole("button", { name: "Import", exact: true }).click();
+
+ await expect(page.getByText("Imported 1 deck as new.")).toBeVisible();
+ expect(saveDeckRequests).toHaveLength(1);
+ expect(saveDeckRequests[0].key).toBeNull();
+ });
});