Skip to content

feat: encryption at rest — encrypted backups + encrypted-volume deploy posture (#67) - #69

Merged
unclesp1d3r merged 26 commits into
mainfrom
feat/encryption-at-rest-backups
Jul 14, 2026
Merged

unclesp1d3r merged 26 commits into
mainfrom
feat/encryption-at-rest-backups

Conversation

@unclesp1d3r

@unclesp1d3r unclesp1d3r commented Jul 13, 2026 •

Copy link
Copy Markdown
Owner

📋 Reviewer's Guide

Scope: 66 files · +11,660 / −63 · 26 commits — one cohesive feature (encryption-at-rest), so it reviews as a unit rather than splitting. | Risk: 🟠 High inherent (crypto · admin auth · data migration · destructive restore · PII), well-mitigated (110 backup tests incl. fault-injection + Testcontainers, e2e, 2 adversarial review rounds with all findings fixed). | Change mix: 34 source · 13 test · 6 docs · 5 deploy · 3 migration.

Suggested review order (highest-leverage first)

  1. src/backup/crypto.ts (+628) — the trust root: Argon2id + secretstream, header parse, bounded KDF params. Verify no plaintext escapes on auth failure.
  2. src/backup/restore-service.ts (+670) — the crown jewel: stage-then-promote (nothing live until the whole bundle authenticates), unified maintenance+snapshot+rollback envelope, per-run schemas. Read alongside restore-service.test.ts (fault-injection rollback, last-chunk tamper, concurrent-restore).
  3. src/backup/maintenance.ts (+592) — durable flag, pool-safe advisory lock, crash recovery (recoverInterruptedRestore), assertWritesAllowed (the write-path guard).
  4. src/backup/bundle.ts (+423) — streaming tar + zip-slip / path-traversal choke point (KTD11).
  5. src/backup/db-{export,import}.ts + table-order.ts — FK-safe NDJSON round-trip, snapshot-isolated export, bounded import.
  6. Routes app/api/admin/backup/* — admin gate → same-origin CSRF → streaming; discriminated outcomes; operator_audit.
  7. UI app/(admin)/backup/* — no-recovery warning, type-to-confirm force-replace, session invalidation, double-submit guard.
  8. Deploy/docs — Docker secrets, hardening, docs/operations/encryption-at-rest.md threat matrix.

Review checklist (tailored)

Crypto / security

  • No hand-rolled crypto; wrong-password & tampered-bundle fail before any data change (incl. last chunk)
  • Untrusted bundle input bounded (KDF params, NDJSON line length, blob path/count/size) — no zip-slip
  • Both admin routes: admin gate + same-origin check before doing work; password never logged

Restore correctness (destructive path)

  • Live data untouched until full-bundle authentication; force-replace rolls back both stores on failure
  • Concurrent restores can't corrupt each other; a crash mid-restore is recoverable on boot
  • Maintenance mode actually blocks ordinary writes during the wipe/promote window

Data migration

  • operator_audit migration 0018 is additive/safe; table-order.ts covers every persistent table (guard test present)

Tests

  • Safety-critical behaviors proven behaviorally (not just mechanism), via Testcontainers/e2e — not mocks

Summary

Adds a coherent data-at-rest story to MagStacker in two complementary layers, resolving the direction of #67:

  1. Admin-only encrypted backup/restore — an operator exports the entire instance (full database + every document blob from Attach documents to firearms (receipts, warranties, ATF forms) #12) as one password-encrypted file they download and keep off-box, and restores it onto a fresh or deliberately-wiped instance. Live column encryption is deliberately excluded so the database stays queryable.
  2. Encrypted-volume deploy posture — shipped Docker Compose config (secrets, container hardening, encrypted-volume-ready mounts) plus operator documentation for running the data on an encrypted host disk.

Plan: docs/plans/2026-07-12-001-feat-encryption-at-rest-backups-plan.md (implementation-ready; strengthened from a multi-persona doc review before implementation).

What's included (by implementation unit)

  • U1 — Crypto (src/backup/crypto.ts): Argon2id KDF (sodium-native, OWASP MODERATE) + chunked authenticated secretstream (XChaCha20-Poly1305) streams with a self-describing header. No hand-rolled crypto.
  • U2 — Bundle (src/backup/bundle.ts, manifest.ts): streaming tar (manifest.json + db.ndjson + blobs/) composed with the crypto stream; nothing buffered whole. Zip-slip / path-traversal defense at the read choke point.
  • U3 — DB export/import (src/backup/db-export.ts, db-import.ts, table-order.ts): FK-safe NDJSON round-trip over every persistent table (ephemeral tables excluded), with a schema-diff guard test. Adds the operator_audit table (migration 0018).
  • U4 — Export service: admin-gated streamed export, version-stamped, retaining nothing server-side.
  • U5 — Restore service (restore-service.ts, maintenance.ts): stage-then-promote — nothing touches live data until the whole bundle authenticates (holds on both empty and force paths, incl. a tamper in the final chunk); BACKUP_FORMAT_VERSION compatibility gate; refuse-unless-empty by default; force-replace with both-store snapshot/rollback.
  • U6 — Admin routes (app/api/admin/backup/*): streaming request/response, discriminated restore outcome, operator_audit logging on success and failure.
  • U7 — Admin UI (app/(admin)/backup/*): password + no-recovery warning, direct streamed download, upload restore with differentiated outcome messaging, type-to-confirm force-replace, session-invalidation on success.
  • U8 — Deploy hardening: Docker secrets, read-only rootfs + dropped caps + no-new-privileges, encrypted-volume-ready named mounts.
  • U9 — Operator docs (docs/operations/encryption-at-rest.md): LUKS/encrypted-cloud-volume how-to + threat-coverage matrix.

Test plan

  • just ci-check green: Biome lint + format, tsc --noEmit, pre-commit hooks, full bun test (600 pass), Playwright e2e (34 passed incl. e2e/backup.spec.ts).
  • 56 backup unit/integration tests (Testcontainers Postgres) including crypto round-trip + tamper/wrong-key auth failures, FK-order + column round-trip fault-injection, zip-slip refusal, restore stage-then-promote (last-chunk tamper before promote), and two force-restore rollback fault-injection tests (pre-commit and post-commit-pre-blob-swap).
  • Deploy: docker compose config + boot smoke (secrets resolve, read-only rootfs, uploads writable) verified during U8.

Review Findings — all resolved

The multi-persona code review surfaced the findings below. All are now fixed in this PR (commits 6aa5333, 8591cd8, 9caec0a, 19fc7a9), each with tests.

  • [P1] Maintenance mode is now enforced on the write path. assertWritesAllowed(db) (a cheap single flag SELECT, fail-open on missing infra) is called from every write authorization helper (resolveCreateOwner, authorizeUpdate, authorizeOwnerOnlyUpdate, authorizeDelete, authorizeAndDeleteParent, authorizeMount) plus the non-helper mutation sites (grants create/revoke, accessories' bespoke gate, magpul-mode settings, admin create/ban/unban). Ordinary user writes are refused with MaintenanceModeError during a force-restore; reads and the restore's own promote are unaffected.
  • [P1] Concurrent-restore race fixed. Per-run unique restore_staging_<id> / restore_snapshot_<id> schemas, and the advisory lock now wraps the entire restore body (staging + promote, both paths) so restores fully serialize.
  • [P1] Crash recovery + startup sweep added. recoverInterruptedRestore() runs from Next.js instrumentation.ts on boot (guarded on DATABASE_URL, never fails boot): it rolls back an interrupted force-restore from the snapshot schema, restores stranded blobs, clears the stuck flag, and sweeps leftover staging schemas/dirs.
  • [P1] exportDatabase is now snapshot-isolated. The export consumption runs inside a repeatable read / read only transaction, so the bundle is internally consistent even under concurrent writes.
  • [P1] F2 empty-instance restore wipes before promote inside its transaction (was: INSERT collided with the live bootstrap admin on user.email).
  • [P2] Explicit same-origin CSRF check on both admin backup routes (Origin → Referer → Sec-Fetch-Site, 403 on mismatch), no longer relying solely on Better Auth's default cookie SameSite.
  • [P2] Staging-phase errors reclassified — only genuine crypto-auth failures map to wrong_password_or_tampered; other errors (ENOSPC, malformed tar, unknown table) surface distinctly.
  • [P2] NDJSON import line cap (8 MiB, bounded reader) prevents unbounded buffering on a crafted bundle.
  • [P2] KDF params from the untrusted header are bounded before deriveKey (pre-auth Argon2id resource-blowup prevention).
  • [P3] Minimum backup-password length (12) enforced server- and client-side on export.

Also from the review/simplify passes: the restore UI validates the route outcome and falls back gracefully instead of crashing on an unmodeled outcome; bufferDbExport tallies rows in-loop and returns a Buffer; parallel blob stats; single staging mkdir; and a pre-existing full-suite flake was fixed (gating.test.ts self-seeds its documented admin).

Closes #67.

Comprehensive review round 2 — all findings resolved

A second multi-agent review (correctness, tests, silent-failures, type-design, comments) surfaced further findings; all are fixed in commits 32c4922, 5b399da, 6104cbc, 59ff896, 29b9a7a:

  • Critical TOCTOU — the non-force restore path wiped live data with no maintenance guard or snapshot, so a concurrent write during staging was destroyed unrecoverably. Both promote paths are now unified through the full maintenance + snapshot + rollback envelope.
  • Silent rollback/recovery failures — undoBlobSwap and recoverInterruptedRestore now log and, on failure, leave maintenance active (blocking writes) instead of reporting a clean rollback / clearing the flag over a half-wiped DB; the active snapshot is excluded from the boot sweep.
  • Audit/stream error handling — every recordOperatorEvent is .catch+logged (no logging hiccup masks a real restore result); export streams get an error listener.
  • Type/validation — untrusted NDJSON rows are shape-validated; KdfParams.alg is validated; BundleEvent handling is exhaustiveness-checked; _testFaultInjection is guarded against production use.
  • Tests added — KDF-param rejection, behavioral snapshot isolation, crash-before-risky-section recovery, CSRF Sec-Fetch-Site/default-deny, unknown-table rejection, and instrumentation.register() (now run in CI in its own process).
  • Comments/crypto — relocated orphaned JSDoc; copy-salt-on-ingest; corrected a dangling doc reference.

Copilot AI review requested due to automatic review settings July 13, 2026 00:13
@coderabbitai

coderabbitai Bot commented Jul 13, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

This PR adds encrypted streaming backups, staged database/blob restores with rollback and maintenance recovery, admin APIs and UI, audit logging, Docker-secret deployment configuration, container hardening, and extensive integration and end-to-end coverage.

Suggested labels: security, backend, infrastructure, documentation, testing, frontend, enhancement, dependencies, priority:high

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ⚠️ Warning The title is related, but it omits the required Conventional Commits scope and uses an em dash-style description. Use Conventional Commits format like feat(backup): encrypted backups and encrypted-volume deploy posture.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description covers summary, issue, changes, and test plan well; only the AI disclosure and checklist sections are missing.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/encryption-at-rest-backups

Warning

Review ran into problems

🔥 Problems

These MCP integrations need to be re-authenticated in the Integrations settings: Notion


Comment @coderabbitai help to get the list of available commands.

Fold multi-persona review findings into the plan: stage-then-promote
restore (KTD10) so R9 holds on all paths, BACKUP_FORMAT_VERSION gate
(KTD4), zip-slip blob path validation (KTD11), dedicated operator_audit
table (KTD6), crash-safe durable maintenance flag + pool-safe advisory
lock (KTD5), and differentiated U7 UI states.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Adds src/backup/crypto.ts, a thin libsodium wrapper for the encrypted
backup plan (KTD1): deriveKey() via Argon2id (crypto_pwhash, libsodium's
OWASP-aligned MODERATE params) plus createEncryptStream/createDecryptStream
over crypto_secretstream_xchacha20poly1305, chunked at 64 KiB. A fixed-
length header (magic, version, salt, KDF params, secretstream header)
precedes the ciphertext; writeHeader/readHeader (de)serialize it.

Uses sodium-native (native, prebuilt via prebuildify - verified to load
cleanly under Bun with no compile step) and adds it to
package.json's trustedDependencies. Ships a small hand-written
sodium-native.d.ts ambient declaration since the only published
@types/sodium-native predates the installed v5.x line by several majors.

20 new tests cover round-trips (small/empty/multi-chunk/boundary-aligned/
unaligned writes), deriveKey determinism and salt/password sensitivity,
header round-tripping and corruption handling, wrong-key decryption
failure, and tamper detection (mid-stream and final-chunk byte flips,
truncation) - all fail before any plaintext is produced.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Add the DB export/import primitives for the encryption-at-rest backup
plan: exportDatabase()/importDatabase() stream the full database as
NDJSON in FK-safe order, wipeDatabase() clears it in reverse order for a
future force-replace restore, and table-order.ts enumerates every
persistent table (ephemeral session/rate_limit/idempotency excluded) so
new tables can't silently drop out of a backup. Also adds the
operator_audit table (id, actor, action, outcome, at) to record backup
export/restore events, wired into the schema barrel with its migration.

Verified with a Testcontainers-backed round-trip integration test: full
seed -> export -> wipe -> import reproduces every table's rows exactly,
FK order lets firearm_document import right after firearm, ephemeral
tables are excluded, and a regression guard compares table-order.ts
against the live information_schema.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…nts (U8)

Move the Postgres password and Better Auth signing secret out of plain
compose environment variables and into Docker secrets (files under
./secrets/, mounted read-only at /run/secrets/*), resolved into
DATABASE_URL/BETTER_AUTH_SECRET at container start by a new
docker-entrypoint.sh (R16). Apply baseline container hardening — read-only
rootfs, explicit writable tmpfs, dropped capabilities, no-new-privileges —
to the db, migrate, and app services (R18), and document the Postgres data
and upload volumes as the attach points for an encrypted host disk (R17).

Update setup.sh, README, CONTRIBUTING, and docs/deployment.md so the
documented setup/dev flows create and read the new secret files instead of
plaintext .env values.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Admin-only /backup screen: export (password + confirm, explicit
no-recovery warning, native <form> POST download so a GB-scale bundle
is never buffered client-side) and restore (file + password upload via
fetch(), refuse-unless-empty messaging, force-replace behind a
type-to-confirm "REPLACE ALL DATA" phrase, distinct copy per
discriminated outcome, and session invalidation + redirect to /login
on success since the users table was just replaced).

- app/(admin)/backup/{page,backup-panel,export-panel,restore-panel,constants}.tsx
- components/ui/confirm-dialog.tsx: add confirmDisabled for the
  type-to-confirm guard
- app/api/admin/backup/export/route.ts: accept a form-encoded password
  body (the real <form> submit) alongside the existing JSON contract
- app/(auth)/login/login-form.tsx: show "Instance restored — please
  sign in" after a force-invalidated session redirect
- app/(app)/app-shell.tsx: add the admin "Backup" nav link
- next.config.ts: externalize sodium-native so Turbopack's build
  tracing doesn't break its native addon's runtime path resolution
  (ADDON_NOT_FOUND) — this was blocking every build, not just U7
- e2e/backup.spec.ts: export/restore flows, no-recovery warning,
  refuse-unless-empty (real bundle), force-replace phrase gating,
  mocked outcome messaging, and post-restore redirect

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Simplify: bufferDbExport tallies rows in-loop and returns a Buffer (drop
a full-payload string split + round-trip); parallelize blob stats; mkdir
staging dir once per dir. Restore UI: validate the route outcome against
the copy map and fall back to the unexpected-error display instead of
crashing on an unmodeled (bad_request/error) outcome.

Review fixes: F2 empty-instance restore now wipes before promote inside
its transaction so restoring onto an instance holding the bootstrap admin
no longer collides on user.email; bound untrusted KDF params from the
bundle header before deriveKey to prevent a pre-auth Argon2id resource
blowup.

Test: gating.test.ts self-provisions its documented seeded admin in
beforeAll, fixing a pre-existing full-suite flake where a sibling test
deleting the shared ambient-DB admin made these assertions order-dependent.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@coderabbitai coderabbitai Bot added backend documentation Improvements or additions to documentation enhancement New feature or request infrastructure priority:medium security testing labels Jul 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements an encryption-at-rest strategy via admin-only encrypted backup/export + restore (DB + blobs) and a hardened “encrypted-volume-ready” Docker deployment posture (Docker secrets, read-only rootfs, reduced caps), plus supporting UI and documentation.

Changes:

  • Added streaming encrypted backup bundle format (manifest + NDJSON + blobs) with libsodium-based crypto, plus admin export/restore services and routes.
  • Introduced operator_audit table + logging for backup export/restore attempts, and comprehensive unit/integration/e2e coverage for the backup feature.
  • Updated deployment posture: Docker secrets, entrypoint secret resolution, container hardening, and operator docs for encrypted host volumes.

Reviewed changes

Copilot reviewed 47 out of 50 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/db/schema.ts Re-exports operator audit schema.
src/db/operator-audit-schema.ts Adds operator_audit table schema + constraint/index.
src/db/migrations/meta/_journal.json Registers migration 0018.
src/db/migrations/0018_spotty_blonde_phantom.sql Creates operator_audit table + index.
src/backup/table-order.ts Defines export/wipe order + ephemeral exclusions.
src/backup/sodium-native.d.ts Adds minimal ambient typings for sodium-native.
src/backup/restore-service.ts Implements stage-then-promote restore flow + force rollback envelope.
src/backup/manifest.ts Adds bundle manifest format + parsing/validation + versioning.
src/backup/maintenance.ts Adds durable maintenance flag + pool-safe advisory lock helper.
src/backup/export-service.ts Implements admin-gated streaming encrypted backup creation.
src/backup/db-import.ts Adds NDJSON import + wipe helpers.
src/backup/db-export.ts Adds NDJSON export for all persistent tables.
src/backup/bundle.ts Implements tar bundle writer/reader with KTD11 safety checks.
src/backup/audit.ts Adds operator audit logging helper.
src/backup/tests/routes.test.ts Route-level backup export/restore tests + audit assertions.
src/backup/tests/export-service.test.ts Export-service crypto/bundle round-trip + streaming behavior tests.
src/backup/tests/db-roundtrip.test.ts Export/import round-trip + schema coverage regression guard.
src/backup/tests/crypto.test.ts Crypto round-trip + tamper/wrong-key tests.
src/backup/tests/bundle.test.ts Bundle format + zip-slip/symlink/bounds tests.
src/auth/tests/gating.test.ts Fixes flake by self-seeding admin in suite.
setup.sh Updates first-run checks for Docker-secret-based secrets.
secrets/README.md Documents Docker secrets setup/rotation + local tooling usage.
README.md Updates quickstart/dev docs for Docker secrets + links encryption ops doc.
package.json Adds sodium-native + tar-stream dependencies and types.
next.config.ts Excludes sodium-native from server bundling for runtime addon resolution.
e2e/backup.spec.ts Adds admin backup UI e2e coverage (export + restore flows).
docs/operations/encryption-at-rest.md Adds threat matrix + encrypted host volume guidance + backup caveats.
docs/deployment.md Updates deployment docs for Docker secrets + encrypted volume posture.
Dockerfile Adds docker-entrypoint.sh and sets ENTRYPOINT.
docker-entrypoint.sh Resolves *_FILE secrets and assembles DATABASE_URL at startup.
docker-compose.yml Adds Docker secrets + hardening + encrypted-volume-ready mounts.
CONTRIBUTING.md Updates dev setup instructions for Docker secrets.
components/ui/confirm-dialog.tsx Adds confirmDisabled to support type-to-confirm guards.
bun.lock Locks new dependencies/types.
app/api/admin/backup/restore/route.ts Adds streamed admin restore route + outcome mapping + auditing.
app/api/admin/backup/export/route.ts Adds admin export route supporting form submit + auditing.
app/(auth)/login/login-form.tsx Adds “restored” login callout message.
app/(app)/app-shell.tsx Adds admin nav link to Backup screen.
app/(admin)/backup/restore-panel.tsx Adds restore UI + force-replace confirm + session invalidation.
app/(admin)/backup/page.tsx Adds admin backup page with defense-in-depth gate.
app/(admin)/backup/export-panel.tsx Adds export UI with no-recovery warning + form-submit download flow.
app/(admin)/backup/constants.ts Adds restore outcome copy + force phrase constant.
app/(admin)/backup/backup-panel.tsx Composes export + restore panels.
.gitignore Ignores secrets directory except README.
.env.example Updates env template to reflect Docker-secret-based secrets.
.dockerignore Excludes secrets from build context except README.

Comment thread app/api/admin/backup/export/route.ts
Comment thread app/api/admin/backup/export/route.ts Outdated
Comment thread app/api/admin/backup/restore/route.ts Outdated
Comment thread app/api/admin/backup/restore/route.ts Outdated
Comment thread src/backup/restore-service.ts Outdated
Comment thread src/backup/restore-service.ts Outdated
Comment thread src/backup/maintenance.ts
Comment thread src/backup/db-import.ts Outdated
Comment thread src/backup/db-export.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
README.md (1)

128-144: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Load BETTER_AUTH_SECRET in the host-development flow.

This flow creates and exports only the Postgres secret. Host-side bun run dev and seed-admin do not pass through docker-entrypoint.sh, so secrets/better_auth_secret.txt is never loaded. Add its creation and explicit .env.local/export configuration, matching CONTRIBUTING.md.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 128 - 144, The host-development setup instructions
must also provision and load BETTER_AUTH_SECRET. Update the README flow around
the Postgres secret, DATABASE_URL export, and bun run dev commands to create
secrets/better_auth_secret.txt and explicitly configure or export
BETTER_AUTH_SECRET for host-side dev and seed-admin, matching the established
instructions in CONTRIBUTING.md.
🧹 Nitpick comments (2)
src/backup/export-service.ts (1)

80-91: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Blob stat/read TOCTOU can break a mid-flight export.

size is captured by listUploadBlobs (stat) but the content is opened later in blobEntriesFor (createReadStream). tar-stream requires the streamed byte count to match the declared size exactly, so a blob deleted (e.g. orphan sweep) or resized between listing and streaming produces an ENOENT / size-mismatch that destroys the encrypt stream and fails the download. Confirm uploads under activeStorageRoot() are effectively immutable for the export window, or re-stat at open time and skip/abort deterministically.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backup/export-service.ts` around lines 80 - 91, Update blobEntriesFor to
eliminate the stat/read race between listUploadBlobs and createReadStream: at
stream-open time, re-stat each file under activeStorageRoot and verify it still
exists with the expected size before yielding it. If the file is missing or
resized, apply a deterministic skip or abort behavior that preserves
tar-stream’s exact declared byte count and prevents the encryption stream from
receiving inconsistent data.
app/(admin)/backup/restore-panel.tsx (1)

112-132: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

No abort/timeout on the restore fetch — an indefinite hang looks identical to progress.

postRestore has no AbortController/timeout; if the server stalls or the connection drops mid-stream during a force-replace, await fetch(...) can hang indefinitely with the UI stuck on "Applying changes…" and no way for the operator to cancel or know whether it's still working. This is adjacent to the already-tracked "no crash recovery for interrupted force-restore" finding, but is the client-side counterpart — no signal/escape hatch exists here regardless of what the server does.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(admin)/backup/restore-panel.tsx around lines 112 - 132, Update the
restore flow centered on runRestore and postRestore to use an AbortController
with a finite timeout, passing its signal into the restore fetch. Clear the
timeout when the request settles, and handle abort/timeout results by stopping
the applying timer, returning the UI to a non-progress state, and surfacing a
clear failure outcome so the operator is not left waiting indefinitely.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/`(admin)/backup/export-panel.tsx:
- Around line 46-57: Keep the export action disabled for the entire page session
after a valid submission. Update the status gating in canExport and the onSubmit
flow around ASSUME_STARTED_MS so the transition to "started" does not re-enable
the button; preserve the existing optimistic status readout while preventing any
subsequent concurrent submission.

In `@app/`(admin)/backup/restore-panel.tsx:
- Around line 44-58: Update postRestore to safely transport non-Latin-1
passwords by percent-encoding the password before assigning X-Backup-Password,
then update the restore route handler to decode that header exactly once before
validation. Preserve the existing header-based transport and ensure ASCII
passwords retain their current behavior.

In `@app/api/admin/backup/export/route.ts`:
- Around line 56-72: Guard the success-path recordOperatorEvent call in POST
with the same non-blocking error handling used by the failure paths, such as a
swallowed catch, so audit-write failures cannot discard the successfully created
bundle or alter the export response.
- Around line 95-105: Update readPassword to reject passwords shorter than the
required minimum length before returning them for key derivation, while
preserving the existing type and empty-value validation. Synchronize the
corresponding UI and error-message copy with the same minimum-length
requirement.

In `@app/api/admin/backup/restore/route.ts`:
- Around line 79-89: Guard the success-path recordOperatorEvent call so an
audit-write failure cannot prevent the completed restore outcome from being
returned. Match the existing catch behavior used on the failure path, while
preserving the outcome JSON and status derived from outcome.kind and
OUTCOME_STATUS.

In `@CONTRIBUTING.md`:
- Line 44: Update the setup instructions around DATABASE_URL and
BETTER_AUTH_SECRET to avoid placing shell substitution in .env.local. Provide an
explicit shell export or file-generation command that evaluates
secrets/postgres_password.txt when constructing DATABASE_URL, and show a
concrete BETTER_AUTH_SECRET value rather than an unevaluated expression.
- Around line 38-41: Update the secret-generation commands in the setup
instructions so secrets are created only when their files are absent, preserving
existing PostgreSQL and authentication credentials on reruns. Apply this
behavior to both postgres_password.txt and better_auth_secret.txt, or add an
explicit warning and confirmation before overwriting them.

In `@docker-entrypoint.sh`:
- Around line 27-34: The DATABASE_URL assembly in the entrypoint interpolates
POSTGRES_PASSWORD without enforcing URL-safe characters. Update this block to
either percent-encode the password before constructing DATABASE_URL or validate
the documented hex-only password contract and fail fast on invalid characters;
ensure malformed passwords are never exported in the connection URL.

In `@README.md`:
- Around line 102-105: Update the README backup guidance near the
encryption-at-rest reference to clarify that pg_dump backs up only PostgreSQL
data and excludes uploaded document blobs. Direct operators to use the encrypted
application backup or separately back up the uploads volume so restores include
uploaded documents.
- Around line 55-59: Update the README secret-file creation commands near the
PostgreSQL and Better Auth secret generation steps to explicitly create both
files with owner-only permissions (0600), using a temporary umask or an
equivalent permission-setting approach. Apply the same change to the
corresponding repeated instructions, while preserving the existing secret
generation commands and filenames.
- Around line 128-131: Update the setup commands around
secrets/postgres_password.txt so the openssl generation runs only when the file
does not already exist. Preserve existing passwords across repeated setup passes
while keeping the DATABASE_URL and Docker startup commands unchanged.

In `@setup.sh`:
- Around line 74-86: The setup instructions and secret-file handling must
enforce owner-only permissions. In the secret creation flow around the displayed
Docker secret commands and the preflight checks, set umask 077 before generating
files, then validate existing secrets and reject or chmod them to owner-only
permissions before continuing; retain the existing non-empty-file checks.

In `@src/backup/__tests__/routes.test.ts`:
- Around line 181-185: Update the environment cleanup logic in the shown test
teardown to use delete process.env.DATABASE_URL when ORIGINAL_DATABASE_URL is
undefined, while preserving restoration of ORIGINAL_DATABASE_URL when it exists.

In `@src/backup/db-export.ts`:
- Around line 25-37: Update exportDatabase to execute the entire
EXPORT_TABLE_ORDER iteration and all selectAllRows calls within one REPEATABLE
READ or serializable transaction, using the transaction handle for every read
before producing the Readable stream; preserve the existing table and row export
order and output format.

In `@src/backup/db-import.ts`:
- Around line 66-90: Update the NDJSON import flow around createInterface and
the for-await loop to enforce a hard maximum line length before JSON.parse or
row processing. Fail immediately with a clear error when a line exceeds the
limit, while preserving existing handling for valid lines and blank lines.

In `@src/backup/restore-service.ts`:
- Around line 149-152: Update restore() so withRestoreAdvisoryLock() wraps the
entire restore flow from its beginning, before recreateStagingSchema() or
staging-directory creation. Move enterMaintenance() and its corresponding
exit/cleanup inside that lock, including the force path, so shared restore state
is accessed only while the advisory lock is held.

In `@src/db/migrations/0018_spotty_blonde_phantom.sql`:
- Line 7: Add src/db/migrations/ to the SQLFluff ignore configuration in
.sqlfluff, while preserving the existing PostgreSQL dialect setting, so
generated migration SQL is excluded from linting and does not block just
ci-check.

---

Outside diff comments:
In `@README.md`:
- Around line 128-144: The host-development setup instructions must also
provision and load BETTER_AUTH_SECRET. Update the README flow around the
Postgres secret, DATABASE_URL export, and bun run dev commands to create
secrets/better_auth_secret.txt and explicitly configure or export
BETTER_AUTH_SECRET for host-side dev and seed-admin, matching the established
instructions in CONTRIBUTING.md.

---

Nitpick comments:
In `@app/`(admin)/backup/restore-panel.tsx:
- Around line 112-132: Update the restore flow centered on runRestore and
postRestore to use an AbortController with a finite timeout, passing its signal
into the restore fetch. Clear the timeout when the request settles, and handle
abort/timeout results by stopping the applying timer, returning the UI to a
non-progress state, and surfacing a clear failure outcome so the operator is not
left waiting indefinitely.

In `@src/backup/export-service.ts`:
- Around line 80-91: Update blobEntriesFor to eliminate the stat/read race
between listUploadBlobs and createReadStream: at stream-open time, re-stat each
file under activeStorageRoot and verify it still exists with the expected size
before yielding it. If the file is missing or resized, apply a deterministic
skip or abort behavior that preserves tar-stream’s exact declared byte count and
prevents the encryption stream from receiving inconsistent data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: c7ac0707-d58d-423a-8676-a308ce539ccc

📥 Commits

Reviewing files that changed from the base of the PR and between 644d5a9 and efb882b.

⛔ Files ignored due to path filters (2)
  • .env.example is excluded by !.env*
  • bun.lock is excluded by !**/*.lock, !bun.lock
📒 Files selected for processing (48)
  • .dockerignore
  • .gitignore
  • CONTRIBUTING.md
  • Dockerfile
  • README.md
  • app/(admin)/backup/backup-panel.tsx
  • app/(admin)/backup/constants.ts
  • app/(admin)/backup/export-panel.tsx
  • app/(admin)/backup/page.tsx
  • app/(admin)/backup/restore-panel.tsx
  • app/(app)/app-shell.tsx
  • app/(auth)/login/login-form.tsx
  • app/api/admin/backup/export/route.ts
  • app/api/admin/backup/restore/route.ts
  • components/ui/confirm-dialog.tsx
  • docker-compose.yml
  • docker-entrypoint.sh
  • docs/deployment.md
  • docs/operations/encryption-at-rest.md
  • docs/plans/2026-07-12-001-feat-encryption-at-rest-backups-plan.md
  • e2e/backup.spec.ts
  • next.config.ts
  • package.json
  • secrets/README.md
  • setup.sh
  • src/auth/__tests__/gating.test.ts
  • src/backup/__tests__/bundle.test.ts
  • src/backup/__tests__/crypto.test.ts
  • src/backup/__tests__/db-roundtrip.test.ts
  • src/backup/__tests__/export-service.test.ts
  • src/backup/__tests__/restore-service.test.ts
  • src/backup/__tests__/routes.test.ts
  • src/backup/audit.ts
  • src/backup/bundle.ts
  • src/backup/crypto.ts
  • src/backup/db-export.ts
  • src/backup/db-import.ts
  • src/backup/export-service.ts
  • src/backup/maintenance.ts
  • src/backup/manifest.ts
  • src/backup/restore-service.ts
  • src/backup/sodium-native.d.ts
  • src/backup/table-order.ts
  • src/db/migrations/0018_spotty_blonde_phantom.sql
  • src/db/migrations/meta/0018_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/db/operator-audit-schema.ts
  • src/db/schema.ts

Comment thread app/(admin)/backup/export-panel.tsx
Comment thread app/(admin)/backup/restore-panel.tsx
Comment thread app/api/admin/backup/export/route.ts Outdated
Comment thread app/api/admin/backup/export/route.ts
Comment thread app/api/admin/backup/restore/route.ts
Comment thread src/backup/__tests__/routes.test.ts
Comment thread src/backup/db-export.ts
Comment thread src/backup/db-import.ts
Comment thread src/backup/restore-service.ts Outdated
Comment thread src/db/migrations/0018_spotty_blonde_phantom.sql
@unclesp1d3r
unclesp1d3r force-pushed the feat/encryption-at-rest-backups branch from efb882b to 10272fa Compare July 13, 2026 01:51
@coderabbitai coderabbitai Bot added dependencies Pull requests that update a dependency file frontend priority:high and removed priority:medium labels Jul 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/backup/__tests__/crypto.test.ts (1)

162-162: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard against non-integer length. Buffer.alloc(CHUNK_SIZE * 3.5) throws RangeError if CHUNK_SIZE is ever odd. It's fine while CHUNK_SIZE is a power of two, but the test silently couples to that. Prefer an explicit floor to keep the "3.5 chunks" intent robust to future CHUNK_SIZE changes.

♻️ Suggested tweak
-    const plaintext = Buffer.alloc(CHUNK_SIZE * 3.5);
+    const plaintext = Buffer.alloc(Math.floor(CHUNK_SIZE * 3.5));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backup/__tests__/crypto.test.ts` at line 162, Update the plaintext
allocation in the crypto test to explicitly floor CHUNK_SIZE * 3.5 before
passing it to Buffer.alloc, preserving the intended 3.5-chunk test size while
ensuring the length is always an integer.
docker-compose.yml (1)

33-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale guard description. These are now Docker secret files, not env vars, so the ${VAR:?...} parameter-expansion guard doesn't apply to them. Compose does abort when a secret's source file is missing, but via secret resolution — not a ${VAR:?} check. Reword to avoid sending operators looking for a guard that isn't in this file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 33 - 35, Update the comment above the secret
declarations to describe the actual Docker secret-file resolution behavior:
Compose aborts when secrets/postgres_password.txt or
secrets/better_auth_secret.txt is missing, rather than via a ${VAR:?}
environment-variable guard. Remove the stale claim that these files are
validated by parameter expansion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backup/restore-service.ts`:
- Around line 171-182: Update the catch handling in stageBundle so only
DecryptionAuthError maps to wrong_password_or_tampered, while preserving the
existing VersionMismatchSignal and NotEmptySignal mappings. Allow other DB/IO
failures from importIntoStaging or pool.connect to bubble up, or route them
through a distinct internal-failure outcome.

---

Nitpick comments:
In `@docker-compose.yml`:
- Around line 33-35: Update the comment above the secret declarations to
describe the actual Docker secret-file resolution behavior: Compose aborts when
secrets/postgres_password.txt or secrets/better_auth_secret.txt is missing,
rather than via a ${VAR:?} environment-variable guard. Remove the stale claim
that these files are validated by parameter expansion.

In `@src/backup/__tests__/crypto.test.ts`:
- Line 162: Update the plaintext allocation in the crypto test to explicitly
floor CHUNK_SIZE * 3.5 before passing it to Buffer.alloc, preserving the
intended 3.5-chunk test size while ensuring the length is always an integer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: e8f8d72f-6131-4d9f-b92a-305e44d042da

📥 Commits

Reviewing files that changed from the base of the PR and between efb882b and 10272fa.

⛔ Files ignored due to path filters (2)
  • .env.example is excluded by !.env*
  • bun.lock is excluded by !**/*.lock, !bun.lock
📒 Files selected for processing (48)
  • .dockerignore
  • .gitignore
  • CONTRIBUTING.md
  • Dockerfile
  • README.md
  • app/(admin)/backup/backup-panel.tsx
  • app/(admin)/backup/constants.ts
  • app/(admin)/backup/export-panel.tsx
  • app/(admin)/backup/page.tsx
  • app/(admin)/backup/restore-panel.tsx
  • app/(app)/app-shell.tsx
  • app/(auth)/login/login-form.tsx
  • app/api/admin/backup/export/route.ts
  • app/api/admin/backup/restore/route.ts
  • components/ui/confirm-dialog.tsx
  • docker-compose.yml
  • docker-entrypoint.sh
  • docs/deployment.md
  • docs/operations/encryption-at-rest.md
  • docs/plans/2026-07-12-001-feat-encryption-at-rest-backups-plan.md
  • e2e/backup.spec.ts
  • next.config.ts
  • package.json
  • secrets/README.md
  • setup.sh
  • src/auth/__tests__/gating.test.ts
  • src/backup/__tests__/bundle.test.ts
  • src/backup/__tests__/crypto.test.ts
  • src/backup/__tests__/db-roundtrip.test.ts
  • src/backup/__tests__/export-service.test.ts
  • src/backup/__tests__/restore-service.test.ts
  • src/backup/__tests__/routes.test.ts
  • src/backup/audit.ts
  • src/backup/bundle.ts
  • src/backup/crypto.ts
  • src/backup/db-export.ts
  • src/backup/db-import.ts
  • src/backup/export-service.ts
  • src/backup/maintenance.ts
  • src/backup/manifest.ts
  • src/backup/restore-service.ts
  • src/backup/sodium-native.d.ts
  • src/backup/table-order.ts
  • src/db/migrations/0018_spotty_blonde_phantom.sql
  • src/db/migrations/meta/0018_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/db/operator-audit-schema.ts
  • src/db/schema.ts
🚧 Files skipped from review as they are similar to previous changes (40)
  • app/(admin)/backup/backup-panel.tsx
  • src/auth/tests/gating.test.ts
  • app/(auth)/login/login-form.tsx
  • package.json
  • src/backup/sodium-native.d.ts
  • app/api/admin/backup/export/route.ts
  • components/ui/confirm-dialog.tsx
  • Dockerfile
  • src/db/schema.ts
  • secrets/README.md
  • CONTRIBUTING.md
  • app/(admin)/backup/constants.ts
  • app/(app)/app-shell.tsx
  • app/(admin)/backup/export-panel.tsx
  • app/(admin)/backup/page.tsx
  • docker-entrypoint.sh
  • next.config.ts
  • src/backup/audit.ts
  • src/backup/table-order.ts
  • .gitignore
  • src/backup/maintenance.ts
  • .dockerignore
  • src/backup/export-service.ts
  • src/db/migrations/meta/_journal.json
  • src/backup/tests/export-service.test.ts
  • docs/operations/encryption-at-rest.md
  • e2e/backup.spec.ts
  • src/db/operator-audit-schema.ts
  • src/backup/bundle.ts
  • app/api/admin/backup/restore/route.ts
  • src/backup/db-export.ts
  • src/backup/db-import.ts
  • src/backup/manifest.ts
  • src/backup/tests/bundle.test.ts
  • src/backup/tests/restore-service.test.ts
  • src/backup/tests/db-roundtrip.test.ts
  • src/backup/tests/routes.test.ts
  • README.md
  • app/(admin)/backup/restore-panel.tsx
  • src/backup/crypto.ts

Comment thread src/backup/restore-service.ts Outdated
Add an explicit same-origin guard (src/backup/same-origin.ts) to both admin
backup routes, checked right after the admin gate: they are plain Route
Handlers never routed through Better Auth's own handler, so Better Auth's
origin checks never applied to them, leaving only the session cookie's
sameSite attribute as implicit CSRF protection on the app's highest-blast-
radius endpoints. The guard checks Origin, falling back to Referer then
Sec-Fetch-Site, against the request's own origin and BETTER_AUTH_URL.

Enforce a 12-character minimum on the export password
(src/backup/password-policy.ts), server-side in the export route and
client-side in the export panel; restore intentionally keeps no such
minimum since its password must match whatever encrypted the given bundle.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…aintenance guard, error classification

Four fixes to the restore core from code review:

1. Per-run unique staging/snapshot schema names (restore_staging_<id> /
   restore_snapshot_<id>) plus a single advisory lock held for restore()'s
   ENTIRE body (staging through promote, both empty-instance and force
   paths) instead of just forcePromote's promote step. Two concurrent
   restores now fully serialize instead of racing to promote into public
   at the same time.

2. Boot-time crash recovery: maintenance.ts now records which snapshot
   schema belongs to an in-progress force-restore, and exports
   recoverInterruptedRestore(db, uploadDir) to roll the DB back from that
   snapshot, restore blobs from the newest pre-restore directory, and
   sweep any leftover restore_staging_*/restore_snapshot_* schemas and
   restore-staging-* temp dirs. Wired into a new instrumentation.ts
   register() hook, guarded on NEXT_RUNTIME=nodejs and DATABASE_URL, and
   never throws out.

3. Exported assertWritesAllowed(db) + MaintenanceModeError from
   maintenance.ts for the write-path enforcement worker to consume.

4. restore()'s staging-error classification now only maps genuine crypto
   auth failures (DecryptionAuthError/InvalidHeaderError) to
   wrong_password_or_tampered; every other staging error (ENOSPC,
   malformed tar, path traversal, ...) re-throws so the route's own
   catch-all surfaces a generic failure instead of misleading the
   operator.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
A force-restore sets the durable maintenance flag, but nothing outside
src/backup/maintenance.ts read it, so ordinary writes proceeded
unguarded during the restore's wipe+promote window. Wire
assertWritesAllowed into every write-authorization entry point
(authorize.ts's resolveCreateOwner/authorizeUpdate/
authorizeOwnerOnlyUpdate/authorizeDelete/authorizeAndDeleteParent,
accessory-visibility.ts's authorizeMount, accessories/service.ts's
bespoke requireEditPermission gate, grants.ts's createGrant/
revokeGrant, and the settings/admin-user actions that bypass those
helpers) so every create/update/delete/grant/settings-change/
admin-user-op is blocked while a restore is active. Reads are
untouched.

Made the check itself cheap: assertWritesAllowed now does a single
SELECT instead of also running the restore path's
CREATE SCHEMA/TABLE IF NOT EXISTS ensure-step on every call, and fails
open (allows writes) when Postgres reports the maintenance relation
doesn't exist yet (42P01) - i.e. no restore has ever run on this
instance. The SELECT runs inside db.transaction(...) rather than a
bare db.execute(...): most callers pass an already-open transaction,
and a caught 42P01 from a bare execute would still leave that
transaction aborted at the protocol level (25P02), poisoning every
later statement in it even though the JS exception was swallowed.
Wrapping it lets drizzle's nested-transaction support use a
SAVEPOINT/ROLLBACK TO SAVEPOINT instead, so the caller's transaction
stays usable.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@coderabbitai coderabbitai Bot added priority:medium and removed enhancement New feature or request priority:high labels Jul 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/auth/accessory-visibility.ts (1)

88-95: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Close the maintenance race before the mount write
assertWritesAllowed(tx) only checks the flag once, and restore() does not coordinate with normal write transactions. A restore can flip maintenance on after this read and still let the rest of the transaction continue, so this check needs to happen immediately before the actual mutation or be backed by a lock writers also honor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/auth/accessory-visibility.ts` around lines 88 - 95, Update authorizeMount
so the maintenance-state validation is performed immediately before its actual
database mutation, or reuse a lock shared with restore() and other write
transactions. Ensure the mutation cannot proceed if maintenance begins after the
initial assertWritesAllowed(tx) check, while preserving the existing transaction
flow.
src/backup/__tests__/routes.test.ts (1)

190-239: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Delete DATABASE_URL instead of assigning undefined
process.env.DATABASE_URL = undefined leaves the key present as "undefined" under Node/Bun, so a later lazy reconnect can pick up a bogus URL. Use delete process.env.DATABASE_URL in the no-original branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backup/__tests__/routes.test.ts` around lines 190 - 239, Update the
no-original branch in the afterAll cleanup to remove DATABASE_URL with the
delete operator instead of assigning undefined. Preserve restoring
ORIGINAL_DATABASE_URL unchanged when it exists, so later lazy connections use
the correct configuration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backup/maintenance.ts`:
- Around line 502-547: Update recoverInterruptedRestore so exitMaintenance and
cleanup of the restore snapshot occur only after rollback, blob restoration, and
snapshot removal complete successfully; preserve the maintenance flag and
snapshot schema when any recovery step fails, allowing a later retry. Ensure
sweepLeftoverSchemas does not remove the retained restore_snapshot schema after
a failed recovery, while keeping cleanup behavior for successful recovery.

---

Outside diff comments:
In `@src/auth/accessory-visibility.ts`:
- Around line 88-95: Update authorizeMount so the maintenance-state validation
is performed immediately before its actual database mutation, or reuse a lock
shared with restore() and other write transactions. Ensure the mutation cannot
proceed if maintenance begins after the initial assertWritesAllowed(tx) check,
while preserving the existing transaction flow.

In `@src/backup/__tests__/routes.test.ts`:
- Around line 190-239: Update the no-original branch in the afterAll cleanup to
remove DATABASE_URL with the delete operator instead of assigning undefined.
Preserve restoring ORIGINAL_DATABASE_URL unchanged when it exists, so later lazy
connections use the correct configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: e290a85d-bce7-4f63-9712-ff5c8214f8a8

📥 Commits

Reviewing files that changed from the base of the PR and between 10272fa and 19fc7a9.

📒 Files selected for processing (25)
  • app/(admin)/backup/export-panel.tsx
  • app/(admin)/users/__tests__/actions.test.ts
  • app/(admin)/users/actions.ts
  • app/(app)/settings/__tests__/actions.test.ts
  • app/(app)/settings/actions.ts
  • app/api/admin/backup/export/route.ts
  • app/api/admin/backup/restore/route.ts
  • instrumentation.ts
  • src/auth/accessory-visibility.ts
  • src/auth/authorize.ts
  • src/auth/grants.ts
  • src/backup/__tests__/db-roundtrip.test.ts
  • src/backup/__tests__/export-service.test.ts
  • src/backup/__tests__/maintenance.test.ts
  • src/backup/__tests__/restore-service.test.ts
  • src/backup/__tests__/routes.test.ts
  • src/backup/__tests__/write-path-maintenance-guard.test.ts
  • src/backup/db-import.ts
  • src/backup/export-service.ts
  • src/backup/maintenance.ts
  • src/backup/password-policy.ts
  • src/backup/restore-service.ts
  • src/backup/same-origin.ts
  • src/domain/accessories/service.ts
  • src/domain/action-result.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • app/(admin)/backup/export-panel.tsx
  • app/api/admin/backup/export/route.ts
  • src/backup/export-service.ts
  • app/api/admin/backup/restore/route.ts
  • src/backup/tests/db-roundtrip.test.ts
  • src/backup/tests/export-service.test.ts

Comment thread src/backup/maintenance.ts
…lation & unknown-table tests

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
… JSDoc

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…es + recovery/exhaustiveness hardening

- Unify the empty-instance (F2) and force-replace (F3) restore promote
  paths through the same maintenance+snapshot+rollback envelope
  (forcePromote/emptyInstancePromote -> single `promote`). Closes a TOCTOU
  window where a non-force restore never raised the maintenance flag,
  so a concurrent ordinary write during staging/promote was neither
  blocked nor recoverable if the promote then failed.
- `undoBlobSwap` now logs loudly and re-throws instead of swallowing
  rollback failures. When rollback itself fails inside `promote`, the
  error propagates as a generic thrown error (not a reassuring
  `rolled_back` outcome) and the maintenance flag is deliberately left
  active so writes stay blocked and recovery can retry.
- `recoverInterruptedRestore` only clears the maintenance flag once
  recovery genuinely succeeded or wasn't needed; a partial
  `rollbackLiveFromSnapshot` failure now leaves the flag active and
  preserves the snapshot schema, logging a MANUAL INTERVENTION REQUIRED
  message. `sweepLeftoverSchemas` excludes any snapshot schema still
  referenced by an active flag.
- `restore()`'s staging cleanup now logs drop/rm failures instead of
  swallowing them; `stageBundle`'s `BundleEvent` handling is now an
  exhaustive switch with a `never`-checked default; `_testFaultInjection`
  is now runtime-guarded to refuse outside `NODE_ENV=test`.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…er outcome message + instrumentation test

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…sage out of redundant UI

- justfile test recipe also runs __tests__/instrumentation.test.ts in its
  own process (mock.module is process-global and would bleed into bun test src).
- restore-panel no longer renders outcome.message: it paraphrases the curated
  per-kind copy, so showing both was redundant and tripped a Playwright
  strict-mode duplicate-text match. message stays in the JSON response + the
  client_error fallback.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@coderabbitai coderabbitai Bot added enhancement New feature or request priority:high shared and removed dependencies Pull requests that update a dependency file priority:medium labels Jul 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/backup/restore-service.ts (1)

661-664: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the success-path cleanup best-effort.
If DROP SCHEMA or commitBlobSwap() fails after the restore has already committed, this path turns a successful restore into a generic error and leaves cleanup artifacts behind. Mirror the rollback path here: log cleanup failures and let the successful restore stand.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backup/restore-service.ts` around lines 661 - 664, Update the success
path around the DROP SCHEMA execution and commitBlobSwap call to make cleanup
best-effort: wrap each cleanup operation using the rollback path’s existing
error-logging pattern, log failures, and continue returning the
already-successful restore result instead of propagating cleanup errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/backup/restore-service.ts`:
- Around line 661-664: Update the success path around the DROP SCHEMA execution
and commitBlobSwap call to make cleanup best-effort: wrap each cleanup operation
using the rollback path’s existing error-logging pattern, log failures, and
continue returning the already-successful restore result instead of propagating
cleanup errors.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 447c8aab-0241-498b-8281-32fe4b526436

📥 Commits

Reviewing files that changed from the base of the PR and between 19fc7a9 and 29b9a7a.

📒 Files selected for processing (15)
  • __tests__/instrumentation.test.ts
  • app/(admin)/backup/restore-panel.tsx
  • app/api/admin/backup/export/route.ts
  • app/api/admin/backup/restore/route.ts
  • justfile
  • src/backup/__tests__/crypto.test.ts
  • src/backup/__tests__/db-roundtrip.test.ts
  • src/backup/__tests__/export-service.test.ts
  • src/backup/__tests__/maintenance.test.ts
  • src/backup/__tests__/restore-service.test.ts
  • src/backup/__tests__/routes.test.ts
  • src/backup/crypto.ts
  • src/backup/db-import.ts
  • src/backup/maintenance.ts
  • src/backup/restore-service.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • app/api/admin/backup/export/route.ts
  • app/api/admin/backup/restore/route.ts
  • src/backup/tests/db-roundtrip.test.ts
  • src/backup/tests/restore-service.test.ts
  • app/(admin)/backup/restore-panel.tsx
  • src/backup/tests/routes.test.ts
  • src/backup/db-import.ts
  • src/backup/maintenance.ts
  • src/backup/crypto.ts

@unclesp1d3r unclesp1d3r self-assigned this Jul 13, 2026
…sword, doc clarity (PR review)

Addresses CodeRabbit PR review findings on the deploy/secret-handling
docs and scripts:

- CONTRIBUTING.md: generate secrets/*.txt only when absent instead of
  overwriting them on every setup pass (an existing Postgres volume
  keeps its original password, so a silent overwrite locks devs out).
  Also stop instructing readers to embed unevaluated `$(cat ...)` shell
  substitution directly in .env.local — mise parses it as a literal
  dotenv file and never expands it; show the shell-evaluated
  file-generation commands instead.
- docker-entrypoint.sh: enforce the documented hex-only password
  contract before interpolating POSTGRES_PASSWORD into DATABASE_URL,
  failing fast with a clear message instead of silently exporting a
  connection string a non-hex password (containing @ : / ? # %, etc.)
  would corrupt.
- README.md: create the Docker secret files with owner-only
  permissions (umask 077) in both the quick-start and from-source
  flows; make the developer-loop postgres_password.txt generation
  idempotent for the same reason as CONTRIBUTING.md; clarify that
  `pg_dump` is database-only and does not capture uploaded document
  blobs (they live on the separate uploads volume), pointing readers
  at the in-app encrypted backup or a separate uploads-volume backup.
- setup.sh: print owner-only (umask 077) secret-creation instructions,
  and have the preflight actively tighten existing secret files to
  0600 (best-effort) rather than only checking they're non-empty.

Verified with shellcheck (setup.sh, docker-entrypoint.sh) and
pre-commit run --files, both clean.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…st/migration nits (PR review)

Resolves remaining PR-review findings on the backup backend/tests:

- db-export.ts: correct the exportDatabase doc comment. selectAllRows
  buffers a whole table into memory via a single SELECT before yielding
  any of its rows, so it is table-by-table buffering, not row-level
  streaming from Postgres. Also note that export-service.ts's
  bufferDbExport still buffers the full concatenated NDJSON output for
  its own reasons (tar header byte length), which is a property of that
  caller, not of exportDatabase.
- routes.test.ts: use `delete process.env.DATABASE_URL` instead of
  assigning `undefined`, which coerces to the string "undefined" and
  would leave a bogus DATABASE_URL for whichever test file runs next.

isMaintenanceActive() (maintenance.ts) was NOT removed: it is exercised
directly by maintenance.test.ts and restore-service.test.ts as a flag-state
assertion helper, so it is not dead code. The underlying write-blocking
concern the review raised is already handled independently by
assertWritesAllowed, which is wired into authorize.ts, grants.ts,
accessory-visibility.ts, restore-service.ts, and
domain/accessories/service.ts, with dedicated integration coverage in
write-path-maintenance-guard.test.ts.

The migration/.sqlfluff finding (0018_spotty_blonde_phantom.sql) was
declined: sqlfluff is not wired into this repo's pre-commit hooks or
`just ci-check`, and every existing drizzle-generated migration has the
same quoting/indent/line-length shape, so hand-editing this one
generated file would not fix anything actually blocking CI.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…eview fix (PR review)

- export-panel: keep the export button disabled for the rest of the
  view's lifetime once submitted, not just while status === "pending".
  Previously the button re-enabled the moment the optimistic "started"
  state kicked in (ASSUME_STARTED_MS after submit), letting an admin
  fire a second full-instance encrypted export while the first was
  still streaming server-side.
- restore-panel: reject restore passwords containing characters above
  U+00FF before ever calling fetch(), since Headers/fetch throw
  synchronously for header values outside the Latin-1 byte range
  (curly quotes, CJK, emoji, currency symbols like the euro sign).
  Surfaces an actionable field-level validation error instead of the
  previous opaque client_error fallback.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…mand

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…oded header (PR review)

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@coderabbitai coderabbitai Bot added dependencies Pull requests that update a dependency file and removed shared labels Jul 13, 2026
@unclesp1d3r
unclesp1d3r merged commit e24b9ff into main Jul 14, 2026
7 checks passed
@unclesp1d3r
unclesp1d3r deleted the feat/encryption-at-rest-backups branch July 14, 2026 00:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation enhancement New feature or request frontend infrastructure priority:high security testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate data encryption-at-rest (Postgres/ORM-native preferred)

2 participants