Skip to content

Proposal G, PR3: opaque-blob saved-decks API (recreated after #88's base-deletion auto-close) - #95

Closed
WilfordGrimley wants to merge 2 commits into
masterfrom
claude/proposal-g-saved-decks-api-v2
Closed

Proposal G, PR3: opaque-blob saved-decks API (recreated after #88's base-deletion auto-close)#95
WilfordGrimley wants to merge 2 commits into
masterfrom
claude/proposal-g-saved-decks-api-v2

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Description

Recreates #88 (claude/proposal-g-saved-decks-api), which was auto-closed when its base branch (claude/proposal-g-schema-backend, PR #85) was deleted on squash-merge - exactly the "stacked-PR base-deletion trap" docs/lessons.md documents and #88's own description flagged as a risk to retarget before merging. I wasn't fast enough to retarget it before GitHub closed it, so this is a fresh PR with the identical content, rebased onto current master (which now already contains #85's models and #86's navbar changes) via git rebase --onto origin/master 92fb60a7 ... - replaying only this branch's own 2 commits, not #85's now-redundant ones.

Content is unchanged from #88 - verified byte-identical (frontend/src/common/schema_types.ts, frontend/src/store/api.ts) and identical diff stat (22 files, 1616 insertions, 18 deletions) against the original. Re-verified fresh rather than just trusting the rebase:

  • manage.py check clean, makemigrations --check --dry-run shows no drift against current master's models.
  • A live smoke test against real local Postgres exercising all 7 endpoints end-to-end (crypto profile create/read, deck save/list/delete, account reset) - all passed.
  • black==22.8.0, isort==5.12.0 (pinned versions, matching .pre-commit-config.yaml), mypy all clean.

require_authenticated decorator (cardpicker/security.py) plus 7 endpoints - 2/savedDecks/ (list), 2/saveDeck/ (upsert), 2/loadDeck/, 2/deleteDeck/, 2/cryptoProfile/ (GET), 2/saveCryptoProfile/ (upsert), 2/resetSavedDecks/ (the destructive last-resort reset). Every ciphertext/nonce/wrapped-key field is base64 in transit, opaque to the backend - stored and returned faithfully, never decrypted or inspected.

  • saveDeck upserts by key (null creates; an existing key updates in place if owned, else 403). kind defaults to "deck"; a "snapshot" create skips the SAVED_DECK_MAX_PER_USER cap entirely and prunes the owner's snapshot rows to the newest SAVED_DECK_SNAPSHOT_RING_SIZE (5, a fixed constant per decision 7, not a setting) afterwards.
  • No server-side name-uniqueness check - can't exist once titles are encrypted (§8's Consequences). The original spec's renameDeck endpoint is gone too: renaming is now just a normal saveDeck update-by-key call, since there's no server-visible name to rename.
  • get_saved_decks returns full per-deck ciphertext, not just metadata - the deck's title lives inside it, so the client must decrypt each row to render "My Decks"; there's no lighter-weight name to return instead. A real, closed-eyes tradeoff, not solved differently, since §8 enumerates the stored fields as an exhaustive list ("nothing else").
  • saveCryptoProfile is an upsert covering both first-save creation and a passphrase change (which only ever replaces this one row - deck ciphertext/wrapped-DEKs are never touched). kdfIterations is checked against SAVED_DECK_MIN_KDF_ITERATIONS (default 600,000, matching §8's own floor).
  • resetSavedDecks requires an explicit confirm: true and deletes every SavedDeck + the crypto profile for the requesting user. No admin-side or Discord-derived decryption path exists anywhere in this stack, by design (§8's "Explicitly rejected").

New JSON schemas under schemas/schemas/endpoints/ for all 7 request/response shapes, regenerated schema_types.py/schema_types.ts via quicktype.

A real gotcha caught along the way: adding schemas with a kind enum property caused quicktype's naming/disambiguation to rename the pre-existing generic Kind type (used only by VoteQueueRequest before now) to VoteQueueRequestKind - fixed the two import sites (views.py, store/api.ts) that referenced the old name.

Checklist

  • I have installed pre-commit and installed the hooks with pre-commit install before creating any commits.
  • I have updated any related tests for code I modified or added new tests where appropriate.
  • I have manually tested my changes as follows:
    • Fresh live smoke test (this recreation): manage.py check, makemigrations --check --dry-run (no drift), and a Django-test-client script exercising all 7 endpoints end-to-end against real local Postgres - all passed.
    • black, isort (pinned versions matching .pre-commit-config.yaml), mypy --config-file mypy.ini all clean.
    • tsc --noEmit, next lint, prettier --check all clean; full jest suite passes (verified via claude/proposal-g-ui-wiring (Proposal G, PR4b: passphrase UX, My Decks page, save/load wiring #93), which already carries an identical copy of schema_types.ts/api.ts from this branch and has its own full green test run).
    • Added test_saved_deck_views.py (pytest, mirrors test_moderation_views.py's ownership/403 pattern) for CI's real Docker-backed pytest run to execute - this sandbox still can't run pytest directly (testcontainers/Docker limitation).
  • I have updated any relevant documentation or created new documentation where appropriate. (API-only PR; no new user/admin-facing behavior to document yet - lands with the frontend PR, Proposal G, PR4b: passphrase UX, My Decks page, save/load wiring #93)

Merge-time checklist

  • None - this PR is based directly on master (not stacked), so there's no retarget step this time.

Generated by Claude Code

claude added 2 commits July 18, 2026 22:26
Per §3/§8: require_authenticated decorator (cardpicker/security.py) plus
7 endpoints - 2/savedDecks/ (list), 2/saveDeck/ (upsert), 2/loadDeck/,
2/deleteDeck/, 2/cryptoProfile/ (GET), 2/saveCryptoProfile/ (upsert),
2/resetSavedDecks/ (the destructive last-resort reset). Every
ciphertext/nonce/wrapped-key field is base64 in transit, opaque to the
backend - stored and returned faithfully, never decrypted or inspected.

- saveDeck upserts by key (null creates, existing key updates in place if
  owned else 403); kind defaults to "deck"; a "snapshot" create skips the
  SAVED_DECK_MAX_PER_USER cap entirely and prunes the owner's snapshot
  rows to the newest SAVED_DECK_SNAPSHOT_RING_SIZE (5, a fixed constant
  per decision 7, not a setting) afterwards.
- No server-side name-uniqueness check - can't exist once titles are
  encrypted (§8's Consequences). The old spec's renameDeck endpoint is
  gone too: renaming is now just a normal saveDeck update-by-key call,
  since there's no server-visible name to rename.
- get_saved_decks returns full per-deck ciphertext, not just metadata -
  the deck's title lives inside it, so the client must decrypt each row
  to render "My Decks"; there's no lighter-weight name to return instead.
  Documented as a real, closed-eyes tradeoff, not solved differently,
  since §8 enumerates the stored fields as an exhaustive list ("nothing
  else").
- saveCryptoProfile is an upsert covering both first-save creation and a
  passphrase change (which only ever replaces this one row via
  update_or_create - deck ciphertext/wrapped-DEKs are never touched).
  kdfIterations is checked against SAVED_DECK_MIN_KDF_ITERATIONS (default
  600,000, matching §8's own floor) as a defensive floor against a
  buggy/malicious client persisting a weak key derivation.
- resetSavedDecks requires an explicit confirm:true and deletes every
  SavedDeck + the crypto profile for the requesting user - the "lost
  both keys" last resort from §8's Account reset design. No admin-side
  or Discord-derived decryption path exists anywhere in this stack, by
  design (§8's "Explicitly rejected").

New JSON schemas under schemas/schemas/endpoints/ for all 7
request/response shapes, regenerated schema_types.py/schema_types.ts via
quicktype. Caught and fixed a real quicktype naming collision: adding
schemas with a "kind" enum property caused quicktype's disambiguation to
rename the pre-existing generic `Kind` type (used only by VoteQueueRequest
before now) to `VoteQueueRequestKind` - fixed the two import sites
(views.py, store/api.ts) that referenced the old name. Also had to
isort+black the raw quicktype Python output and prettier the raw
TypeScript output - the committed files are always post-processed, not
raw generator output, and diffing against the unprocessed version
produces a huge spurious diff.

Verified against real Postgres via Django's test client (not pytest -
same testcontainers/Docker limitation as before): a 17-assertion smoke
script covering every endpoint - anonymous rejection, crypto-profile
creation with the iteration floor enforced, deck create/update-in-place,
list scoping, ownership 403s on load/save/delete across two different
owners, cap enforcement with the friendly message, snapshot-ring pruning
to exactly 5 after creating 8, delete, and the destructive reset flow's
confirm requirement and full cleanup - all passed. Added
test_saved_deck_views.py (pytest, mirrors test_moderation_views.py's
ownership/403 pattern) covering the same ground for real CI to run,
which has actual Docker (as it already did for PR1's model tests).
ruff/isort/black==22.8.0/mypy clean on the backend; tsc/eslint/prettier
clean and the full 304-test jest suite passes on the frontend.
…import differently)

Ran the correctly-pinned isort==5.12.0 (matching .pre-commit-config.yaml)
locally after CI caught a real drift my newer local isort didn't - it
collapses a 3-line wrapped import into one line where newer isort leaves
it wrapped. No semantic change, purely the import statement's formatting.
@WilfordGrimley

Copy link
Copy Markdown
Author

Superseded by #94, already merged (c064888) — both PRs independently recovered #88 after its stacked-base-deletion auto-close within the same minute (a concurrent-session collision, not a duplicate request). Diffed this branch against current master: it's a strict subset (zero unique content), safe to close without porting anything.

WilfordGrimley added a commit that referenced this pull request Jul 18, 2026
…tions (#100)

* Consolidate today's conventions into CLAUDE.md; fix proposal docs

CLAUDE.md:
- Report-relay: replies must carry the full GitHub blob URL to the
  pushed report, not just branch+path.
- New merge-duty rule: never delete a branch in the same action as
  merging its PR; precondition `gh pr list --base <branch>` empty
  before deleting (this is how PR #88 was lost to the stacked-PR
  base-deletion trap).
- New rule: search for an existing recovery before rebuilding a
  lost/auto-closed PR (two sessions rebuilt #88 in parallel; #95
  duplicated #94's already-shipped recovery).

docs/proposals/proposal-b-bleed-normalization.md:
- Correct decision 4's stale pre-PR-2 persistence note: the shipped
  mechanism is identifier-keyed localStorage (device-local, mirroring
  favoritesSlice), not SavedDeck/project-cloud state, matching
  proposal-g's own §5 description of the same mechanism.
- Genericize "Proxxied"/"Steam Deck" design-reference mentions.

docs/proposals/proposal-c-context-menu-restyle.md:
- Mark Part (b) (solid-color utilitarian restyle) SUPERSEDED by
  Proposal H, which absorbs the restyle direction.
- Genericize a "Proxxied" design-reference mention.

docs/proposals/proposal-g-user-accounts-saved-decks.md:
- Genericize a "Proxxied" design-reference mention.

docs/proposals/proposal-h-unified-display-page.md:
- Correct alex-taxiera/proxy-print's license label from MIT to
  AGPL-3.0 (verified against its actual GitHub license metadata);
  acoreyj/proxies-at-home remains correctly MIT.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHfxN9bbWAWHs8rfzVtBWt

* Add docs/lessons.md entries for PR #91's two findings

- "A value carried verbatim out of its old context can silently stop
  meaning what it meant": generalizes PR #91's starburst width%
  (relative to a column width that changed under it) together with
  PR #78's existing "extracts X verbatim" entry as two instances of
  the same class.
- "Bootswatch Superhero hardcodes some component colors as literal
  properties, not CSS custom-property references": PR #91's
  .btn-primary background-color finding — verify computed styles on a
  live element, not just that a --bs-* custom property resolved
  correctly.

Neither was captured in docs/lessons.md by PR #91 itself (checked: its
diff only touched cardPanel.tsx and whatsthat.tsx).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHfxN9bbWAWHs8rfzVtBWt

---------

Co-authored-by: Claude <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.

2 participants