feat: add settings import with encrypted credential migration - #142
Open
wgordon17 wants to merge 19 commits into
Open
feat: add settings import with encrypted credential migration#142wgordon17 wants to merge 19 commits into
wgordon17 wants to merge 19 commits into
Conversation
wgordon17
marked this pull request as ready for review
August 26, 2026 19:48
Schema-driven export payload with denylist + drift guard test (Task 1); plaintext Settings import with config-fixup extraction (Task 2); worker expiry-aware sealing + /api/proxy/unseal + CREDENTIAL_NONCE_KV nonce store (Task 3); client-side envelope encryption with one-time code (Task 4).
Task 5: assembles a GitHub+Jira credential bundle, client-encrypts it with a generated one-time code, then Worker-seals the ciphertext (encrypt-then-seal), surfaced via a one-time-code modal with deferred download. Task 6: unseals once server-side, decrypts client-side with wrong-code retry against the cached ciphertext, and commits identity+config+Jira in order with pre-auth IndexedDB isolation. Parameterizes acquireTurnstileToken(siteKey, action) and fixes the JiraCallback action mismatch.
Adds an 'Import from backup' flow to the Login page: parse/validate, one-time-code unseal-then-cache-retry with an in-flight guard, conditional identity confirmation (only when local config exists), auto-login, and cross-identity IndexedDB isolation. Documents the full round-trip in USER_GUIDE.md. Also makes a client-side Turnstile-acquisition failure retryable and distinct from a server rejection on both Login and Settings import flows, so a transient widget failure no longer burns a still-valid bundle.
Adds a generation guard so a cancel-then-reselect during an in-flight unseal can no longer strand one file's credentials into another's import (preventing a wrong-identity commit). Makes network/rate-limit/turnstile unseal failures retryable instead of burning a still-valid bundle. Reimplements the export-code and import dialogs with Kobalte Dialog (focus trap, Escape, re-trigger guard). Resyncs Settings editor state after import so later edits can't discard imported selections. Also: enforces the export-file version, clamps the Jira refresh TTL, aligns the imported Jira authMethod to the sealed bundle, tightens the credential-bundle schema, pins GitHub API headers, and single-sources the credential-bundle purpose constant.
Documents provisioning the new credential-nonce KV namespace for fork/self-host deploys (create commands + wrangler.toml wiring), the pre-auth /api/proxy/unseal endpoint, and the fail-closed 503 behavior when the binding is absent. Notes plaintext-config import is unaffected on static-only or unprovisioned deploys.
The credential-unseal single-use nonce is best-effort and bounded by the nonce's KV TTL: once it expires, a resealed copy is unsealable again. This is harmless because a code-less unseal only ever yields the inert, still-code-encrypted ciphertext, so single-use is an availability guarantee, not a confidentiality one. Comment-only; no behavior change.
wgordon17
force-pushed
the
feat/settings-import-export
branch
from
August 26, 2026 19:48
a922c55 to
7edb67d
Compare
- extracts the credential-import state machine into a shared createCredentialImport composable consumed by the Settings and Login pages - disables Cancel/decline while a commit is in flight and adds a commit re-entry guard so cancelling can't leave a half-applied import - surfaces commit failures on the Login identity-confirm step (was silent) and degrades a malformed Jira-refresh response to jiraRestored:false - corrects the USER_GUIDE claim that a wrong one-time code is unrecoverable - adds tests for concurrent unseal, the exact expiry boundary, double-commit, and the identity-confirm commit-failure path
…n time happy-dom schedules requestAnimationFrame via setImmediate, which resolves after @testing-library/user-event's zero-delay setTimeout chain. Kobalte's Dialog defers its exit cleanup (undo aria-hidden + restore body pointer-events) via setTimeout(() => requestAnimationFrame(fn)), so await user.click(dismiss) returned before cleanup ran, leaving the page aria-hidden for the next interaction. Rerouting rAF to a microtask orders it ahead of userEvent's chain without going fully synchronous.
API-token mode authenticates to Atlassian with basic auth (email + the unsealed token) and never uses the OAuth client id, so requiring JIRA_CLIENT_ID wrongly blocked token-only deployments.
Replace the hex one-time code with a 26-char Crockford base32 encoding of the same 16 CSPRNG bytes (representation-only; 128-bit entropy and the HKDF envelope-key path are unchanged). Decode normalizes case, dashes, and Crockford leniency (I/L->1, O->0) and rejects out-of-alphabet chars and wrong lengths. Display uses dashed groups of four.
Add a plaintext _viewPreferences section to the settings export carrying the durable view-store keys (jira order, expand/lock state, tab + custom filters, dependency groups, PR-run + dep-dashboard toggles, ignored/tracked items). Item entries are rebuilt from a field allowlist so titles, URLs, and jira status never enter the export. Transient keys (lastActiveTab, globalSort, globalFilter) are excluded. Schema-drift guards force a conscious include/ exclude decision when the view or item schemas gain a field.
Apply an imported _viewPreferences section after the GitHub identity is established, on every import path (encrypted-credential and plaintext, in Settings and on the login screen). applyImportedViewState validates each curated key independently and overlays only those keys, never the transient ones; malformed input is a silent no-op. Stripped item entries default a missing title to an empty string and re-hydrate from live data.
…e-use warning The Export button now opens a dialog offering "config only" or "with encrypted credentials" instead of a persistent checkbox. The credentials branch hosts a prominent single-use warning that the transfer is one-time and expires in 30 days. Config-only still carries plaintext view preferences; the credentials path preserves the shown-once base32 code and copy flow. Only one dialog is ever visible at a time.
The terminal unseal-failed copy now notes the file may already have been used (single-use) in addition to a code/file mismatch, and suggests re-exporting. The retryable wrong-code message and the expired message are unchanged and remain distinct.
…, and view-preferences migration Document the Crockford base32 one-time code, the export choice dialog and its single-use warning, and the new view-preferences migration — including what travels in plaintext (identifiers, saved filters) versus what is scrubbed (titles, URLs, Jira status) and how items re-hydrate on the target.
Importing a different GitHub identity now clears the prior identity's data on every path. The pre-auth login import resets the view store (its identity-switch reset was inert with no current user), and other open tabs now reload when a cross-tab token change is a genuine identity switch rather than a same-user token rotation, so a passive tab can no longer render the previous identity's config, view, or cached data. Also hardens settings-transfer: base32 known-answer tests, an import-side stripped-item backfill guard mirroring the export drift guard, and a redundant assignment cleanup in the view-preferences export.
…ests The auth store registers a module-level storage listener, and the test file's vi.resetModules() + re-import in beforeEach re-registered a new one each time without removing the old, so a dispatched StorageEvent fired every accumulated stale listener. Stale listeners frozen on an old token kicked off a fire-and-forget /user fetch that fell through to real network when fetch wasn't stubbed at that moment, and happy-dom aborted it at teardown (ERR_INVALID_STATE). Add a safe default fetch stub in beforeEach and flush pending chains before restoring globals in afterEach.
…er review Block dismissing the export-choice dialog while a credential seal is in flight (guarded onOpenChange + disabled Cancel/config-only buttons, plus a post-await check), so cancelling mid-seal can no longer pop the one-time-code modal after the user backed out. Associate the single-use warning with its button via aria-describedby. Wrap applyImportedViewState in a total no-op guard as defense-in-depth, and add size caps to the customTabFilters and expandedRepos view-state records.
Other open tabs reload automatically when credentials for a different GitHub identity are imported, so document that any unsaved in-tab state there is lost.
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
/api/proxy/unsealendpoint with Turnstile + tightened rate limiting + a single-use KV nonce, and a 30-day expiry.CREDENTIAL_NONCE_KVWorker binding (provisioning documented in DEPLOY.md); missing binding fails closed with a 503 while plaintext-config import still works.Closes #139