Skip to content

feat: attach documents to firearms (receipts, warranties, ATF forms) - #68

Merged
unclesp1d3r merged 6 commits into
mainfrom
12-attach-documents-to-firearms-receipts-warranties-atf-forms
Jul 12, 2026
Merged

unclesp1d3r merged 6 commits into
mainfrom
12-attach-documents-to-firearms-receipts-warranties-atf-forms

Conversation

@unclesp1d3r

@unclesp1d3r unclesp1d3r commented Jul 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

Lets a firearm's owner attach, view, download, and delete typed documents (receipts, warranties, ATF Form 1/4, manuals, insurance) on the firearm detail view, reusing the storage foundation from #9. Documents are owner-only on every operation (list / upload / view / download / delete) — unlike photos, they never follow the firearm's grants — and are never included in CSV export. Fixes #12.

Impact 24 new files · ~1,724 production LOC · ~1,761 test/e2e LOC (≈1:1) · 1 migration · 1 new dependency (file-type)
Inherent risk surface High — auth/authz, PII (ATF forms), untrusted file upload, in-origin serving of untrusted bytes
Residual risk after mitigations Low–Medium — see Risk Assessment
Suggested review time ~45–60 min following the reviewer's guide
Status just ci-check green (lint · format · typecheck · pre-commit · full unit/integration suite · full Playwright e2e, 33 passed)

Out of scope for review: docs/plans/2026-07-12-001-…encryption…md is a pre-existing sibling commit (#67) that rode along on this branch — not part of this feature.


For Reviewers — suggested review order

Review the security spine first (top), then the mechanical mirror-of-photos work (bottom):

  1. Data model — src/db/inventory-schema.ts (firearmDocument) + src/db/migrations/0017_*.sql. The shape: owner-less firearm child, MIME/docType CHECK constraints, FK ON DELETE CASCADE.
  2. Auth gate — src/auth/authorize.ts (authorizeOwnerOnlyRead). The owner-only read gate; the security core.
  3. Domain service — src/domain/firearm-documents/service.ts ⭐ highest-logic file: magic-byte content sniff, filename sanitization, optimistic-then-locked quota, cleanup-on-rollback.
  4. Serving route — app/api/documents/[id]/route.ts: 404-collapse existence-hiding, inline/attachment disposition, RFC 6266 filenames, nosniff / CSP frame-ancestors / no-store.
  5. UI — app/(app)/firearms/[id]/firearm-documents.tsx + firearm-detail-view.tsx: isOwner section gating + static locked panel, the sandbox="allow-scripts" PDF iframe, the fetch-to-blob View modal with focus trap.
  6. Blob lifecycle — src/storage/document-blobs.ts + the deleteFirearm hook in src/domain/firearms/service.ts (R19 eager cleanup) + orphan-sweep.ts.
  7. Mechanical / low-risk — the __tests__/* and e2e/* files (mirror the sibling photo suites), constants.ts, validate.ts, urls.ts, row.ts.

Request / serve flow

flowchart TB
  UI["firearm-documents.tsx (owner-only section)"] -->|upload/delete| ACT["documents-actions.ts (server action)"]
  UI -->|View / Download| ROUTE["api/documents/[id] route"]
  ACT --> GATEW["authorizeOwnerOnlyUpdate / authorizeDelete"]
  ROUTE --> GATER["authorizeOwnerOnlyRead → 404-collapse"]
  GATEW --> SVC["firearm-documents/service.ts"]
  GATER --> SVC
  SVC --> SNIFF["file-type content sniff + sanitize filename"]
  SVC --> STORE["storage (single blob, off the image pipeline)"]
  SVC --> DB["firearm_document (Drizzle)"]
Loading

What changed

Unit Change
U1 firearm_document table + migration (0017): firearm child, MIME + docType CHECK constraints, sizeBytes > 0, FK ON DELETE CASCADE
U2 Document constants + pure upload validator (all failure codes together)
U3 Single-blob delete, eager firearm-delete blob cleanup so PII blobs aren't left to the bare cascade (R19), orphan-sweep coverage
U4 authorizeOwnerOnlyRead — owner-only read gate (none existed before)
U5 Domain service: magic-byte content sniff (file-type), filename sanitization, optimistic-then-locked quota, cleanup-on-rollback, most-recent-first listing
U6 Authenticated serving route: inline/attachment disposition switch, RFC 6266 filenames, nosniff, CSP frame-ancestors on inline, no-store, 404-collapse hiding existence from non-owners
U7 Owner-only documents UI: View/Download split, hardened fetch-to-blob view modal (<img> / sandboxed PDF <iframe>, error+Download fallback, focus trap), delete confirmation, empty state, static locked panel for non-owners
U8 CSV-exclusion regression guard + end-to-end coverage

Security posture

  • Owner-only authorization before any per-file work on every operation; edit- and view-grantees refused; unseen firearms 404.
  • Content-based MIME validation by magic bytes, not the client-declared type; a sniff throw on a corrupt file degrades to a per-file failure, never an aborted batch.
  • Filename sanitization strips path separators, control chars, and the " delimiter (header-injection defense), truncates surrogate-safe; serving uses RFC 6266 encoding.
  • Hardened inline serving of untrusted bytes: PDF in a sandbox="allow-scripts" iframe (never with allow-same-origin), nosniff, anti-framing CSP, no-store; the View modal fetches then renders so HTTP failures reliably show the error+Download fallback (R21).
  • storageKey never reaches the client — narrowed through toFirearmDocumentRow on both the page load and the upload-action response.
  • Never in CSV export — regression-guarded against an actual exported row (AE4/R11).

Risk Assessment

Factor Level Notes / mitigation
Size 🟠 High 41 files — but a single cohesive feature (8 pre-planned units), not splittable; ~half the LOC is tests
Security 🟠 High surface → 🟢 mitigated Auth/PII/upload/inline-serving; owner-only enforced + tested on every op; content-sniff + hardened sandbox; 4 independent review passes
Complexity 🟡 Medium Concentrated in one service file; mirrors the reviewed firearm-photos sibling's shape
Test coverage 🟢 Low risk ≈1:1 test-to-source; unit + integration (Testcontainers) + e2e; adversarial paths (sniff-throw, rollback, quota race, 404-collapse) explicitly tested
Dependencies 🟢 Low One addition (file-type) for magic-byte sniffing; bodySizeLimit raised 160→270 MB for the document batch

Mitigation summary: the inherent high-risk surface is reduced by (a) mirroring an already-reviewed sibling feature, (b) ≈1:1 adversarial test coverage, (c) four review passes (multi-persona plan review, ce-code-review, pr-review-toolkit, CodeRabbit + Copilot), and (d) a green just ci-check including the full e2e suite with no regressions.

Test coverage

Layer Coverage
Schema/DB constraints (MIME, docType, sizeBytes>0), FK cascade, DOC_TYPES/MIME drift guard
Validation all-codes-together, batch cap, docType guard, surrogate-safe filename sanitizer
Domain service content-sniff mismatch + sniff throw, declared-vs-sniffed MIME, sanitization, owner-only on every op, quota + concurrent race under lock, cleanup-on-rollback, storage.save failure, most-recent-first
Auth owner passes / edit+view grantees refused / stranger→not-found
Serving route disposition switch, RFC 6266 filename, nosniff / CSP / no-store, non-owner + stranger → 404 collapse, 401, malformed/unknown id
Blob lifecycle single-blob delete, multi-document firearm-delete, orphan-sweep preserves referenced blobs
Export documents never in CSV, byte-identical export against a real row (AE4)
E2e empty state → upload → image view + PDF view (sandboxed iframe) → download → delete → grantee lockout; invalid-upload feedback; multi-file batch

Reviewer checklist

Security

  • Every document op authorizes owner-only before any file work (existence-hiding)
  • Serving route collapses non-owner/unseen/malformed to a bare 404 (no existence signal)
  • Inline serving only for whitelisted MIME; nosniff + CSP present; PDF iframe sandbox never combines allow-scripts + allow-same-origin
  • storageKey never crosses to the client (page load and upload response)
  • Uploaded bytes validated by magic-byte sniff, not the declared type

Code quality

  • Blob deletion always runs after the DB commit (no orphaned live rows / missing blobs)
  • No console.log in production code (✅ verified 0)
  • Immutable patterns; no data-testid in the app (ARIA/accessible-names only)

Testing

  • New behavior is covered; edge/error/adversarial paths tested, not just happy path
  • Integration tests gate on DATABASE_URL (Testcontainers); e2e via the Playwright harness

Validation

just ci-check — Biome lint + format, tsc, pre-commit hooks, full bun test suite, and the full Playwright e2e suite (33 passed, 4 skipped demos) — including e2e/firearm-documents.spec.ts with no regression to existing specs. Live browser smoke confirmed boot/serve/auth-gating.

Review history — all findings resolved

Four review passes ran; every valid finding is fixed (commits 9442634 / 93bae78 / 73f7f96), nothing deferred: R19 blob-cleanup gap, the 250 MB-vs-160 MB body-limit conflict, CSP directive reconciliation, an unguarded file-type throw, a git-binary sanitizer, quota-overcount, storageKey client leak, the unreliable PDF <iframe onError>, silent-failure logging, WCAG focus trap, notes maxLength, surrogate-safe truncation, a weak CSV assertion, and PDF-view e2e coverage.

Not code-fixed (with reasons, not residuals): the NotFound-vs-NotAuthorized "leak" is the explicit R9/AE3 design (the serving route is the stricter 404-collapse); the Server-Action body-buffer-before-auth is a pre-existing Next.js property shared with photos, already mitigated by the middleware edge cookie-gate + upload rate limiter, with a streaming Route Handler tracked as a cross-cutting follow-up.

Follow-up / out of scope

Per the plan's Deferred / Open Questions: NFA-trust co-owner read access to ATF Form 1/4, document egress/backup coverage, and finer upload-interaction states. Encryption-at-rest is a design constraint here (blobs stay behind the storage seam; PII columns recorded) and is planned separately (#67).

… downloading, and deleting documents

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Let a firearm's owner attach, view, download, and delete typed documents
(receipts, warranties, ATF Form 1/4, manuals, insurance) on the firearm
detail view, reusing the storage foundation from #9. Closes #12.

Documents are owner-only on EVERY operation (list/upload/view/download/
delete) -- unlike photos, they never follow the firearm's grants -- and are
never included in CSV export.

- New `firearm_document` table + migration: firearm child record, MIME and
  docType CHECK constraints, FK ON DELETE CASCADE (U1).
- Document constants + pure upload validator (U2); owner-only read auth gate
  `authorizeOwnerOnlyRead` (U4).
- Single-blob delete + eager firearm-delete blob cleanup so PII blobs are
  never left to the bare cascade, plus orphan-sweep coverage (U3, R19).
- Domain service: magic-byte content sniff (file-type), RFC-safe filename
  sanitization, optimistic-then-locked quota, cleanup-on-rollback, most-
  recent-first listing (U5).
- Authenticated serving route with an inline/attachment disposition switch,
  RFC 6266 filenames, nosniff, CSP frame-ancestors on inline, no-store, and a
  404-collapse that hides existence from non-owners (U6).
- Owner-only documents UI section with a View/Download split, a hardened
  view modal (img / sandboxed PDF iframe with an error+Download fallback),
  destructive delete confirmation, empty state, and a static locked panel for
  non-owners (U7).
- CSV-exclusion regression guard and end-to-end coverage (U8).

Hardening from the multi-persona plan and code reviews is built in: the R19
blob-cleanup gap, the 250MB Server Action body-limit vs the 160MB config
(raised to 270MB), the CSP directive reconciliation, content sniffing, and a
guarded file-type sniff that can't orphan blobs on a corrupt upload.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Copilot AI review requested due to automatic review settings July 12, 2026 13:39
@unclesp1d3r unclesp1d3r linked an issue Jul 12, 2026 that may be closed by this pull request
2 tasks
@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds owner-only firearm document management across persistence, validation, storage, serving, server actions, UI workflows, cleanup paths, and automated tests. Documents support uploads, inline viewing, downloads, deletion, authorization checks, and CSV exclusion. A separate encryption-backup planning document is also added.

Changes

Firearm document feature

Layer / File(s) Summary
Contracts and persistence
src/db/..., src/auth/..., src/domain/firearm-documents/...
Defines document schema, migration metadata, owner-only authorization, MIME/size/type validation, filename sanitization, client-safe rows, and database constraints.
Domain service and blob lifecycle
src/domain/firearm-documents/service.ts, src/storage/..., src/domain/firearms/service.ts, src/test-support/factories.ts
Implements validated upload, listing, deletion, serving, transactional cleanup, firearm deletion cleanup, orphan-sweep protection, and test data creation.
Serving and application integration
app/api/documents/..., app/(app)/firearms/..., src/domain/firearm-documents/urls.ts, next.config.ts, package.json
Adds authenticated document responses, server actions, owner-gated loading, document UI, modal previews, download links, and upload-size configuration.
Validation and workflow coverage
src/**/__tests__/*, app/**/__tests__/*, e2e/*
Adds database, domain, route, action, storage, authorization, CSV, and end-to-end coverage.
Feature planning and backup design
docs/plans/*
Documents the firearm-document contract and encryption-at-rest backup export, restore, deployment, scope, and deferred-format decisions.

Possibly related PRs

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

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The added encryption-at-rest backup plan file is unrelated to #12 and this firearm-documents feature set. Remove the unrelated backup plan docs change from this PR or split it into a separate follow-up.
Docstring Coverage ⚠️ Warning Docstring coverage is 69.70% 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 relevant, but it does not follow the required Conventional Commits <type>(<scope>): <description> format because the scope is missing. Change it to a scoped Conventional Commit, e.g. feat(firearms): attach documents to firearms.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements upload, download, delete, owner-only authorization, validation, CSV exclusion, authenticated serving, and the required document model for #12.
Description check ✅ Passed The description is comprehensive and covers the feature, issue #12, testing, and risks, but it does not use the exact template headings for Related issue, Test plan, AI disclosure, and Checklist.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch 12-attach-documents-to-firearms-receipts-warranties-atf-forms

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.

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

Adds an owner-only firearm document attachment feature (receipts/warranties/ATF forms/manuals/insurance) on top of the existing storage foundation, with hardened authenticated serving and explicit guarantees that documents never follow firearm sharing grants and never appear in CSV export.

Changes:

  • Introduces firearm_document schema + migration, plus domain constants/validation and an owner-only read gate for document operations.
  • Implements document upload/list/delete service with magic-byte MIME sniffing (file-type), filename sanitization, per-request/per-firearm caps, and best-effort blob cleanup (including eager cleanup on firearm delete and orphan-sweep support).
  • Adds an authenticated serving route with inline/attachment disposition switching and hardened headers, a new detail-view UI section, and unit/integration/e2e coverage (plus Next Server Action body size limit increase).

Reviewed changes

Copilot reviewed 34 out of 36 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/test-support/factories.ts Adds makeFirearmDocument factory helper for tests.
src/storage/orphan-sweep.ts Treats firearm_document blobs as owned keys to avoid false orphan deletion.
src/storage/index.ts Exposes deleteDocumentBlob from the storage module surface.
src/storage/document-blobs.ts Adds best-effort single-blob deletion helper for documents.
src/storage/tests/document-blobs.test.ts Covers document-blob delete behavior, firearm-delete cleanup, and orphan sweep interactions.
src/domain/firearms/service.ts Extends firearm deletion to eagerly delete associated document blobs post-commit.
src/domain/firearm-documents/validate.ts Adds pure validation helpers (MIME allow-list/size + batch cap + docType guard).
src/domain/firearm-documents/urls.ts Adds client-safe view/download URL helpers for the serving route.
src/domain/firearm-documents/service.ts Implements owner-only document upload/list/delete + serving byte resolution with MIME sniffing and rollback cleanup.
src/domain/firearm-documents/sanitize-filename.ts Adds filename sanitization to prevent traversal/header injection and length-cap.
src/domain/firearm-documents/constants.ts Defines allow-list, size/count caps, and controlled docType set.
src/domain/firearm-documents/tests/validate.test.ts Unit tests for document upload validation + batch/docType helpers.
src/domain/firearm-documents/tests/serving.test.ts Tests serving-route behavior (disposition, headers, 404-collapse, auth).
src/domain/firearm-documents/tests/service.test.ts Integration tests for service behavior (auth, sniffing, quota, list order, delete/servable).
src/domain/csv/tests/build.test.ts Adds regression guard that document data never appears in CSV export.
src/db/migrations/meta/0017_snapshot.json Drizzle snapshot for the new firearm_document table.
src/db/migrations/meta/_journal.json Adds migration journal entry for 0017.
src/db/migrations/0017_certain_mentallo.sql Creates firearm_document table + constraints + FK/index.
src/db/inventory-schema.ts Adds firearmDocument table definition with CHECK constraints and index.
src/db/tests/firearm-document.test.ts DB-level constraint + cascade coverage for firearm_document.
src/auth/authorize.ts Adds authorizeOwnerOnlyRead wrapper for owner-only read authorization.
src/auth/tests/authorize.test.ts Tests the new owner-only read authorization behavior.
package.json Adds file-type dependency.
next.config.ts Raises Server Action bodySizeLimit for document batch uploads.
e2e/fixtures/user-pool.ts Seeds users for firearm-documents e2e coverage.
e2e/firearm-documents.spec.ts E2E coverage for upload/view/download/delete and grantee lockout behavior.
docs/plans/2026-07-12-001-feat-encryption-at-rest-backups-plan.md Adds a requirements-only plan doc for encrypted backups/encryption posture (#67 direction).
docs/plans/2026-07-11-001-feat-firearm-documents-plan.md Adds the implementation plan document for firearm documents (#12).
bun.lock Updates lockfile for file-type and related dependency changes.
app/api/documents/[id]/route.ts Implements authenticated, owner-only document serving with hardened headers + 404-collapse.
app/(app)/firearms/firearm-detail-view.tsx Mounts the documents section owner-only; shows locked panel for non-owners.
app/(app)/firearms/documents-actions.ts Adds server actions for document upload/delete + path revalidation + rate limiting.
app/(app)/firearms/[id]/page.tsx Loads documents for owners only and narrows the shape sent to the client (no storageKey).
app/(app)/firearms/[id]/firearm-documents.tsx Implements the owner-only documents UI, modal viewing, and delete confirmation.
app/(app)/firearms/tests/documents-actions.test.ts Unit tests for server action boundaries (session gating, shaping, ActionResult mapping).

Comment thread app/(app)/firearms/documents-actions.ts
Comment thread src/db/__tests__/firearm-document.test.ts
Comment thread e2e/firearm-documents.spec.ts

@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: 9

🧹 Nitpick comments (3)
src/domain/firearm-documents/constants.ts (1)

16-22: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Guard the hand-synced MIME/docType allow-lists against drift.

ALLOWED_MIME_TYPES and DOC_TYPES must match the DB CHECK constraints in 0017_certain_mentallo.sql exactly, and the docstring already flags this as manually maintained. A cheap regression test (or a script run in CI) asserting the two lists are identical would catch drift before it becomes a silent upload rejection or a DB-level acceptance the domain layer disallows.

♻️ Example parity check
// e.g. in a db-level test, query information_schema / pg_constraint for the
// CHECK constraint's allowed values and assert equality with ALLOWED_MIME_TYPES / DOC_TYPES.

Also applies to: 48-61

🤖 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/domain/firearm-documents/constants.ts` around lines 16 - 22, Update the
validation/test coverage for ALLOWED_MIME_TYPES and DOC_TYPES to compare both
lists against the allowed values in the 0017_certain_mentallo.sql DB CHECK
constraints, asserting exact parity. Use an existing DB-level test or CI script
and fail when either domain allow-list or constraint values drift.

Source: MCP tools

src/db/inventory-schema.ts (1)

433-447: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a parity test between the CHECK lists and TS constants.

firearm_document_mime_type_valid and firearm_document_doc_type_valid are hand-synced copies of ALLOWED_MIME_TYPES/DOC_TYPES (per the comments at lines 436-437 and 443-444, since SQL can't import the TS constant). Nothing currently pins these two sources together, so a future edit to one list without the other fails silently until an insert with the new value hits the DB constraint in production.

Consider adding a test (e.g. in the src/db/__tests__ schema suite) that introspects the constraint definition via pg_get_constraintdef and asserts it matches the TS constant arrays, so drift is caught at test time instead of at insert time.

🤖 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/db/inventory-schema.ts` around lines 433 - 447, Add a schema parity test
in the database schema test suite that introspects the definitions of
firearm_document_mime_type_valid and firearm_document_doc_type_valid via
pg_get_constraintdef, parses their allowed values, and asserts they match
ALLOWED_MIME_TYPES and DOC_TYPES. Ensure the test covers both CHECK constraints
so future changes to either the SQL lists or TypeScript constants fail during
testing.
src/domain/firearm-documents/__tests__/service.test.ts (1)

1-283: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

LGTM on the coverage provided. One gap worth noting: no test exercises a mixed valid/invalid batch near the per-firearm cap — see the corresponding comment on service.ts's optimistic quota check, which this suite doesn't currently catch.

🤖 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/domain/firearm-documents/__tests__/service.test.ts` around lines 1 - 283,
Add a service test near the existing per-firearm cap coverage that pre-fills a
firearm to one slot below MAX_DOCUMENTS_PER_FIREARM, then submits a mixed batch
containing one valid document and one invalid document. Assert the valid item
succeeds, the invalid item is rejected with its validation code, and the firearm
retains only the valid new document without an optimistic quota rejection.
🤖 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/`(app)/firearms/[id]/firearm-documents.tsx:
- Around line 184-205: Update the modal flow around the viewTarget focus effects
and dialog markup to trap Tab focus within the modal and make the obscured page
inert while it is open. Reuse the established dialog primitive if available,
preserving initial focus on viewCloseRef, Escape dismissal via
setViewTarget(null), and restoration through viewRestoreFocusRef; apply the same
behavior to the additional affected modal section.
- Around line 500-507: Update the document viewer flow around the iframe and its
view-target state to perform an explicit preflight/status check on
documentViewUrl(viewTarget.id) before rendering the iframe, treating non-success
responses and unreadable/corrupt PDFs as load failures. Use the result to show
the existing fallback deterministically, and retain the iframe only for
documents that pass validation rather than relying on its onError handler.
- Around line 341-349: Define or reuse a shared maximum-length constant for
document notes, then apply it as the maxLength prop on the Textarea in the notes
field. Keep the existing notes state binding and upload behavior unchanged, and
use the same shared limit wherever document notes are validated or persisted.

In `@app/`(app)/firearms/documents-actions.ts:
- Around line 71-73: Update the success return in the document action around
createDocuments so each result’s full FirearmDocument is mapped to a client DTO
containing only the fields required by the UI, excluding storageKey and other
internal fields. Preserve the existing ok/data response shape and revalidation
behavior.

In `@docs/plans/2026-07-12-001-feat-encryption-at-rest-backups-plan.md`:
- Around line 59-64: Update the force-replace restore requirements in R7 and the
corresponding restore flow to acquire a maintenance/exclusive-write lock before
snapshotting or wiping. Snapshot both Postgres data and the upload/blob volume,
then restore the bundle atomically; on any failure, roll back both snapshots
together so database rows and blobs remain consistent and concurrent writes
cannot cause data loss.

In `@e2e/firearm-documents.spec.ts`:
- Around line 76-124: Extend the PDF test flow around the existing “download the
PDF via its Download control (F4)” step to open the PDF’s view control before
downloading it. Assert the PDF dialog is visible, contains the inline iframe,
and that the iframe has the expected pinned sandbox value; then close the dialog
and preserve the existing download assertions unchanged.

In `@src/domain/csv/__tests__/build.test.ts`:
- Around line 31-58: Update the test around buildInventoryCsv so the document is
attached to a magazine included in the exported inventory row, ensuring the
before and after CSV outputs exercise actual row serialization while retaining
the document-field leakage assertions. Use the existing magazine factory and
makeFirearmDocument flow, and keep the test’s byte-identical expectation.

In `@src/domain/firearm-documents/sanitize-filename.ts`:
- Around line 41-58: Update sanitizeFilename to prevent truncation from
returning a lone UTF-16 surrogate. After both the ordinary length limit and the
extension-preserving slice in sanitizeFilename, adjust the cut boundary backward
when it lands between a high and low surrogate, while preserving the existing
fallback, extension, and maximum-length behavior.

In `@src/domain/firearm-documents/service.ts`:
- Around line 148-159: Update the optimistic quota check around existingCount
and inputs so it short-circuits only when the firearm is already at
MAX_DOCUMENTS_PER_FIREARM capacity, rather than counting raw submitted inputs.
Leave the authoritative transaction check using prepared.length unchanged,
allowing mixed-validity batches to validate and partially succeed.

---

Nitpick comments:
In `@src/db/inventory-schema.ts`:
- Around line 433-447: Add a schema parity test in the database schema test
suite that introspects the definitions of firearm_document_mime_type_valid and
firearm_document_doc_type_valid via pg_get_constraintdef, parses their allowed
values, and asserts they match ALLOWED_MIME_TYPES and DOC_TYPES. Ensure the test
covers both CHECK constraints so future changes to either the SQL lists or
TypeScript constants fail during testing.

In `@src/domain/firearm-documents/__tests__/service.test.ts`:
- Around line 1-283: Add a service test near the existing per-firearm cap
coverage that pre-fills a firearm to one slot below MAX_DOCUMENTS_PER_FIREARM,
then submits a mixed batch containing one valid document and one invalid
document. Assert the valid item succeeds, the invalid item is rejected with its
validation code, and the firearm retains only the valid new document without an
optimistic quota rejection.

In `@src/domain/firearm-documents/constants.ts`:
- Around line 16-22: Update the validation/test coverage for ALLOWED_MIME_TYPES
and DOC_TYPES to compare both lists against the allowed values in the
0017_certain_mentallo.sql DB CHECK constraints, asserting exact parity. Use an
existing DB-level test or CI script and fail when either domain allow-list or
constraint values drift.
🪄 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: d4f51a96-19fe-4976-83d8-0d57c5b8d9e3

📥 Commits

Reviewing files that changed from the base of the PR and between 39aa5c8 and 2dd73f1.

⛔ Files ignored due to path filters (2)
  • bun.lock is excluded by !**/*.lock, !bun.lock
  • e2e/fixtures/sample-document.pdf is excluded by !**/*.pdf
📒 Files selected for processing (34)
  • app/(app)/firearms/[id]/firearm-documents.tsx
  • app/(app)/firearms/[id]/page.tsx
  • app/(app)/firearms/__tests__/documents-actions.test.ts
  • app/(app)/firearms/documents-actions.ts
  • app/(app)/firearms/firearm-detail-view.tsx
  • app/api/documents/[id]/route.ts
  • docs/plans/2026-07-11-001-feat-firearm-documents-plan.md
  • docs/plans/2026-07-12-001-feat-encryption-at-rest-backups-plan.md
  • e2e/firearm-documents.spec.ts
  • e2e/fixtures/user-pool.ts
  • next.config.ts
  • package.json
  • src/auth/__tests__/authorize.test.ts
  • src/auth/authorize.ts
  • src/db/__tests__/firearm-document.test.ts
  • src/db/inventory-schema.ts
  • src/db/migrations/0017_certain_mentallo.sql
  • src/db/migrations/meta/0017_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/domain/csv/__tests__/build.test.ts
  • src/domain/firearm-documents/__tests__/service.test.ts
  • src/domain/firearm-documents/__tests__/serving.test.ts
  • src/domain/firearm-documents/__tests__/validate.test.ts
  • src/domain/firearm-documents/constants.ts
  • src/domain/firearm-documents/sanitize-filename.ts
  • src/domain/firearm-documents/service.ts
  • src/domain/firearm-documents/urls.ts
  • src/domain/firearm-documents/validate.ts
  • src/domain/firearms/service.ts
  • src/storage/__tests__/document-blobs.test.ts
  • src/storage/document-blobs.ts
  • src/storage/index.ts
  • src/storage/orphan-sweep.ts
  • src/test-support/factories.ts

Comment thread app/(app)/firearms/[id]/firearm-documents.tsx
Comment thread app/(app)/firearms/[id]/firearm-documents.tsx
Comment thread app/(app)/firearms/[id]/firearm-documents.tsx
Comment thread app/(app)/firearms/documents-actions.ts Outdated
Comment thread docs/plans/2026-07-12-001-feat-encryption-at-rest-backups-plan.md
Comment thread e2e/firearm-documents.spec.ts
Comment thread src/domain/csv/__tests__/build.test.ts
Comment thread src/domain/firearm-documents/sanitize-filename.ts
Comment thread src/domain/firearm-documents/service.ts Outdated
…sanitizer

Address PR review findings:
- deleteFirearm's doc comment falsely claimed the owning-user-account clause
  of R19 "rides this same path" and runs the blob-cleanup hook. It does not:
  user deletion is a raw DB delete whose owner_id FK cascade drops firearm and
  firearm_document rows directly, bypassing deleteFirearm(). Corrected the
  comment to state the gap honestly (orphanSweep is the backstop; a dedicated
  account-deletion cleanup is follow-up; pre-existing for photos too).
- sanitizeFilename now also strips the double-quote (the Content-Disposition
  quoted-string delimiter), completing its stated header-injection defense
  (KTD6) rather than relying solely on the serving route's escaping.
- Add the missing dedicated unit test for the security-critical sanitizer,
  covering separators, C0/DEL controls, quotes, fallback, truncation, and
  non-ASCII preservation.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Resolve every valid finding from the multi-agent PR review — no deferrals.

Code hardening:
- createDocuments: split validation from blob writes so the optimistic quota
  check counts only files that would persist (a mixed valid/invalid batch near
  the cap is no longer rejected wholesale), and replace the documented-
  unreachable content-sniff guard with a clean `sniffAllowedMime` helper
  returning `AllowedMimeType | null`.
- Extract the client-safe row shape + narrowing constructor into `row.ts`
  (`toFirearmDocumentRow`) as the single enforcement point that keeps
  `storageKey` off the client, and narrow the upload action's response through
  it too (it previously returned the full row incl. storageKey to the client).
- View modal: fetch the document and render from an object URL instead of
  pointing <img>/<iframe> src directly at the endpoint — an <iframe> does not
  fire `error` for HTTP 4xx/5xx, so a failed PDF view (deleted/revoked/missing
  blob) now reliably shows the error + Download fallback (R21), with an explicit
  loading state. The `allow-scripts`-only sandbox (R15 binding control) is
  unchanged.
- Log unmapped errors in `toActionError` and the getServableDocument blob read
  (context only, never filename/notes PII) so PII-feature failures leave a trail.
- Correct the deleteFirearm comment (user-account deletion is a raw FK cascade
  that bypasses the app cleanup hook; orphanSweep is the backstop).
- Harden the filename sanitizer to strip the double-quote; cap notes length;
  align DOWNLOAD_LINK_CLASS with the Button secondary variant.

Tests (+~30): dedicated sanitizer suite; fileTypeFromBuffer throw; storage.save
failure; transaction-rollback blob cleanup; declared-vs-sniffed MIME; unknown-id
delete; mixed-batch quota regression; concurrent quota race; stranger→404
collapse; DOC_TYPES/MIME drift guard; multi-document firearm-delete; row
serialization. E2e: invalid-upload feedback + multi-file batch.

Chore: run `biome migrate` to align biome.json's schema with the pinned
@biomejs/biome 2.5.2 (dependabot #60 left it at 2.5.1), unblocking lint.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@coderabbitai coderabbitai Bot added the documentation Improvements or additions to documentation label Jul 12, 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/domain/firearm-documents/__tests__/service.test.ts (1)

75-90: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

mock.module("file-type", ...) is never restored — verify it doesn't leak into sibling test files.

Bun's mock.module() overrides are not undone by mock.restore() and are documented/known to persist across test files run in the same process (oven-sh/bun#12823 is still open). This mock delegates to the real fileTypeFromBuffer for all non-poisoned buffers, so it's likely benign today, but it's an ordering-dependent risk if another spec file (e.g. serving.test.ts, validate.test.ts) also imports file-type and runs in the same bun test process. Contrast with the LocalFilesystemAdapter.prototype.save spy elsewhere in this file, which is properly scoped with mockRestore() in a finally.

Consider using --preload for this mock, or confirming this repo's CI runs each spec file in an isolated process/worker.

🤖 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/domain/firearm-documents/__tests__/service.test.ts` around lines 75 - 90,
Prevent the file-type override created near capturedFileTypeFromBuffer and
FILE_TYPE_THROW_MARKER from leaking into sibling test files. Prefer moving this
mock setup into the repository’s preload mechanism, or verify and enforce
isolated test-file processes/workers in the test configuration; preserve the
forced-throw behavior for this suite while ensuring the override is not shared
across suites.
src/domain/firearm-documents/__tests__/sanitize-filename.test.ts (1)

55-67: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add truncation coverage for surrogate-pair (emoji) filenames.

All length-cap tests use plain ASCII. String.prototype.slice() counts UTF-16 code units, not code points, so a truncation boundary landing inside a surrogate pair (e.g. an emoji near position 200) would split it into a lone surrogate — this is a known open finding for this feature (surrogate-pair splitting during truncation can break downloads/previews for emoji-containing names). Add a case with a repeated multi-byte character crossing MAX_FILENAME_LENGTH to lock in correct (or corrected) behavior.

test("does not split a surrogate pair when truncating (emoji at the boundary)", () => {
  const long = `${"🔥".repeat(250)}.pdf`; // 250 astral chars = 500 UTF-16 units
  const out = sanitizeFilename(long);
  expect(out.length).toBeLessThanOrEqual(200);
  expect(out).not.toMatch(/[\uD800-\uDBFF]$/); // no dangling high surrogate
});
🤖 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/domain/firearm-documents/__tests__/sanitize-filename.test.ts` around
lines 55 - 67, Add a test in the sanitizeFilename length-cap coverage using
repeated emoji characters that cross MAX_FILENAME_LENGTH, and assert the result
stays within the cap without ending in a dangling high surrogate. Update
sanitizeFilename truncation logic as needed so truncation preserves complete
surrogate pairs while retaining existing ASCII and extension behavior.
🤖 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/domain/action-result.ts`:
- Around line 27-32: Update the unhandled-error logging in the action-result
flow to avoid passing the raw error object to console.error. Log only a
minimized, sanitized shape containing the error name, message, and stack, while
preserving the existing generic user-facing response.

---

Nitpick comments:
In `@src/domain/firearm-documents/__tests__/sanitize-filename.test.ts`:
- Around line 55-67: Add a test in the sanitizeFilename length-cap coverage
using repeated emoji characters that cross MAX_FILENAME_LENGTH, and assert the
result stays within the cap without ending in a dangling high surrogate. Update
sanitizeFilename truncation logic as needed so truncation preserves complete
surrogate pairs while retaining existing ASCII and extension behavior.

In `@src/domain/firearm-documents/__tests__/service.test.ts`:
- Around line 75-90: Prevent the file-type override created near
capturedFileTypeFromBuffer and FILE_TYPE_THROW_MARKER from leaking into sibling
test files. Prefer moving this mock setup into the repository’s preload
mechanism, or verify and enforce isolated test-file processes/workers in the
test configuration; preserve the forced-throw behavior for this suite while
ensuring the override is not shared across suites.
🪄 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: 1969ce70-d31f-4945-8365-f71930775b6f

📥 Commits

Reviewing files that changed from the base of the PR and between 2dd73f1 and 93bae78.

📒 Files selected for processing (18)
  • app/(app)/firearms/[id]/firearm-documents.tsx
  • app/(app)/firearms/[id]/page.tsx
  • app/(app)/firearms/documents-actions.ts
  • app/(app)/firearms/firearm-detail-view.tsx
  • biome.json
  • e2e/firearm-documents.spec.ts
  • src/db/__tests__/firearm-document.test.ts
  • src/domain/action-result.ts
  • src/domain/firearm-documents/__tests__/row.test.ts
  • src/domain/firearm-documents/__tests__/sanitize-filename.test.ts
  • src/domain/firearm-documents/__tests__/service.test.ts
  • src/domain/firearm-documents/__tests__/serving.test.ts
  • src/domain/firearm-documents/constants.ts
  • src/domain/firearm-documents/row.ts
  • src/domain/firearm-documents/sanitize-filename.ts
  • src/domain/firearm-documents/service.ts
  • src/domain/firearms/service.ts
  • src/storage/__tests__/document-blobs.test.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/domain/firearm-documents/sanitize-filename.ts
  • src/domain/firearm-documents/constants.ts
  • src/domain/firearms/service.ts
  • src/storage/tests/document-blobs.test.ts
  • app/(app)/firearms/documents-actions.ts
  • src/db/tests/firearm-document.test.ts
  • app/(app)/firearms/[id]/page.tsx
  • app/(app)/firearms/[id]/firearm-documents.tsx
  • src/domain/firearm-documents/service.ts
  • src/domain/firearm-documents/tests/serving.test.ts

Comment thread src/domain/action-result.ts
- Trap Tab focus inside the View modal (WCAG 2.2 AA), mirroring ConfirmDialog;
  preserves initial focus, Escape, and focus restoration, sandbox unchanged.
- Bound the notes Textarea with maxLength using the shared MAX_NOTES_LENGTH.
- sanitizeFilename: truncate by code point (Array.from) so a surrogate pair is
  never split at the length cap; add astral-char tests.
- firearm-document schema test: use a flat storage key matching generateKey's
  production shape (no docs/ prefix orphanSweep would not expect).
- CSV-exclusion test: link the firearm to a magazine so it appears in an actual
  exported row, making the byte-identical no-leak assertion meaningful.
- e2e: open the PDF in the View modal and assert the sandboxed inline iframe
  (sandbox="allow-scripts"), covering the highest-risk inline-serving path.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@unclesp1d3r unclesp1d3r self-assigned this Jul 12, 2026
@unclesp1d3r
unclesp1d3r merged commit 644d5a9 into main Jul 12, 2026
7 checks passed
@unclesp1d3r
unclesp1d3r deleted the 12-attach-documents-to-firearms-receipts-warranties-atf-forms branch July 12, 2026 20:01
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:medium security shared testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Attach documents to firearms (receipts, warranties, ATF forms)

2 participants