Asset Vault

Case studyInvoice & inventory platformProject 02

Tested · CI green · access-control fixes merged 2026-10-24 · not deployed (portfolio build) · public repository

Screenshot · synthetic data Asset Vault invoices screen in dark mode, running on synthetic data

Summary

Asset Vault is an invoicing, inventory and multi-store management platform. Its value as engineering evidence is less the feature list than the measured, commit-by-commit hardening campaign of 2026-08-31 to 2026-09-07, and the decisions made along the way, including removing a feature rather than describing it as working.

The problem and who it serves

Small multi-store retailers run invoicing, stock and store operations in disconnected tools: totals that don’t match the books, counts that drift, no single view of the business.

Small retailers with more than one location who need invoices, stock and transfers in one place. It is a self-hostable portfolio build; it has no hosted instance or production users.

My role

Solo. I own the product scope, the data model, the identity migration from hand-rolled JWT to a managed provider, and the hardening pass. Implementation was AI-assisted under my direction; the hardening pass in particular was executed step by step with the type-error and test counts rechecked after every commit, and those counts are recorded in the commit bodies.

Architecture

All browser traffic is REST. A tRPC layer exists on the server (4 routers, 15 procedures) but the shipped UI does not call it, which is why this page no longer describes the platform as tRPC-based. Data is scoped per user: every record carries its owner’s id. An organization-membership feature exists but does not partition data.

Decisions and trade-offs

  • Managed identity over hand-rolled auth. A migration drops the password, reset-token and lockout columns and adds the Clerk user id; the app refuses to render without the publishable key.
  • Delete the fake 2FA. The 2FA dialog returned success while storing nothing, and a flag in the app’s own table could never gate a managed-identity login. After first wiring it, I recognised it as unfixable in place and removed 555 lines, keeping the column for a later migration.
  • Split build from typecheck, then gate. The build script was tsc -b && vite build, which emitted nothing under 407 errors. It was split into build / typecheck / strict build; the count was reported non-gating until it reached zero, then gated in CI. 407 → 0 on the frontend and 12 → 0 on the backend, no suppressions, no any.
  • Dependency remediation without force. 195 advisories, 5 critical, including a route-protection bypass in the pinned authentication library. Upgrade the library, audit-fix without --force, defer breaking majors for separate testing, recheck build and tests after each step: backend 69 → 31 advisories (critical 2 → 0), frontend 24 → 6.
  • Validation as data during render. Frontend money validation that threw on inputs the backend rejects was changed to return verdicts, because it runs inside a memo during render. Arithmetic untouched; 19 tests.
  • One invoice-money implementation. Invoice validation and arithmetic had drifted apart across hand-synced copies in the API, the web app and the invoice form’s submit path. They now share one dependency-free module (PR #6, merge commit e1dabf3, 2026-10-31). Unifying them exposed the drift: exact half-cent amounts sometimes rounded down, against the documented half-away-from-zero rule (now exact integer arithmetic; amounts change by one cent only in those cases), the web app’s validation messages differed from the API’s, and the form’s summary could show stale totals. A follow-up (PR #8, merge commit d213e7e) bounded every input and computed amount by what the numeric(12,4) columns can store, so a create or update over the limit writes nothing; kept tax and discount rates at four decimals through calculation, storage, reload and edit (one migration widens the two rate columns); and made a line with data but no description block submit instead of being dropped silently. It also fixed invoice updates that include lines, which failed on main.
  • Guard tests that parse the source. A stale strip-list silently discarded 21 of 26 real settings fields. The fix extracts a payload builder with 40 tests and a guard that imports the source file as text and parses its declared fields; verified by reintroducing the bug (6 tests fail).
  • Access-control review, with real-database proof. Re-testing the app against a real PostgreSQL database found that the auth guard decoded tokens without verifying their signature, that any signed-in account could list, edit or delete every other account, that storage keys had no ownership check, and that organization membership could be reactivated or escalated by non-members (an admin could also remove the sole owner). Each was reproduced first, then fixed with a positive-control test beside every denial. Because Clerk is mocked in that suite, a separate offline test runs the real SDK with generated keys; it also caught a result-shape mismatch that would have rejected every valid user. The fixes are merged into the repository, not deployed. Reverting them would restore the vulnerabilities, so the README’s recovery guidance is to keep a failed release out of service and fix forward, never to roll back past this change.

Verified results

MeasureBefore → afterEvidence
Strict TypeScript errors407 → 0 frontend; 12 → 0 backendCommit chain ac8d6c2 … 4a77e83, b94106a; typecheck gated in CI (57a2a36)
Backend tests42 passing / 30 failing → 147/147 → 160/160Commits 882f947 … 0e03727; CI run 34171675761, 2026-09-07: 160 passed, 14 suites. These mock the database.
Access-control tests, real PostgreSQLnone → 45/45 (16 of the first 42 failed on the original code)Merge commit 93bdb9c; CI run 40120871037, 2026-10-24. Schema built from the 40 migrations; Clerk verification mocked in this suite
Real Clerk SDK token testsnone → 13/13 (offline, generated keys)Same run; backend unit suite 173 passed, 15 suites
Invoice-money tests, real PostgreSQLnone → 8 (PR #6; on the previous code, exactly the two half-cent cases fail) → 14 (PR #8: storage limits, four-decimal rates, rejected writes leave nothing behind)CI run 41232428609 on main f9bcc74, 2026-10-31: real-database suites 111 passed across 9 files, including 48 access-control and 14 invoice-money
Frontend testssuite would not compile → 98/98 → 126/126Commits 24279fb … cd7f0b9; current count from CI run 41232428609
Container imagesnot built in CI → both images built in CI; the frontend image is run and smoke-checkedPR #7; CI run 41232428609: a build without the identity key is refused, the running container serves the app for the deep link /invoices/create, a 404 for a missing asset and compressed JavaScript, and the Compose file validates
Release migrationsthe production image could not run its migrations, and the API started on any schema → the image runs the compiled migrations as a separate step, and the API refuses to start while one is pendingPR #10; CI job on a disposable PostgreSQL, run 41232428609: an outdated schema is refused, the documented image command applies the migration, a rerun changes nothing, a failing migration stops the release before the API starts, and existing invoice values and totals are unchanged
Full stack in CInot run together → API image in production mode, migrated PostgreSQL and the built frontendPR #11; run 41232428609: 28 API checks with real RS256 token verification (keys generated per run). The Clerk user lookup is stubbed and browser sign-in is not covered
Initial JavaScript652 kB → 314 kB gzipped (2,348 → 1,030 kB raw)PR #11; route-level code splitting, same build configuration before and after; all 36 routes checked for their own chunk in CI, and a failed chunk shows a reload screen
Ignored inputsfour API inputs were accepted and ignored (store activeOnly filter, per-store inventory valuation, the SMTP connection test used stored credentials, object input stringified as "[object Object]") → each honoured or rejected, with ownership checksPR #16; regression tests on real PostgreSQL that fail on the previous code (1/5, 5/8 and 26/32 red before)
Server behaviourdisallowed CORS origin returned 500; SIGTERM ignored (killed after 10 s); HTTPS redirect built from the Host header; X-Forwarded-* trusted from anyone → 403 JSON; bounded graceful drain; redirect to the configured origin only; proxies trusted only when configuredPR #14; measured in the API container in CI: in-flight request completed, new connections refused 54 ms after the signal, container exited 0 in 2.3 s against an 8 s bound; Compose stack behind a TLS proxy verified end to end
Backend lint1,624 errors and 289 warnings, step non-gating → 0 and 0, gatingPRs #13, #15, #17 and #18; typed at the boundaries, no rules disabled, no files excluded, four single-line exceptions each with a reason
Defects the typing exposedsales of inactive items reported success without changing stock; locked identity-provider accounts were accepted; invoice statistics always 0; a saved payment-method note and 19 saved settings silently dropped; the new SMTP test could reach internal addresses → all fixedPR #18, run 41232428609; each fix is its own commit with a test that fails on the previous code; the SMTP finding came from the independent review and was closed against 60 address spellings
SMTP connection testsockets could outlive the result; credentials could go over plain TCP; host text reached logs; no route limit; request-body logs redacted only exact key names → socket owned and destroyed at one deadline, TLS required, host validated, per-user, per-address and in-flight limits, credentials redacted under any key namePR #19, run 41232428609; found by an independent review of the path and the new suite's no-secret-in-logs check; reviewed again at the merged head with no findings
Clean-clone buildbroken (no output under type errors; wrong prod entry; env examples missing keys) → buildsCommits ac8d6c2, 21db0e4, 0fde86e
Critical advisoryauth-library route-protection bypass → remediatedCommit 40c15d5, 2026-08-31
Critical dependency alerts2 open (proxy-addr, vitest) → patched on main; 0 critical in npm auditMerge commit 93bdb9c; the GitHub alerts were still listed open shortly after the merge, pending its rescan
CInone → green; 48 of the last 49 main-branch runs green on 2026-10-31GitHub Actions since 2026-08-31

Limitations and remaining work

  • Not deployed. CI runs the API image against a migrated PostgreSQL with the built frontend, but the Clerk user lookup is stubbed there and browser sign-in is untested; covering it needs a Clerk development instance, its keys as CI secrets and a test user. No hosted instance or production users exist.
  • The 160 original backend tests mock the database. The real-PostgreSQL suites in CI have 111 tests across 9 files: 48 for access control, 14 that create and update invoices through the API and check the stored totals, limits and rates, and the rest for stock sales, store filtering and valuation, the SMTP connection test and its rate limits, saved settings and the HTTP stack (CORS, proxy trust, shutdown). Clerk token verification is mocked in them; a separate offline test exercises the real SDK. Neither has been run against a live Clerk instance.
  • Data is scoped per user. An organization-membership feature exists but does not partition data; adding a member by id needs no consent from that user. An access-control review found and fixed a token-verification flaw and three cross-account holes (merged 2026-10-24). The full-stack check and a final review then found that creating an invoice accepted another user's client or inventory item and returned that record; both are fixed with tests (merged 2026-10-31).
  • API keys can be created but authenticate nothing, the IP allow/deny middleware is never registered, and multipart upload is rejected by a content-negotiation interceptor.
  • Both critical dependency alerts are patched on main; high-severity audit findings remain and are tracked, not fixed.
  • Every currency is stored with two decimals; that is documented as a limitation, not handled.
  • The SMTP connection test (an authenticated API endpoint) connects to a host and port the user enters. It requires TLS before sending credentials, refuses loopback, private, link-local and embedded-IPv4 addresses, connects only to the address it checked, ends the whole attempt at a deadline, returns fixed messages and saves nothing. It is limited per user (5 a minute, 20 an hour), per client address (20 a minute) and to one attempt in flight per user; these counts are kept per server process, so several replicas multiply them. The settings page keeps its Test Connection button disabled, but the endpoint itself is reachable by any signed-in user.