Skip to content

PR-6 deck portability: export/import + standalone decrypt tool - #242

Merged
WilfordGrimley merged 4 commits into
masterfrom
pr6-deck-portability-a1e108bb
Jul 21, 2026
Merged

PR-6 deck portability: export/import + standalone decrypt tool#242
WilfordGrimley merged 4 commits into
masterfrom
pr6-deck-portability-a1e108bb

Conversation

@WilfordGrimley

@WilfordGrimley WilfordGrimley commented Jul 20, 2026

Copy link
Copy Markdown

Summary

Implements docs/proposals/proposal-g-user-accounts-saved-decks.md's "PR-6, post-v1: deck portability" section (design-complete, owner-directed addendum) — the next un-built addendum in sequence after the 5 already-merged core saved-decks PRs. Does not implement PR-7 (art provenance), sequenced after this.

  • Export: "Export my decks" on MyDecksPage downloads the account's complete encrypted bundle (every deck's ciphertext + wrapped DEK + nonces, both wrapped-master-key slots, salt/iterations, formatVersion: 1) as one JSON file. Works even while locked (no unlock required) — verified with a dedicated test, since it's the spec's own headline scenario ("a user who's forgotten their passphrase can still export").
  • Import: ImportDeckModal decrypts a selected bundle using its own passphrase/recovery key (not necessarily the live session's — a bundle may come from a different account or instance), then re-encrypts every deck under the current session's master key and persists each as a brand-new row (key: null) — never overwrites by matching key or name, per spec.
  • Revision tracking: DeckPayload v2 adds private revision/modifiedAt fields (never server-visible). Bumps the payload's own version 1→2 — read as the spec's "PR-6/PR-7 shared versioning rule" (see code comments/ledger note on that interpretation). A v1 legacy payload is upgraded forward on load, never rejected. buildDeckPayload's content shape deliberately excludes these bookkeeping fields so the existing dirty-check baseline isn't broken by an ever-changing modifiedAt.
  • Standalone decrypt tool: decrypt-saved-deck-export/decrypt.mjs — zero npm dependencies (Node's own node:crypto WebCrypto only), cross-verified against a bundle actually produced by the browser's crypto code (not just its own mirrored logic). The trust anchor for "if this site vanishes tomorrow, your decks are still yours."
  • No backend changes — export/import are fully served by the existing getSavedDecks/getCryptoProfile/saveDeck/saveCryptoProfile endpoints. makemigrations --check not applicable (no models touched).

Deviations / open items

  1. MIT license on the standalone tool — this repo is GPL-3.0; decrypt-saved-deck-export/ is declared MIT, mirroring federation-hash-tool/'s existing precedent for a standalone tool meant to be freely reusable by forks. Flagged explicitly (code header, readme, docs/upstreaming/extractable-primitives.md note) rather than silently decided — owner call if this precedent shouldn't extend here.
  2. version-as-shared-versioning-rule reading — the spec's prose on formatVersion vs. the private payload's version field is genuinely ambiguous in isolation; implemented as: EXPORT_FORMAT_VERSION (bundle's own public envelope version) vs. DeckPayloadV2.version (private per-deck version, doubling as the "PR-6/PR-7 shared counter"). Documented in code comments for the PR-7 implementer/owner to correct if this reading is wrong.
  3. Import requires the current session already unlocked — importing into a brand-new account with no crypto profile yet isn't handled by ImportDeckModal; the entry point is simply disabled until unlocked. Not required by spec, a reasonable scope limit.
  4. On-site Privacy Policy text (about.tsx) not touched — PR-6 doesn't collect/retain/share any new data (export only surfaces what the server already stores); judged not to need a policy-text change. Separately, the existing saved-decks feature (Discord OAuth + account data) isn't mentioned on that page at all — a pre-existing gap from the original 5-PR build, out of scope here, worth a follow-up.
  5. Wiki: this changes what a signed-in user sees (new Export/Import buttons on My Decks) — Saved-Decks-adjacent wiki page needs a corresponding update at merge time (cloud-session checklist item per CLAUDE.md).

Test plan

  • npx jest — 460 tests passing (includes new deckPayload.test.ts, deckExportImport.test.ts, ImportDeckModal.test.tsx, plus updated SaveDeckModal/LoadSafetyModal/MyDecksPage tests for revision-tracking and the new UI)
  • npx tsc --noEmit — clean
  • npx eslint — clean
  • npx prettier@2.7.1 --check — clean (pre-commit hook also ran clean)
  • npx playwright test tests/SavedDecks.spec.ts — 7/7 passing, including two new real-browser tests (an actual file download for Export, an actual <input type="file"> selection for Import) — screenshot of the shipped UI: see PR description image below
  • node --test decrypt-saved-deck-export/tests/decrypt.test.mjs — 2/2 passing
  • python3 .github/scripts/docs_lint.py — clean
  • Manually cross-verified: a bundle built by the actual frontend crypto code (Jest, real WebCrypto) was decrypted successfully by the standalone Node CLI tool, confirming true cross-implementation compatibility, not just mirrored logic passing its own tests

Screenshot (My Decks page with Export/Import buttons, from the new Playwright test):

My Decks with Export/Import

(screenshot captured locally at /tmp/pr6-my-decks-export-import.png during the Playwright run — not uploaded to this description; reviewer can reproduce via npx playwright test tests/SavedDecks.spec.ts -g "downloads a bundle".)

🤖 Generated with Claude Code

Merge-conflict resolution (master moved: #243 PR-5 share links, #244 proposal-h docs, #246 canary report)

  • Merged origin/master into this branch (merge commit, no rebase) to resolve the 3-way textual conflict in docs/README.md, docs/features/saved-decks.md, and frontend/src/features/savedDecks/MyDecksPage.tsx — resolution kept both PR-5 (share links) and PR-6 (deck portability) content: docs/features/saved-decks.md now has both "Per-deck share links (PR-5)" and "Deck portability (PR-6)" sections, and MyDecksPage.tsx renders both the Import/Export controls and the per-deck Share modal.
  • Semantic (non-textual) conflict found and fixed: frontend/src/features/savedDecks/deckShare.ts and SharedDeckViewer.tsx (PR-5, from master) typed a shared deck's decrypted payload as DeckPayloadV1. PR-6's deckPayload.ts changed parseDeckPayload to always upgrade to DeckPayloadV2 — these files didn't textually conflict during the merge but failed tsc --noEmit afterward. Fixed by retyping both to DeckPayloadV2 (what parseDeckPayload actually returns).
  • Also fixed, in the same docs/README.md conflict resolution: an unrelated pre-existing regression where Add PR-5 per-deck share links (proposal-g §PR-5) #243's stale source branch had reverted the proposal-b-bleed-normalization.md status row from PARTIAL back to BUILDING (undoing docs: fix stale Proposal B status (PR-2/PR-3 shipped) #234's fix) — restored to PARTIAL since I was already touching that exact table row.

Re-verification after merge

  • npx tsc --noEmit — clean (after the DeckPayloadV2 fix above)
  • npx jest --silent — 55 suites / 470 tests passing (full suite, not just savedDecks)
  • npx jest --silent src/features/savedDecks — 13 suites / 58 tests passing
  • npx playwright test tests/SavedDecks.spec.ts --project=chromium — 7/7 passing
  • npx prettier@2.7.1 --check on all files touched by the merge resolution — clean
  • gh pr view 242 --json mergeable — now MERGEABLE (was CONFLICTING)

Implements docs/proposals/proposal-g-user-accounts-saved-decks.md's
"PR-6, post-v1: deck portability" section: "Export my decks"/"Import
decks" on MyDecksPage, DeckPayload v2 revision/modifiedAt tracking,
and a zero-dependency standalone Node decrypt tool as the trust
anchor for "if this site vanishes tomorrow, your decks are still
yours." Frontend-only, no backend changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@WilfordGrimley

Copy link
Copy Markdown
Author

Owner sign-off recorded: the standalone tool's MIT license (mirroring federation-hash-tool's precedent) is approved by the project owner, 2026-07-20, in the orchestration session directly — verbatim: "go for the license, mirror federation-hash-tool, if we end up making any changes to it we can gpl them." This resolves this PR's own "Deviations / open items" note #1 (MIT license — owner call). Recorded in the private orchestration ledger (proxyprints-orchestration/DECISIONS.md, 2026-07-20 entries) for cross-session verification. No longer an open item.

# Conflicts:
#	docs/README.md
#	docs/features/saved-decks.md
#	frontend/src/features/savedDecks/MyDecksPage.tsx
WilfordGrimley added a commit that referenced this pull request Jul 21, 2026
Mirrors the federation-hash-tool entry: decrypt.mjs (PR #242) is
deliberately MIT-licensed, owner sign-off 2026-07-20.
@WilfordGrimley
WilfordGrimley marked this pull request as ready for review July 21, 2026 01:19
@WilfordGrimley
WilfordGrimley merged commit 5ddf109 into master Jul 21, 2026
10 checks passed
WilfordGrimley added a commit that referenced this pull request Jul 21, 2026
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…t tool

docs/upstreaming/license-provenance.md §2 declared
`decrypt-saved-deck-export/decrypt.mjs` and its test part of the
vote/federation trust anchor, and instructed that both be added to
check_protected_core_license.py's PROTECTED_CORE_FILES "in the PR that
merges #242 (or immediately after)". #242 merged (5ddf109), both files
landed on master, and the CI list was never updated. Two files the
policy calls a trust anchor have carried no gate at all since then.

The one-line fix is to add them to the list. That is not what this does,
because the defect is not the missing entry — it is that two
hand-maintained lists were kept in sync by a convention written in
prose. check_protected_core_license.py now holds NO list: it parses the
marker-bounded roster region in §2 and derives its file set from the
doc, which §2 already declared the source of truth. They cannot disagree
because there is only one list.

Adding the .mjs entries also exposed that neither existing rule could
have fired on them:
  - the provenance-marker regex required a `#` comment leader, so a
    `// PROVENANCE: ..., AGPL-3.0` line in a JS file was invisible. It
    now accepts `#`, `//` and `*`.
  - the import walk was ast.parse()-only, which raises SyntaxError on
    .mjs and returned []. ES-module `import`/`export ... from` /
    dynamic `import()` / `require()` specifiers are now extracted, with
    only relative specifiers resolving (a bare specifier is a node:
    builtin or an npm package — out of scope for the same reason the
    Python side does not scan PyPI metadata).

A roster check's characteristic failure is to check nothing and pass, so
a missing/out-of-order marker, an empty region, a listed path that does
not exist, and a roster entry in an unwalkable language are each HARD
findings rather than silent skips.

§2 also spells out the two "(+ its test)" prose parentheticals as
explicit paths, since only backticked spans are machine-read, and
replaces the stale "not yet in the CI script" bullet with a dated record
of the gap it described.

Roster derived: 11 files (was 9 hardcoded).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…t tool (#587)

* Derive the PROTECTED CORE roster from the policy doc; gate the decrypt tool

docs/upstreaming/license-provenance.md §2 declared
`decrypt-saved-deck-export/decrypt.mjs` and its test part of the
vote/federation trust anchor, and instructed that both be added to
check_protected_core_license.py's PROTECTED_CORE_FILES "in the PR that
merges #242 (or immediately after)". #242 merged (5ddf109), both files
landed on master, and the CI list was never updated. Two files the
policy calls a trust anchor have carried no gate at all since then.

The one-line fix is to add them to the list. That is not what this does,
because the defect is not the missing entry — it is that two
hand-maintained lists were kept in sync by a convention written in
prose. check_protected_core_license.py now holds NO list: it parses the
marker-bounded roster region in §2 and derives its file set from the
doc, which §2 already declared the source of truth. They cannot disagree
because there is only one list.

Adding the .mjs entries also exposed that neither existing rule could
have fired on them:
  - the provenance-marker regex required a `#` comment leader, so a
    `// PROVENANCE: ..., AGPL-3.0` line in a JS file was invisible. It
    now accepts `#`, `//` and `*`.
  - the import walk was ast.parse()-only, which raises SyntaxError on
    .mjs and returned []. ES-module `import`/`export ... from` /
    dynamic `import()` / `require()` specifiers are now extracted, with
    only relative specifiers resolving (a bare specifier is a node:
    builtin or an npm package — out of scope for the same reason the
    Python side does not scan PyPI metadata).

A roster check's characteristic failure is to check nothing and pass, so
a missing/out-of-order marker, an empty region, a listed path that does
not exist, and a roster entry in an unwalkable language are each HARD
findings rather than silent skips.

§2 also spells out the two "(+ its test)" prose parentheticals as
explicit paths, since only backticked spans are machine-read, and
replaces the stale "not yet in the CI script" bullet with a dated record
of the gap it described.

Roster derived: 11 files (was 9 hardcoded).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

* docs-lint: trigger the protected-core job on decrypt-tool changes

The roster is derived from license-provenance.md (covered by `docs/**`),
but the FILES it gates were not all trigger paths.
`decrypt-saved-deck-export/**` had none: a PR pasting AGPL-marked code
into the decrypt tool touches no doc and no cardpicker module, so the
protected-core lint would not have run on it until the weekly cron.
`federation-hash-tool/**` was already listed; this is its counterpart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant