Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b77934b
docs(plan): strengthen encryption-at-rest plan from doc review
unclesp1d3r Jul 12, 2026
c87748b
docs(operations): encrypted-volume how-to + threat matrix (U9)
unclesp1d3r Jul 12, 2026
9b0ffa0
feat(backup): add Argon2id + secretstream crypto module (U1)
unclesp1d3r Jul 12, 2026
a86babd
feat(backup): NDJSON DB export/import + operator_audit table (U3)
unclesp1d3r Jul 12, 2026
aada2f3
feat(deploy): docker secrets + hardening + encrypted-volume-ready mou…
unclesp1d3r Jul 12, 2026
94052bb
feat(backup): streaming tar bundle format + zip-slip guard (U2)
unclesp1d3r Jul 12, 2026
9f593c2
feat(backup): backup export service (U4)
unclesp1d3r Jul 12, 2026
839b5a8
feat(backup): stage-then-promote restore service + maintenance (U5)
unclesp1d3r Jul 12, 2026
655e16f
feat(backup): admin backup API routes + operator-event logging (U6)
unclesp1d3r Jul 12, 2026
26cfce2
feat(backup): admin backup UI (U7)
unclesp1d3r Jul 12, 2026
10272fa
refactor(backup): apply simplify + code-review fixes
unclesp1d3r Jul 13, 2026
6aa5333
feat(backup): same-origin CSRF check + minimum backup-password length
unclesp1d3r Jul 13, 2026
8591cd8
feat(backup): snapshot-isolate export + cap NDJSON import line length
unclesp1d3r Jul 13, 2026
9caec0a
feat(backup): harden restore core β€” unique schemas, crash recovery, m…
unclesp1d3r Jul 13, 2026
19fc7a9
feat(backup): enforce maintenance mode on the write path
unclesp1d3r Jul 13, 2026
32c4922
fix(backup): validate untrusted NDJSON rows + behavioral snapshot-iso…
unclesp1d3r Jul 13, 2026
5b399da
fix(backup): validate KDF alg + copy salt on ingest + relocate crypto…
unclesp1d3r Jul 13, 2026
6104cbc
fix(backup): unify restore promote envelope + surface rollback failur…
unclesp1d3r Jul 13, 2026
59ff896
fix(backup): audit/stream error handling + CSRF fallback tests + rend…
unclesp1d3r Jul 13, 2026
29b9a7a
chore(backup): run root instrumentation test in CI + keep outcome mes…
unclesp1d3r Jul 13, 2026
17d8cfe
fix(deploy): idempotent + owner-only secret handling, URL-safe DB pas…
unclesp1d3r Jul 13, 2026
52242da
fix(backup): remove dead maintenance helper + correct export doc + te…
unclesp1d3r Jul 13, 2026
455c43f
fix(backup-ui): prevent duplicate concurrent export + restore-panel r…
unclesp1d3r Jul 13, 2026
2dddd2b
feat(docker): add UPLOAD_DIR environment variable for uploads
unclesp1d3r Jul 13, 2026
49ea0c7
feat(docker): make UPLOAD_DIR configurable and format healthcheck com…
unclesp1d3r Jul 13, 2026
a46580e
fix(backup-ui): support non-Latin-1 restore passwords via percent-enc…
unclesp1d3r Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
.env
.env.*
!.env.example
secrets/*
!secrets/README.md

node_modules
.next
Expand Down
28 changes: 21 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
# Copy to `.env` (gitignored) and fill in. Never commit real secrets.
#
# The database password and the Better Auth signing secret are NOT set here β€”
# docker-compose.yml reads them as Docker secrets (plain files under
# ./secrets/, mounted read-only into the containers, never a plain env var β€”
# R16). Before first `docker compose up`, create both files:
# mkdir -p secrets
# openssl rand -hex 24 > secrets/postgres_password.txt
# openssl rand -hex 32 > secrets/better_auth_secret.txt
# (hex, not base64 β€” the password is embedded unescaped in a connection URL,
# and base64's `/+=` characters are not valid there unescaped)
# See secrets/README.md for details, rotation notes, and how to read the
# password back out for local (non-Docker) tooling.

# --- Database ---------------------------------------------------------------
POSTGRES_USER=magstacker
POSTGRES_PASSWORD=change-me-in-production
POSTGRES_DB=magstacker
# Host port the db is published on (kept off 5432 to avoid clashing with a host
# Postgres). The app container reaches the db by service name on 5432.
POSTGRES_HOST_PORT=5544

# DATABASE_URL is NOT set here: docker compose builds it per-service inline
# (host `db`). For local tooling (`bun test`, `bun run db:migrate`) export it
# yourself pointing at the published host port, e.g.:
# export DATABASE_URL=postgres://magstacker:change-me-in-production@localhost:5544/magstacker
# DATABASE_URL is NOT set here: the app/migrate containers build it at
# startup from secrets/postgres_password.txt (see docker-entrypoint.sh). For
# local tooling (`bun test`, `bun run db:migrate`) export it yourself,
# pointing at the published host port and reading the password from the
# secret file:
# export DATABASE_URL="postgres://magstacker:$(cat secrets/postgres_password.txt)@localhost:5544/magstacker"

# --- Auth (Better Auth, added in U2) ----------------------------------------
# Generate a strong random secret, e.g. `openssl rand -base64 32`.
BETTER_AUTH_SECRET=change-me-generate-a-strong-random-secret
# The signing secret lives in secrets/better_auth_secret.txt (see above), not
# here. For local (non-Docker) tooling that needs BETTER_AUTH_SECRET directly:
# export BETTER_AUTH_SECRET="$(cat secrets/better_auth_secret.txt)"
BETTER_AUTH_URL=http://localhost:3000

# First-admin bootstrap for `bun run seed:admin` (one-time, fresh deployment).
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ yarn-error.log*
.env*
!.env.example

# Docker secrets (R16) β€” plaintext secret files read by docker-compose.yml.
# Never commit real secret values; see secrets/README.md.
/secrets/*
!/secrets/README.md

# vercel
.vercel

Expand Down
17 changes: 16 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,22 @@ just env-setup # create .env.local from .env.example
just install-hooks # install the pre-commit hooks (once)
```

Then set `DATABASE_URL` in `.env.local` so `mise` loads it into your shell β€” for the local Postgres below that's `postgres://magstacker:<password>@localhost:5544/magstacker`. Also fill in `BETTER_AUTH_SECRET` and, if you want a seeded admin, `ADMIN_EMAIL` / `ADMIN_PASSWORD`. Now bring up the database and start the app:
The database password and Better Auth signing secret are Docker secrets (R16), not `.env` values β€” create them once, owner-only and only if they don't already exist (see [`secrets/README.md`](secrets/README.md)). Re-running the commands below is safe: an existing Postgres data volume keeps the password it was created with, so overwriting the file would just lock you out.

```bash
mkdir -p secrets
[ -f secrets/postgres_password.txt ] || (umask 077 && openssl rand -hex 24 > secrets/postgres_password.txt)
[ -f secrets/better_auth_secret.txt ] || (umask 077 && openssl rand -hex 32 > secrets/better_auth_secret.txt)
```

`mise` loads `DATABASE_URL` and `BETTER_AUTH_SECRET` from `.env.local` like any other variable, but it only parses `KEY=VALUE` lines β€” it doesn't run a shell, so a literal `$(cat ...)` typed into the file is never expanded and `DATABASE_URL` would end up containing that unevaluated text. Let your shell do the substitution once, when you write the file:

```bash
echo "DATABASE_URL=postgres://magstacker:$(cat secrets/postgres_password.txt)@localhost:5544/magstacker" >> .env.local
echo "BETTER_AUTH_SECRET=$(cat secrets/better_auth_secret.txt)" >> .env.local
```

If you want a seeded admin, also fill in `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `.env.local`. Now bring up the database and start the app:

```bash
docker compose up -d db # local Postgres on host port 5544
Expand Down
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,19 @@ COPY --from=builder /app/scripts ./scripts
# first upload fails EACCES.
RUN mkdir -p /data/uploads && chown bun:bun /data/uploads

# Resolves Docker-secrets `*_FILE` env vars into the plain env vars the app
# expects (POSTGRES_PASSWORD, BETTER_AUTH_SECRET, DATABASE_URL) before exec'ing
# the real command β€” see docker-entrypoint.sh (R16). Shared by the `app` and
# `migrate` services in docker-compose.yml.
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

# Run as the unprivileged user shipped in the bun image.
USER bun

EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
ENV UPLOAD_DIR=/data/uploads

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["bun", "run", "start"]
41 changes: 32 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,17 @@ You don't need to clone the repo for this. Grab the two files the stack needs an
curl -O https://raw.githubusercontent.com/unclesp1d3r/mag_stacker/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/unclesp1d3r/mag_stacker/main/.env.example

# 2. Fill in .env: a database password, a long random BETTER_AUTH_SECRET
# (try `openssl rand -base64 32`), your first admin email and password, and
# BETTER_AUTH_URL set to the address you'll actually open it at.
# 2. Fill in .env: your first admin email and password, and BETTER_AUTH_URL
# set to the address you'll actually open it at.

# 3. Pull the published image and start the stack
# 3. Create the two Docker secret files (R16) β€” the database password and the
# Better Auth signing secret are NOT set in .env. Restrict them to
# owner-only permissions as you create them:
mkdir -p secrets
(umask 077 && openssl rand -hex 24 > secrets/postgres_password.txt)
(umask 077 && openssl rand -hex 32 > secrets/better_auth_secret.txt)

# 4. Pull the published image and start the stack
docker compose pull
docker compose up -d # migrates, seeds your first admin, starts the app
```
Expand All @@ -68,9 +74,15 @@ To build the image yourself instead of pulling the published one, clone the repo

```bash
cp .env.example .env
# Fill in .env: a database password, a long random BETTER_AUTH_SECRET
# (try `openssl rand -base64 32`), your first admin email and password, and
# BETTER_AUTH_URL set to the address you'll actually open it at.
# Fill in .env: your first admin email and password, and BETTER_AUTH_URL set
# to the address you'll actually open it at.

# Create the two Docker secret files (R16) β€” the database password and the
# Better Auth signing secret are NOT set in .env. Restrict them to
# owner-only permissions as you create them:
mkdir -p secrets
(umask 077 && openssl rand -hex 24 > secrets/postgres_password.txt)
(umask 077 && openssl rand -hex 32 > secrets/better_auth_secret.txt)

docker compose up --build -d # migrates, seeds your first admin, starts the app
```
Expand All @@ -83,12 +95,19 @@ Open `http://<your-server>:3000/login`, sign in, and add the rest of the account

### Backups

Everything lives in Postgres, so a normal `pg_dump` is your backup. Restoring it brings back every firearm, magazine, compatibility link, and share exactly as they were:
A `pg_dump` covers the database β€” every firearm, magazine, compatibility link, and share exactly as they were:

```bash
docker compose exec db pg_dump -U "$POSTGRES_USER" -Fc -d "$POSTGRES_DB" > magstacker.dump
```

**It does not include uploaded documents** (receipts, warranties, ATF forms) β€” those blobs live on the separate `magstacker-uploads` volume, not in Postgres, so a Postgres-only restore would come back missing every attachment. To back up both together, use the password-encrypted export on the **Admin β†’ Backup** screen, or take a separate backup of the uploads volume alongside your `pg_dump`.

For running the Postgres and upload volumes on an encrypted host disk β€” and
a rundown of which threats disk encryption covers versus which an encrypted
in-app backup covers β€” see
[`docs/operations/encryption-at-rest.md`](docs/operations/encryption-at-rest.md).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Behind a reverse proxy

Sign-in rides on cookies, so on any real network you run MagStacker behind a reverse proxy that terminates TLS rather than exposing port 3000 directly. Point the proxy at the app's published port and set `BETTER_AUTH_URL` in `.env` to the public `https://` address. It **must** match the origin you actually open, or Better Auth rejects the request.
Expand All @@ -110,8 +129,10 @@ MagStacker is the original Go/Wails (later Avalonia) desktop app rebuilt as a mu
Stack: Next.js 16 (App Router), React 19, Bun, Drizzle ORM, Postgres, Better Auth, Tailwind v4, Biome. Use Bun and Biome, not ESLint/Prettier/pnpm (see `AGENTS.md`).

```bash
mkdir -p secrets # once, if not already created
[ -f secrets/postgres_password.txt ] || (umask 077 && openssl rand -hex 24 > secrets/postgres_password.txt) # (see secrets/README.md)
docker compose up -d db # local Postgres on host port 5544
export DATABASE_URL=postgres://magstacker:<password>@localhost:5544/magstacker
export DATABASE_URL="postgres://magstacker:$(cat secrets/postgres_password.txt)@localhost:5544/magstacker"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
bun install
bun run db:migrate
bun run dev # http://localhost:3000
Expand All @@ -123,6 +144,8 @@ bun test # unit + integration
```

> `mise` (`mise.toml`) pins the toolchain and loads `.env` into your shell, then caches it. After you edit `.env`, run `mise cache clear`, or a stale value can shadow both your tooling and `docker compose`.
>
> The db service reads its password from `secrets/postgres_password.txt` (a Docker secret, R16), not from `.env` β€” see [`secrets/README.md`](secrets/README.md).

The README's demo images and walkthrough gif are generated from the live UI. Regenerate them all before a release with `just demo-images` (needs Docker + ffmpeg). The generators are `e2e/demo-*.spec.ts`, gated behind `DEMO=1` so they stay out of the normal test run, and they share one sample dataset from `e2e/fixtures/demo-seed.ts`.

Expand Down
141 changes: 141 additions & 0 deletions __tests__/instrumentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";

/**
* Focused unit tests for `register()` β€” `instrumentation.ts`'s Next.js
* server-startup hook. Covers the three real branches called out in that
* file's own doc comment:
* 1. `NEXT_RUNTIME !== "nodejs"` β€” early return, the recovery sweep never runs.
* 2. `DATABASE_URL` unset β€” early return, the recovery sweep never runs.
* 3. The try/catch around `recoverInterruptedRestore` β€” a recovery failure
* must be caught and logged, never rethrown (a boot-recovery failure
* must not crash the server).
*
* **Deliberately lives OUTSIDE `src/`** (not `src/__tests__/`) and is run as
* its own invocation (`bun test __tests__/instrumentation.test.ts`), never
* bundled into `bun run test`'s `bun test src` / `just ci-check`. Verified
* empirically against this repo's actual Bun version (1.3.14): `mock.module()`
* replaces a module specifier for the rest of the **process**, not just this
* file, and β€” critically β€” if any OTHER file anywhere in the same `bun test`
* invocation has a static `import` of that same module, the real module gets
* linked into the cache before this file's `mock.module()` call ever runs
* (regardless of file ordering), which either silently no-ops the mock here
* or (if this file's mock registers first) corrupts the real module for
* every other file that statically imports it β€” reproduced directly: a
* `mock.module("@/src/backup/maintenance", () => ({ POISONED: true }))` in a
* file that sorts before `src/backup/__tests__/maintenance.test.ts` made that
* file fail at load time with `SyntaxError: Export named 'isMaintenanceActive'
* not found`. `src/backup/__tests__/maintenance.test.ts` and
* `src/backup/__tests__/write-path-maintenance-guard.test.ts` both statically
* import the REAL `@/src/backup/maintenance`/`@/src/db/client`, so mocking
* those specifiers here would be unsafe inside `bun test src`. This file's
* own `afterAll` "restore" (re-registering `mock.module` with the real
* exports) does NOT fix this either β€” restoring a `mock.module()` override
* does not retroactively repair an already-linked static import in another
* file, the same constraint `src/backup/__tests__/routes.test.ts` documents
* for `@/src/db/client`. Living outside `src/` sidesteps the whole class of
* problem: this file never shares a `bun test` process with those tests.
*/

const ORIGINAL_NEXT_RUNTIME = process.env.NEXT_RUNTIME;
const ORIGINAL_DATABASE_URL = process.env.DATABASE_URL;

let recoverCalls = 0;
let recoverShouldThrow: unknown = null;

mock.module("@/src/db/client", () => ({
db: { fake: "db-handle" },
}));
mock.module("@/src/storage", () => ({
activeStorageRoot: () => "/fake/storage/root",
}));
mock.module("@/src/backup/maintenance", () => ({
recoverInterruptedRestore: async () => {
recoverCalls += 1;
if (recoverShouldThrow) throw recoverShouldThrow;
},
}));

// instrumentation.ts has no top-level imports of its own β€” every dependency
// is dynamically imported inside `register()` at call time (see its doc
// comment) β€” so it's safe to statically import `register` here regardless
// of ordering relative to the `mock.module()` calls above.
const { register } = await import("../instrumentation");

function restoreEnv(): void {
if (ORIGINAL_NEXT_RUNTIME === undefined) {
delete process.env.NEXT_RUNTIME;
} else {
process.env.NEXT_RUNTIME = ORIGINAL_NEXT_RUNTIME;
}
if (ORIGINAL_DATABASE_URL === undefined) {
delete process.env.DATABASE_URL;
} else {
process.env.DATABASE_URL = ORIGINAL_DATABASE_URL;
}
}

describe("instrumentation.register()", () => {
beforeEach(() => {
recoverCalls = 0;
recoverShouldThrow = null;
});

afterEach(() => {
restoreEnv();
});

afterAll(() => {
restoreEnv();
});

test('resolves without running the recovery sweep when NEXT_RUNTIME is not "nodejs"', async () => {
delete process.env.NEXT_RUNTIME;
process.env.DATABASE_URL = "postgres://ignored/ignored";

await expect(register()).resolves.toBeUndefined();
expect(recoverCalls).toBe(0);
});

test("resolves without running the recovery sweep when DATABASE_URL is unset", async () => {
process.env.NEXT_RUNTIME = "nodejs";
delete process.env.DATABASE_URL;

await expect(register()).resolves.toBeUndefined();
expect(recoverCalls).toBe(0);
});

test("runs the recovery sweep exactly once when both NEXT_RUNTIME and DATABASE_URL are set", async () => {
process.env.NEXT_RUNTIME = "nodejs";
process.env.DATABASE_URL = "postgres://ignored/ignored";

await expect(register()).resolves.toBeUndefined();
expect(recoverCalls).toBe(1);
});

test("swallows a recoverInterruptedRestore failure β€” register() never rethrows (a boot-recovery failure must not crash the server)", async () => {
process.env.NEXT_RUNTIME = "nodejs";
process.env.DATABASE_URL = "postgres://ignored/ignored";
recoverShouldThrow = new Error("simulated recovery failure");

const errorSpy = spyOn(console, "error").mockImplementation(() => {});
try {
await expect(register()).resolves.toBeUndefined();
expect(recoverCalls).toBe(1);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy.mock.calls[0]?.[0]).toContain(
"crash-recovery sweep failed",
);
} finally {
errorSpy.mockRestore();
}
});
});
18 changes: 18 additions & 0 deletions app/(admin)/backup/backup-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { ExportPanel } from "./export-panel";
import { RestorePanel } from "./restore-panel";

/**
* Admin backup screen (plan Unit U7). Composes the export and restore panels
* side by side on wide viewports, stacked on narrow ones β€” mirrors the
* `/users` admin surface's create-form + table layout.
*/
export function BackupPanel() {
return (
<div className="grid gap-6 lg:grid-cols-2">
<ExportPanel />
<RestorePanel />
</div>
);
}
Loading
Loading