PR-6 deck portability: export/import + standalone decrypt tool - #242
Merged
Conversation
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>
…y-a1e108bb # Conflicts: # docs/README.md
Author
|
Owner sign-off recorded: the standalone tool's MIT license (mirroring |
5 tasks
# 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
marked this pull request as ready for review
July 21, 2026 01:19
4 tasks
This was referenced Jul 29, 2026
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.MyDecksPagedownloads 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").ImportDeckModaldecrypts 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.DeckPayloadv2 adds privaterevision/modifiedAtfields (never server-visible). Bumps the payload's ownversion1→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-changingmodifiedAt.decrypt-saved-deck-export/decrypt.mjs— zero npm dependencies (Node's ownnode:cryptoWebCrypto 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."getSavedDecks/getCryptoProfile/saveDeck/saveCryptoProfileendpoints.makemigrations --checknot applicable (no models touched).Deviations / open items
decrypt-saved-deck-export/is declared MIT, mirroringfederation-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.mdnote) rather than silently decided — owner call if this precedent shouldn't extend here.version-as-shared-versioning-rule reading — the spec's prose onformatVersionvs. the private payload'sversionfield 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.ImportDeckModal; the entry point is simply disabled until unlocked. Not required by spec, a reasonable scope limit.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.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 newdeckPayload.test.ts,deckExportImport.test.ts,ImportDeckModal.test.tsx, plus updatedSaveDeckModal/LoadSafetyModal/MyDecksPagetests for revision-tracking and the new UI)npx tsc --noEmit— cleannpx eslint— cleannpx 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 belownode --test decrypt-saved-deck-export/tests/decrypt.test.mjs— 2/2 passingpython3 .github/scripts/docs_lint.py— cleanScreenshot (My Decks page with Export/Import buttons, from the new Playwright test):
(screenshot captured locally at
/tmp/pr6-my-decks-export-import.pngduring the Playwright run — not uploaded to this description; reviewer can reproduce vianpx 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)
origin/masterinto this branch (merge commit, no rebase) to resolve the 3-way textual conflict indocs/README.md,docs/features/saved-decks.md, andfrontend/src/features/savedDecks/MyDecksPage.tsx— resolution kept both PR-5 (share links) and PR-6 (deck portability) content:docs/features/saved-decks.mdnow has both "Per-deck share links (PR-5)" and "Deck portability (PR-6)" sections, andMyDecksPage.tsxrenders both the Import/Export controls and the per-deck Share modal.frontend/src/features/savedDecks/deckShare.tsandSharedDeckViewer.tsx(PR-5, from master) typed a shared deck's decrypted payload asDeckPayloadV1. PR-6'sdeckPayload.tschangedparseDeckPayloadto always upgrade toDeckPayloadV2— these files didn't textually conflict during the merge but failedtsc --noEmitafterward. Fixed by retyping both toDeckPayloadV2(whatparseDeckPayloadactually returns).docs/README.mdconflict 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 theproposal-b-bleed-normalization.mdstatus row fromPARTIALback toBUILDING(undoing docs: fix stale Proposal B status (PR-2/PR-3 shipped) #234's fix) — restored toPARTIALsince I was already touching that exact table row.Re-verification after merge
npx tsc --noEmit— clean (after theDeckPayloadV2fix above)npx jest --silent— 55 suites / 470 tests passing (full suite, not just savedDecks)npx jest --silent src/features/savedDecks— 13 suites / 58 tests passingnpx playwright test tests/SavedDecks.spec.ts --project=chromium— 7/7 passingnpx prettier@2.7.1 --checkon all files touched by the merge resolution — cleangh pr view 242 --json mergeable— nowMERGEABLE(wasCONFLICTING)