Skip to content

feat: add settings import with encrypted credential migration - #142

Open
wgordon17 wants to merge 19 commits into
gordon-code:mainfrom
wgordon17:feat/settings-import-export
Open

feat: add settings import with encrypted credential migration#142
wgordon17 wants to merge 19 commits into
gordon-code:mainfrom
wgordon17:feat/settings-import-export

Conversation

@wgordon17

Copy link
Copy Markdown
Member

Summary

  • Adds an "Import settings" counterpart to Export on both the Settings page and (pre-authentication) the Login page, replacing config wholesale via schema validation, with a schema-driven export payload guarded by a drift test.
  • Adds an opt-in encrypted-credentials bundle (GitHub + Jira) using client-side one-time-code envelope encryption then a Worker seal (encrypt-then-seal), a new pre-auth /api/proxy/unseal endpoint with Turnstile + tightened rate limiting + a single-use KV nonce, and a 30-day expiry.
  • Requires a new CREDENTIAL_NONCE_KV Worker binding (provisioning documented in DEPLOY.md); missing binding fails closed with a 503 while plaintext-config import still works.

Closes #139

@wgordon17
wgordon17 marked this pull request as ready for review August 26, 2026 19:48
@wgordon17
wgordon17 requested a review from a team 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
wgordon17 force-pushed the feat/settings-import-export branch from a922c55 to 7edb67d Compare August 26, 2026 19:48
- 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.
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.

Add settings import, including encrypted credential migration

1 participant