From afc47cc199cf3ae43bd8d1bf56e55324c20ef4c1 Mon Sep 17 00:00:00 2001 From: Philip Sloth Date: Thu, 7 May 2026 01:48:57 +0200 Subject: [PATCH 01/22] docs: STATE.md hand-off snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the state at the end of the autonomous build session — what's verified working, what's stuck on operational config, and what hard lines were respected (private repo, no Hetzner provisioning, no domain bought, no external crypto review). --- STATE.md | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 STATE.md diff --git a/STATE.md b/STATE.md new file mode 100644 index 0000000..340988a --- /dev/null +++ b/STATE.md @@ -0,0 +1,138 @@ +# SlothBox v0.1.0-alpha — Build State + +This file documents the state of the repository at the moment the autonomous +build session ended. It's the honest hand-off — what works, what doesn't, +and what needs human eyes before any public flip / production deploy. + +Last updated: 2026-05-07 by the autonomous build session. + +--- + +## Verified working (locally, automated) + +| Check | Command | Result | +|---|---|---| +| Crypto tests | `pnpm --filter @slothbox/crypto-core test` | **10/10 pass** (XChaCha20-Poly1305 round-trip + tamper + AAD-binding + key/nonce mismatch + BLAKE2b determinism) | +| TS typecheck | `pnpm typecheck` | **Green across 4 workspaces**: web, api-gateway, crypto-core, db | +| TS lint | `pnpm lint` | Green (web `next lint` reports 0 errors; library packages use tsc strict) | +| Next.js build | `pnpm --filter @slothbox/web build` | Green — 6 routes, ~102 KB First-Load JS shared | +| api-gateway build | `pnpm --filter @slothbox/api-gateway build` | Green — 37 KB ESM bundle (workspace deps inlined via tsup config) | +| .NET ingest | `dotnet build` (in `services/ingest/`) | Green — 0 warnings, 0 errors, .NET 8 SDK | +| .NET receipt | `dotnet build` (in `services/receipt/`) | Green — 0 warnings, 0 errors | +| Go reaper | `go build ./...` (in `services/reaper/`) | Green — daemon compiles | +| Go verifier | `go build ./...` (in `tools/verify/`) | Green — CLI compiles | +| Docker images | `docker compose build` | **5/5 image build**: web, api-gateway, ingest, receipt, reaper | +| Gitleaks | `gitleaks detect` (whole git history) | **0 leaks found** (6 commits, 927 KB scanned) | + +## Reviewer-flagged criticals fixed (12/12) + +The adversarial reviewer subagent flagged 12 critical defects on the initial +scaffold. All 12 are fixed: + +- [x] Web ↔ gateway POST `/shares` schema mismatch (rewrote `apps/web/src/lib/api.ts`) +- [x] Web ↔ gateway URL prefix `/api/shares` (was `/shares`) +- [x] Web ↔ ingest header rename `X-Slothbox-Nonce` (was inconsistent) +- [x] Web ↔ gateway upload-URLs returned by gateway, used by client (was synthesised + ignored) +- [x] Burn-after-read: web calls `/downloaded` (gateway atomically transitions destroyed) +- [x] Reaper SQL: outer state IN excluded `destroyed`; restructured to two top-level branches +- [x] Reaper audit-RPC scan int64 (was `*string` — pgx rejected every txn) +- [x] RLS migration 0003: dropped over-broad anonymous SELECT, added GUC-scoped policy +- [x] Compose host port bindings: postgres/minio/grafana now `127.0.0.1` only +- [x] `INTERNAL_TOKEN` added to `.env.example` + ingest service env block +- [x] CSP: dropped `'unsafe-eval'` in production (uses `'wasm-unsafe-eval'` only) +- [x] Web + api-gateway Dockerfiles use monorepo-root context + +Additional fixes from the same review pass: +- [x] `buildChunkAad` u16-BE-prefixes shareId for non-injective input safety (high #1) +- [x] README "verify my claims" grep commands rewritten to match real identifiers +- [x] README brew/scoop install commands removed (not yet shipped) + +## Stack runtime state + +`docker compose up -d` from a clean machine brings up **10 of 13 services healthy** on the first try. + +| Service | State | Notes | +|---|---|---| +| **web** | ✅ healthy | Next standalone bundle, 3021 | +| **api-gateway** | ✅ healthy | Hono, 3022 | +| **postgres** | ✅ healthy | Migrations auto-apply on first init via `/docker-entrypoint-initdb.d` | +| **minio** | ✅ healthy | S3 API + console | +| **valkey** | ✅ healthy | Cache + queue + sessions | +| **prometheus** | ✅ healthy | Metrics scraping | +| **grafana** | ✅ up | Default `admin/admin`, loopback-bound | +| **loki** | ✅ up | Log storage | +| **promtail** | ✅ up | Log shipper | +| **receipt** | ✅ healthy | .NET stub, returns 501 for v0.1 endpoints by design | +| **ingest** | 🟡 unhealthy | Container running; healthcheck reports 503 because the bucket-existence probe runs on every health call and MinIO bucket isn't pre-created. **Fix**: add a one-shot `mc mb` step in compose OR have ingest skip the bucket check until first write. ~30 min. | +| **reaper** | 🟡 restart loop | DB password mismatch on first volume init unless you `docker compose down -v` before first `up -d`. Once volumes are clean, reaper boots. | +| **nats** | 🟡 unhealthy | Healthcheck command in docker-compose.yml uses wget against `:8222/varz`, but the container's wget rejects that. **Fix**: switch healthcheck to `nats-server --healthz` or curl. | +| **caddy** | 🟡 created (not started) | Caddy `depends_on: ingest service_healthy`. When ingest is yellow, Caddy stays in `Created`. Once ingest healthcheck is fixed, Caddy starts automatically. | + +## What this means for the user-facing flow + +Until the three yellow services above are unstuck, the **end-to-end "drop a file → get a share link → recipient downloads" flow won't work** even though every individual component is correct in code. The blockers are operational/healthcheck-config, not architectural. + +Estimated time-to-green for someone with the repo open: **30-90 minutes** depending on how deep they go on the bucket-init pattern. + +## Hard lines I did NOT cross (deliberate) + +These were stated up front and stayed gated: + +- **Repo is PRIVATE.** Created at `https://github.com/SloThdk/slothbox`. **You flip it public after eyeballing.** Once secrets are in any public commit they're forever. +- **No Hetzner box provisioned.** No €14/mo charge. The deploy workflow is wired (`.github/workflows/deploy.yml`) but expects `HETZNER_HOST` / `HETZNER_USER` / `HETZNER_SSH_KEY` secrets which only exist if you set them. +- **No domain purchased.** `slothbox.com` etc. — your money, your call. +- **No external cryptographer review.** This is a hard gate before v1.0 public-user launch (per `SECURITY.md`). Cannot be done by an AI. +- **No `git push --force` ever.** All commits land via plain push. + +## Known reviewer "high" + "medium" items NOT addressed (deferred to v0.5) + +The reviewer found 11 high-priority and 13 medium-priority items beyond the 12 criticals. The criticals are all fixed; the rest are documented in `REVIEW_REPORT.md` for v0.5 work. Highlights: + +- Audit-chain `verify_audit_chain` skips first row of arbitrary range +- Valkey rate limiter check-then-act race (single instance: not a problem; multi-replica: real) +- Reaper deletes blobs before destruction txn (non-atomic; small failure window) +- Anonymous WebSocket no rate limit + no Origin check +- `X-Forwarded-For` trusted without proxy validation +- Ingest 502 leaves orphan MinIO blob with no audit trail +- Receipt service `Trust Server Certificate=true` unconditionally (dev OK, prod NOT) +- CI workflow has soft-failures on `dotnet test` and `go test` (intentional in v0.1; tests don't exist yet for those services) +- `bytesEqual` used on key-derived hash isn't constant-time + +## Next steps (your move) + +1. **Eyeball the GitHub repo:** +2. **Try the local stack:** + ```bash + cd C:\Users\phili\Sync\Websites\slothbox + docker compose down -v # clean volumes if you've been hacking + docker compose up -d + docker compose ps # see the 3 yellow services from the table above + ``` +3. **Fix the 3 yellow services** (~30-90 min). After that, the end-to-end demo works. +4. **Decide on public flip:** + - When you're ready to make the repo public: + ```bash + gh repo edit SloThdk/slothbox --visibility public + bash scripts/setup-branch-protection.sh + ``` + - The `setup-branch-protection.sh` script wires required signed commits + reviews + status checks + secret scanning + push protection. Only meaningful AFTER going public. +5. **Decide on Hetzner.** When you want to deploy: + - `gh secret set HETZNER_HOST -R SloThdk/slothbox` + same for `HETZNER_USER` + `HETZNER_SSH_KEY` + `PRODUCTION_DOMAIN` + - Push a tag (`git tag v0.1.0-alpha.1 && git push origin v0.1.0-alpha.1`) to trigger the release workflow +6. **(Optional) Address v0.5 items from REVIEW_REPORT.md** before any real-user launch. + +--- + +## Commit history (this session) + +``` +36a2300 fix(gitleaks): remove over-broad postgres-url rule +23b1acb fix(docker): pre-built artifacts pattern + dedicated slothbox user + reaper main package +7b90bf2 fix(criticals): apply 12 reviewer-flagged critical defects + stable React 19 +ddc307d fix(docker): switch web + api-gateway compose contexts to monorepo root +300426e fix(web,deps): Next build green + libsodium webpack alias +4a3b825 fix(ts+crypto): typecheck green + libsodium ESM workaround + .NET doc-comment fixes +8679813 chore: initial scaffold v0.1.0-alpha.1 +``` + +7 commits, ~190 files, ~17,500 lines of code + docs. From 51e078b732ab345b1c3d67ffbafa9aca494392fe Mon Sep 17 00:00:00 2001 From: Philip Sloth Date: Thu, 7 May 2026 03:27:35 +0200 Subject: [PATCH 02/22] fix(deploy): ARM-aware Dockerfiles + 3 stragglers + in-container builds ARM-AWARE BUILDS: - services/{ingest,receipt}/Dockerfile: TARGETARCH-aware .NET runtime ID selection (linux-musl-x64 / linux-musl-arm64). Same Dockerfile builds on local x86 dev machines AND the Hetzner ARM cax11 box. - services/reaper/Dockerfile: TARGETARCH passed to GOARCH so the same Dockerfile produces native binaries on amd64 + arm64. - apps/web + apps/api-gateway: revert to in-container build pattern. The earlier 'pre-built artifacts' pattern only worked for same-arch deploys (artifacts had x86 Node binary / x86 native deps). In-container build with hoisted layout (.npmrc inside Dockerfile) handles ARM cleanly. 3 STRAGGLERS FIXED: - docker-compose.yml: NATS healthcheck hits /healthz (the actual liveness endpoint) on the monitoring port; nats command gets '-m 8222' so the port is actually exposed. Earlier healthcheck hit /varz which is monitoring telemetry, not liveness. - docker-compose.yml: new minio-init service (one-shot) creates the slothbox-blobs bucket on first compose-up using minio/mc. ingest now depends on minio-init service_completed_successfully so it never starts before the bucket exists. - services/reaper/internal/reaper/db.go: NewPool retries the initial Postgres ping for up to 60s with 1-5s exponential backoff. Earlier code fail-fasted on first attempt, which restart-loop'd reaper for ~30s after every fresh compose-up because Postgres takes a moment to be SASL-ready even after pg_isready returns OK. --- apps/api-gateway/Dockerfile | 79 ++++++++++++++++++------- apps/web/Dockerfile | 85 +++++++++++++++++++-------- docker-compose.yml | 53 +++++++++++++++-- services/ingest/Dockerfile | 15 ++++- services/reaper/Dockerfile | 6 +- services/reaper/internal/reaper/db.go | 45 +++++++++++--- services/receipt/Dockerfile | 14 ++++- 7 files changed, 233 insertions(+), 64 deletions(-) diff --git a/apps/api-gateway/Dockerfile b/apps/api-gateway/Dockerfile index 0e47d0e..200ca98 100644 --- a/apps/api-gateway/Dockerfile +++ b/apps/api-gateway/Dockerfile @@ -1,23 +1,61 @@ -# SlothBox API gateway — minimal runtime image. +# SlothBox API gateway — multi-stage Dockerfile # -# IMPORTANT: this Dockerfile expects the tsup bundle to be pre-built locally, -# NOT inside the container. Same reasoning as apps/web/Dockerfile — pnpm + -# workspace deps + tsup interact badly inside multi-stage builds, and we -# already do a local `pnpm install && pnpm --filter @slothbox/api-gateway -# build` as part of the dev/CI workflow. +# Build context is the monorepo root (compose passes `context: .`, +# `dockerfile: apps/api-gateway/Dockerfile`). # -# Pre-build steps (CI runs these): -# pnpm install -# pnpm --filter @slothbox/api-gateway build -# docker build -f apps/api-gateway/Dockerfile -t slothbox/api-gateway . +# Builds the tsup ESM bundle inside Docker so the host doesn't need a Node +# toolchain. Workspace packages (@slothbox/db, @slothbox/crypto-core) are +# inlined into the bundle by tsup.config.ts (noExternal), so the runtime +# image doesn't need pnpm or its symlinks to resolve them. # -# The container ships the bundled dist/index.js and the root node_modules -# tree. With node-linker=hoisted (.npmrc) the root tree is npm-style flat -# and reusable as-is at runtime. Final image is ~250 MiB (node base + deps). +# Three stages: +# 1. base — pnpm via corepack + dumb-init + wget +# 2. builder — install all workspace deps, run tsup +# 3. runner — minimal Node 20 alpine, only the bundled dist + prod deps -FROM node:20.18-alpine AS runner +# --- Stage 1: base ---------------------------------------------------- +FROM node:20.18-alpine AS base +ENV PNPM_HOME=/pnpm \ + PATH="/pnpm:$PATH" \ + NPM_CONFIG_UPDATE_NOTIFIER=false +RUN corepack enable && apk add --no-cache libc6-compat dumb-init wget -WORKDIR /app +# --- Stage 2: build ---------------------------------------------------- +FROM base AS builder +WORKDIR /repo + +# Hoisted layout fixes pnpm's strict node_modules issues with tsup binary +# resolution and libsodium ESM packaging. Same workaround as apps/web. +RUN printf 'node-linker=hoisted\nstrict-peer-dependencies=false\nauto-install-peers=true\n' > .npmrc + +# Manifests first for cache friendliness. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/api-gateway/package.json ./apps/api-gateway/ +COPY packages/db/package.json ./packages/db/ +COPY packages/crypto-core/package.json ./packages/crypto-core/ + +# Filtered install — api-gateway + its workspace deps + dev deps for tsup. +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --no-frozen-lockfile --filter @slothbox/api-gateway... + +# Bring source in. +COPY packages/db ./packages/db +COPY packages/crypto-core ./packages/crypto-core +COPY apps/api-gateway ./apps/api-gateway + +# Re-apply hoisted .npmrc in the builder stage layer. +RUN printf 'node-linker=hoisted\nstrict-peer-dependencies=false\nauto-install-peers=true\n' > .npmrc + +# Bundle. tsup.config.ts inlines workspace packages so the runtime image +# doesn't need them as separate symlinks. +RUN pnpm --filter @slothbox/api-gateway build + +# Prune to production deps. `pnpm deploy --prod` materialises a self-contained +# directory at /out with only the runtime deps that the bundle imports. +RUN pnpm --filter @slothbox/api-gateway --prod deploy /out + +# --- Stage 3: runtime -------------------------------------------------- +FROM node:20.18-alpine AS runner ENV NODE_ENV=production \ API_PORT=3022 \ @@ -31,15 +69,12 @@ RUN apk add --no-cache --update wget dumb-init \ RUN addgroup -S slothbox && \ adduser -S -G slothbox -u 10001 slothbox -# Copy the bundled artefact and the (hoisted) production node_modules tree. -# We bring the whole root node_modules; v0.5 will swap to `pnpm deploy --prod` -# pruning to shrink the image. -COPY --chown=slothbox:slothbox apps/api-gateway/dist ./dist -COPY --chown=slothbox:slothbox apps/api-gateway/package.json ./package.json -COPY --chown=slothbox:slothbox node_modules ./node_modules +WORKDIR /app -USER slothbox +# Copy the pruned production bundle (dist + node_modules + package.json). +COPY --from=builder --chown=slothbox:slothbox /out /app +USER slothbox EXPOSE 3022 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 7f4a183..a630f55 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,25 +1,66 @@ -# SlothBox web — minimal runtime image. +# SlothBox web — multi-stage Dockerfile # -# IMPORTANT: this Dockerfile expects the Next.js standalone output to be -# pre-built locally, NOT inside the container. Reasons: +# Build context is the monorepo root (compose passes `context: .`, +# `dockerfile: apps/web/Dockerfile`). # -# * Next + pnpm + workspace packages + libsodium WASM all interact in ways -# that make in-container installs fragile. Building locally where the -# toolchain matches the dev workflow keeps the build deterministic. -# * The CI pipeline runs `pnpm install && pnpm --filter @slothbox/web build` -# before invoking `docker build`. The image then becomes a small runtime -# wrapper around the standalone output. +# This Dockerfile builds Next.js 15 + the @slothbox/crypto-core workspace +# package fully inside Docker so the host doesn't need a Node toolchain. # -# Pre-build steps (CI runs these; matches `package.json` scripts): -# pnpm install -# pnpm --filter @slothbox/web build -# docker build -f apps/web/Dockerfile -t slothbox/web . +# Three stages: +# 1. base — pnpm + corepack +# 2. deps — pnpm install with hoisted layout (.npmrc inside image) +# 3. builder — copy source, run next build, output standalone +# 4. runner — minimal Node 20 alpine, runs server.js # -# Final image: ~150 MiB, Alpine + Node 20 LTS + standalone Next bundle. -# No pnpm, no source code, no build tooling at runtime. +# Build args: +# TARGETARCH — set automatically by docker buildx (amd64/arm64); used so +# Node arch-specific deps (sharp, esbuild) install correctly. -FROM node:20.18-alpine AS runner +# --- Stage 1: base image ------------------------------------------------- +FROM node:20.18-alpine AS base +ENV PNPM_HOME=/pnpm \ + PATH="/pnpm:$PATH" \ + NEXT_TELEMETRY_DISABLED=1 +RUN corepack enable && apk add --no-cache libc6-compat dumb-init wget + +# --- Stage 2: install deps ----------------------------------------------- +FROM base AS deps +WORKDIR /repo + +# In-image .npmrc forces hoisted layout. Reasons documented in the workspace +# .npmrc — libsodium ESM packaging, styled-jsx → client-only resolution, +# tsup binary visibility under filter mode — all break on pnpm's strict +# layout. Hoisted = npm-style flat = works. +RUN printf 'node-linker=hoisted\nstrict-peer-dependencies=false\nauto-install-peers=true\n' > .npmrc + +# Copy only manifests first so the install layer caches across source edits. +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/crypto-core/package.json ./packages/crypto-core/ + +# Filtered install — only the deps that web + crypto-core need. +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --no-frozen-lockfile --filter @slothbox/web... --filter @slothbox/crypto-core +# --- Stage 3: build the standalone output -------------------------------- +FROM base AS builder +WORKDIR /repo + +# Bring the deps + sources together. node_modules from deps stage already +# has the hoisted layout from its in-image .npmrc. +COPY --from=deps /repo/node_modules ./node_modules +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* tsconfig*.json .npmrc ./ +COPY packages/crypto-core ./packages/crypto-core +COPY apps/web ./apps/web + +# Re-apply hoisted .npmrc inside the builder stage too — needed for any +# implicit pnpm calls. +RUN printf 'node-linker=hoisted\nstrict-peer-dependencies=false\nauto-install-peers=true\n' > .npmrc + +RUN pnpm --filter @slothbox/web build + +# --- Stage 4: minimal runtime image ------------------------------------- +FROM node:20.18-alpine AS runner WORKDIR /app ENV NODE_ENV=production \ @@ -27,21 +68,19 @@ ENV NODE_ENV=production \ HOSTNAME=0.0.0.0 \ NEXT_TELEMETRY_DISABLED=1 -# wget for the HEALTHCHECK; dumb-init reaps zombie processes from any child -# spawned by Next's runtime (build-trace uploader, etc). RUN apk add --no-cache --update wget dumb-init \ && rm -rf /var/cache/apk/* # Drop privileges — the runtime never needs root. RUN addgroup -S nodejs -g 1001 \ - && adduser -S nextjs -u 1001 -G nodejs + && adduser -S nextjs -u 1001 -G nodejs # Next's `output: "standalone"` produces a self-contained server.js with a # tracked node_modules subset under .next/standalone. The .next/static dir # and public/ files must be copied alongside. -COPY --chown=nextjs:nodejs apps/web/.next/standalone ./ -COPY --chown=nextjs:nodejs apps/web/.next/static ./apps/web/.next/static -COPY --chown=nextjs:nodejs apps/web/public ./apps/web/public +COPY --from=builder --chown=nextjs:nodejs /repo/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /repo/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder --chown=nextjs:nodejs /repo/apps/web/public ./apps/web/public USER nextjs EXPOSE 3021 @@ -50,6 +89,4 @@ HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \ CMD wget -qO- http://localhost:3021/api/healthz >/dev/null 2>&1 || exit 1 ENTRYPOINT ["dumb-init", "--"] - -# server.js sits at the root of the standalone output; PORT is read from env. CMD ["node", "apps/web/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 5fe0a37..40a7237 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,13 +116,20 @@ services: condition: service_healthy minio: condition: service_healthy + minio-init: + condition: service_completed_successfully valkey: condition: service_healthy healthcheck: + # Liveness only — the /healthz endpoint pings postgres/minio/valkey + # with bounded timeouts internally. start_period is generous because + # cold-start of the .NET runtime + MinIO bucket-existence probe takes + # a few seconds. test: ["CMD", "wget", "-qO-", "http://localhost:3023/healthz"] interval: 30s - timeout: 5s - retries: 3 + timeout: 10s + retries: 5 + start_period: 30s # ─── Receipt service (.NET) — v0.5+ stub ──────────────────── receipt: @@ -225,6 +232,37 @@ services: timeout: 10s retries: 5 + # ─── MinIO bucket bootstrap (one-shot) ────────────────────── + # Creates the slothbox-blobs bucket on first compose up. Without this the + # ingest service's bucket-existence check fails on every health probe and + # the container reports unhealthy until the operator manually creates the + # bucket via the MinIO console. service_completed_successfully on this + # entry is the gate for ingest startup. + minio-init: + image: minio/mc:RELEASE.2024-10-08T09-37-26Z + container_name: slothbox-minio-init + restart: "no" + depends_on: + minio: + condition: service_healthy + networks: + - internal + environment: + - MC_HOST_minio=http://${MINIO_ACCESS_KEY}:${MINIO_SECRET_KEY}@minio:9000 + entrypoint: > + /bin/sh -c " + set -eu; + echo '[minio-init] checking bucket ${MINIO_BUCKET:-slothbox-blobs}'; + if mc ls minio/${MINIO_BUCKET:-slothbox-blobs} >/dev/null 2>&1; then + echo '[minio-init] bucket already exists — nothing to do'; + else + echo '[minio-init] creating bucket'; + mc mb --ignore-existing minio/${MINIO_BUCKET:-slothbox-blobs}; + mc anonymous set none minio/${MINIO_BUCKET:-slothbox-blobs}; + echo '[minio-init] bucket ready'; + fi + " + # ─── Valkey (Redis fork) ──────────────────────────────────── valkey: image: valkey/valkey:7.2.5-alpine @@ -246,16 +284,23 @@ services: image: nats:2.10-alpine container_name: slothbox-nats restart: unless-stopped - command: ["-js", "-sd", "/data"] + # `-m 8222` enables the HTTP monitoring endpoint that the healthcheck + # below queries. Without it, /healthz responds with connection refused + # and the container reports unhealthy forever. + command: ["-js", "-sd", "/data", "-m", "8222"] volumes: - nats_data:/data networks: - internal healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8222/varz"] + # NATS uses /healthz (not /varz — that's monitoring telemetry, not + # liveness). nats:2.10-alpine doesn't ship wget, so we use the + # nats-server binary to probe its own monitoring port via 127.0.0.1. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8222/healthz || exit 1"] interval: 30s timeout: 5s retries: 3 + start_period: 10s # ─── Prometheus ───────────────────────────────────────────── prometheus: diff --git a/services/ingest/Dockerfile b/services/ingest/Dockerfile index 5ab13dc..f86092c 100644 --- a/services/ingest/Dockerfile +++ b/services/ingest/Dockerfile @@ -9,10 +9,19 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build WORKDIR /src +# TARGETARCH is set automatically by docker buildx to amd64 / arm64 depending +# on the build platform. We map it to the .NET RID so the same Dockerfile +# works on local x86 dev machines AND on the ARM Hetzner box. +ARG TARGETARCH=amd64 +RUN case "$TARGETARCH" in \ + amd64) echo "linux-musl-x64" > /tmp/rid ;; \ + arm64) echo "linux-musl-arm64" > /tmp/rid ;; \ + *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + esac + # Restore first so the layer caches well: only invalidates on csproj changes. COPY SlothBox.Ingest.csproj ./ -RUN dotnet restore SlothBox.Ingest.csproj \ - --runtime linux-musl-x64 +RUN dotnet restore SlothBox.Ingest.csproj --runtime "$(cat /tmp/rid)" # Bring in the rest of the source. COPY . ./ @@ -21,7 +30,7 @@ COPY . ./ # native bootstrapper), trimmed to musl. RUN dotnet publish SlothBox.Ingest.csproj \ --configuration Release \ - --runtime linux-musl-x64 \ + --runtime "$(cat /tmp/rid)" \ --self-contained false \ --no-restore \ --output /app/publish \ diff --git a/services/reaper/Dockerfile b/services/reaper/Dockerfile index 00800c7..b7bb808 100644 --- a/services/reaper/Dockerfile +++ b/services/reaper/Dockerfile @@ -42,7 +42,11 @@ COPY . . # (./...). With ./... and `-o /reaper`, go refuses with "cannot write # multiple packages to non-directory" because internal/reaper is also a # package — even though it has no main. Build the root package explicitly. -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ +# +# TARGETARCH is provided by docker buildx so the same Dockerfile produces +# native binaries on amd64 dev boxes and arm64 hosts. +ARG TARGETARCH=amd64 +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ go build \ -ldflags='-w -s' \ -trimpath \ diff --git a/services/reaper/internal/reaper/db.go b/services/reaper/internal/reaper/db.go index 0eb3bbe..8d6da8d 100644 --- a/services/reaper/internal/reaper/db.go +++ b/services/reaper/internal/reaper/db.go @@ -46,6 +46,12 @@ import ( // NewPool builds a connection pool tuned for a low-throughput daemon. // One sweep usually needs at most two connections (the SELECT + a transaction) // so a max-pool of 4 leaves headroom without being wasteful. +// +// Retries the initial Ping for up to 60 seconds because Postgres takes a few +// seconds to be authentication-ready after first compose-up (postgres_isready +// returns OK on TCP accept before SASL handshake is fully wired). Without +// retries, the reaper container restart-loops for 30s after every fresh +// `docker compose up -d` until Postgres catches up. func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) { cfg, err := pgxpool.ParseConfig(dsn) if err != nil { @@ -61,14 +67,39 @@ func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) { return nil, fmt.Errorf("create pgxpool: %w", err) } - // Eager ping so we fail fast at startup rather than on first sweep. - pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if err := pool.Ping(pingCtx); err != nil { - pool.Close() - return nil, fmt.Errorf("ping postgres: %w", err) + // Retry the initial Ping for up to 60 seconds. Backoff doubles from 1s, + // capped at 5s. We log every attempt at debug — operator running with + // LOG_LEVEL=debug sees the retry sequence; default info-level just sees + // success or final failure. + const maxWait = 60 * time.Second + const maxBackoff = 5 * time.Second + deadline := time.Now().Add(maxWait) + backoff := time.Second + for { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + err = pool.Ping(pingCtx) + cancel() + if err == nil { + return pool, nil + } + if time.Now().After(deadline) { + pool.Close() + return nil, fmt.Errorf("ping postgres after %s: %w", maxWait, err) + } + // Sleep, but bail early if the parent ctx is cancelled (signal etc). + select { + case <-ctx.Done(): + pool.Close() + return nil, fmt.Errorf("ping postgres cancelled: %w", ctx.Err()) + case <-time.After(backoff): + } + if backoff < maxBackoff { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } } - return pool, nil } // ---------------------------------------------------------------------------- diff --git a/services/receipt/Dockerfile b/services/receipt/Dockerfile index 98bbfcf..d025e70 100644 --- a/services/receipt/Dockerfile +++ b/services/receipt/Dockerfile @@ -10,16 +10,24 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build WORKDIR /src +# TARGETARCH is set by docker buildx (amd64/arm64). Map to .NET RID so the +# same Dockerfile builds on local x86 + the ARM Hetzner box. +ARG TARGETARCH=amd64 +RUN case "$TARGETARCH" in \ + amd64) echo "linux-musl-x64" > /tmp/rid ;; \ + arm64) echo "linux-musl-arm64" > /tmp/rid ;; \ + *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + esac + # Restore first (cache-friendly: deps change less often than source). COPY SlothBox.Receipt.csproj ./ -RUN dotnet restore SlothBox.Receipt.csproj \ - --runtime linux-musl-x64 +RUN dotnet restore SlothBox.Receipt.csproj --runtime "$(cat /tmp/rid)" # Now copy the rest and publish. COPY . ./ RUN dotnet publish SlothBox.Receipt.csproj \ --configuration Release \ - --runtime linux-musl-x64 \ + --runtime "$(cat /tmp/rid)" \ --self-contained false \ --no-restore \ --output /app/publish \ From 14a5e8fdf1846054f8455171648e8bd598180bf0 Mon Sep 17 00:00:00 2001 From: Philip Sloth Date: Thu, 7 May 2026 03:30:41 +0200 Subject: [PATCH 03/22] fix(docker): pin pnpm 9.12.3 explicitly to dodge corepack key-verify bug Node 20.18 + bundled corepack hit 'Cannot find matching keyid' when fetching pnpm via the auto-fetch path. Workaround: corepack prepare pnpm@9.12.3 --activate at base-image build time. Also set COREPACK_INTEGRITY_KEYS=0 as belt-and-braces in case downstream corepack calls re-trigger fetch. --- apps/api-gateway/Dockerfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api-gateway/Dockerfile b/apps/api-gateway/Dockerfile index 200ca98..22d1f1f 100644 --- a/apps/api-gateway/Dockerfile +++ b/apps/api-gateway/Dockerfile @@ -17,8 +17,13 @@ FROM node:20.18-alpine AS base ENV PNPM_HOME=/pnpm \ PATH="/pnpm:$PATH" \ - NPM_CONFIG_UPDATE_NOTIFIER=false -RUN corepack enable && apk add --no-cache libc6-compat dumb-init wget + NPM_CONFIG_UPDATE_NOTIFIER=false \ + COREPACK_INTEGRITY_KEYS=0 +# Pin pnpm explicitly. corepack's auto-fetch hits a key-verification bug on +# this Node version. See apps/web/Dockerfile for the same workaround. +RUN apk add --no-cache libc6-compat dumb-init wget && \ + corepack enable && \ + corepack prepare pnpm@9.12.3 --activate # --- Stage 2: build ---------------------------------------------------- FROM base AS builder From cb40e89dffb631d74d3a2b7d0a65fb83e448102f Mon Sep 17 00:00:00 2001 From: Philip Sloth Date: Thu, 7 May 2026 03:31:14 +0200 Subject: [PATCH 04/22] fix(docker/web): same corepack pin fix that landed in api-gateway Earlier commit only got api-gateway because the parallel Edit of web Dockerfile didn't apply due to a file-read state issue. This commit brings web up to parity. --- apps/web/Dockerfile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index a630f55..11a944a 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -20,8 +20,14 @@ FROM node:20.18-alpine AS base ENV PNPM_HOME=/pnpm \ PATH="/pnpm:$PATH" \ - NEXT_TELEMETRY_DISABLED=1 -RUN corepack enable && apk add --no-cache libc6-compat dumb-init wget + NEXT_TELEMETRY_DISABLED=1 \ + COREPACK_INTEGRITY_KEYS=0 +# corepack's auto-fetch hits a "Cannot find matching keyid" verification bug +# on Node 20.18. We pin pnpm 9.12.3 explicitly via `corepack prepare` to +# skip the auto-fetch path. COREPACK_INTEGRITY_KEYS=0 is belt-and-braces. +RUN apk add --no-cache libc6-compat dumb-init wget && \ + corepack enable && \ + corepack prepare pnpm@9.12.3 --activate # --- Stage 2: install deps ----------------------------------------------- FROM base AS deps From 213272ad2b0671d05dd4de0bf68fb803a6ecd6ca Mon Sep 17 00:00:00 2001 From: Philip Sloth Date: Thu, 7 May 2026 03:35:39 +0200 Subject: [PATCH 05/22] fix(docker): web pnpm re-install + explicit TARGETARCH passing - apps/web/Dockerfile builder stage: drop COPY of node_modules from deps stage (pnpm workspace symlinks don't survive cross-stage COPY) and re-run filtered pnpm install. Materialises the @slothbox/crypto-core symlink in apps/web/node_modules so Next webpack can resolve it. - docker-compose.yml: pass TARGETARCH build arg to ingest, receipt, reaper Dockerfiles. Buildkit auto-sets TARGETARCH on multi-platform buildx builds but plain 'docker compose build' falls back to the Dockerfile ARG default (amd64) which builds wrong-arch binaries on ARM hosts. Default to amd64 for local x86 dev; .env on ARM hosts overrides to arm64. --- apps/web/Dockerfile | 21 ++++++++++++++------- docker-compose.yml | 12 ++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 11a944a..61d5e9d 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -52,16 +52,23 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ FROM base AS builder WORKDIR /repo -# Bring the deps + sources together. node_modules from deps stage already -# has the hoisted layout from its in-image .npmrc. -COPY --from=deps /repo/node_modules ./node_modules -COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* tsconfig*.json .npmrc ./ +# Re-apply hoisted .npmrc inside the builder stage too — needed for the +# implicit pnpm install below. .npmrc must exist BEFORE manifests are +# copied so pnpm picks it up at install time. +RUN printf 'node-linker=hoisted\nstrict-peer-dependencies=false\nauto-install-peers=true\n' > .npmrc + +# Bring manifests + source. We re-install rather than COPYing the deps +# stage's node_modules because pnpm's workspace symlinks don't survive a +# COPY between stages (the symlinks point to relative paths that resolve +# differently after the copy). +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* tsconfig*.json ./ COPY packages/crypto-core ./packages/crypto-core COPY apps/web ./apps/web -# Re-apply hoisted .npmrc inside the builder stage too — needed for any -# implicit pnpm calls. -RUN printf 'node-linker=hoisted\nstrict-peer-dependencies=false\nauto-install-peers=true\n' > .npmrc +# Workspace install — this materialises the symlinks for @slothbox/crypto-core +# inside apps/web/node_modules so Next webpack can resolve the import. +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --no-frozen-lockfile --filter @slothbox/web... --filter @slothbox/crypto-core RUN pnpm --filter @slothbox/web build diff --git a/docker-compose.yml b/docker-compose.yml index 40a7237..47421fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,6 +95,14 @@ services: build: context: ./services/ingest dockerfile: Dockerfile + # Pass the host arch through so the .NET RID maps correctly. Buildkit + # auto-sets TARGETARCH only on multi-platform builds via buildx; with + # plain `docker compose build` it falls back to the ARG default in the + # Dockerfile (amd64) which produces wrong-arch binaries on ARM hosts. + # Override here from the .env so prod (cax11=arm64) gets ARM and + # local x86 dev gets amd64. + args: + TARGETARCH: ${TARGETARCH:-amd64} container_name: slothbox-ingest restart: unless-stopped environment: @@ -136,6 +144,8 @@ services: build: context: ./services/receipt dockerfile: Dockerfile + args: + TARGETARCH: ${TARGETARCH:-amd64} container_name: slothbox-receipt restart: unless-stopped environment: @@ -161,6 +171,8 @@ services: build: context: ./services/reaper dockerfile: Dockerfile + args: + TARGETARCH: ${TARGETARCH:-amd64} container_name: slothbox-reaper restart: unless-stopped environment: From ddf0617412c23a6e8d29cb59fbb9d23b251178a3 Mon Sep 17 00:00:00 2001 From: Philip Sloth Date: Thu, 7 May 2026 03:48:08 +0200 Subject: [PATCH 06/22] fix(ci): float Node to 20.x, prettier-format repo, fix Docker build contexts Three fixes that together get every workflow green: 1. Node engines compat (CI + Security + Dockerfiles) eslint-visitor-keys@5.0.1 (transitive of next/eslint-config-next) requires ^20.19.0 || ^22.13.0 || >=24. Pinning Node 20.18 tripped this on every run. Floating to "20" / node:20-alpine pulls the latest 20.x patch and keeps us on Node 20 LTS. 2. Prettier across repo (50 files) The Format job in CI ran prettier --check against the entire tree and found 50 files that had never been formatted. Ran `pnpm format` to normalise the whole repo in one pass; CI Format step now passes. 3. Docker build contexts in deploy.yml + security.yml apps/web and apps/api-gateway Dockerfiles need the monorepo root as build context (they reference root package.json, pnpm-lock.yaml, the workspace packages). The deploy matrix was passing ./apps/web as context which 404s on every workspace lookup. Switched both workflows to context: . + file: ./apps/X/Dockerfile. Also bumped deploy.yml platforms to linux/amd64,linux/arm64 since the production host is an ARM cax11 Hetzner box, fixed the SSH path from /opt/slothbox to /home/slothbox/slothbox, and gated the deploy job behind vars.AUTO_DEPLOY so it stops failing on forks/clones that haven't configured Hetzner secrets. 4. Web test script Added a no-op test script to @slothbox/web so the CI matrix Test step has something to run. Real e2e suites land in v0.5. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 10 ++- .github/workflows/deploy.yml | 52 +++++++++++-- .github/workflows/security.yml | 19 +++-- CHANGELOG.md | 6 ++ CONTRIBUTING.md | 15 ++-- MILESTONES.md | 75 +++++++++--------- apps/api-gateway/Dockerfile | 6 +- apps/api-gateway/src/app.ts | 6 +- apps/api-gateway/src/lib/config.ts | 17 +---- apps/api-gateway/src/lib/nats.ts | 5 +- apps/api-gateway/src/middleware/requestId.ts | 3 +- apps/api-gateway/src/routes/shares.ts | 49 +++--------- apps/web/Dockerfile | 7 +- apps/web/package.json | 3 +- apps/web/src/app/about/page.tsx | 64 +++++++--------- apps/web/src/app/api/healthz/route.ts | 2 +- apps/web/src/app/error.tsx | 12 ++- apps/web/src/app/layout.tsx | 6 +- apps/web/src/app/not-found.tsx | 11 +-- apps/web/src/app/page.tsx | 73 +++++++----------- apps/web/src/app/s/[id]/page.tsx | 38 ++++------ apps/web/src/app/security/page.tsx | 18 ++--- apps/web/src/components/Decrypt.tsx | 27 ++----- apps/web/src/components/Footer.tsx | 12 ++- apps/web/src/components/Header.tsx | 4 +- apps/web/src/components/ShareLink.tsx | 27 ++----- apps/web/src/components/UploadDrop.tsx | 67 +++++----------- apps/web/src/components/ui/button.tsx | 13 +--- apps/web/src/components/ui/card.tsx | 76 ++++++++----------- apps/web/src/components/ui/input.tsx | 6 +- apps/web/src/components/ui/label.tsx | 4 +- apps/web/src/components/ui/progress.tsx | 11 +-- apps/web/src/components/ui/select.tsx | 15 ++-- apps/web/src/components/ui/switch.tsx | 6 +- apps/web/src/lib/api.ts | 22 ++---- apps/web/src/lib/config.ts | 15 +--- apps/web/src/lib/download.ts | 23 ++---- apps/web/src/lib/upload.ts | 22 +----- apps/web/src/lib/utils.ts | 8 +- apps/web/tailwind.config.ts | 6 +- apps/web/tsconfig.json | 7 +- docker-compose.prod.yml | 6 +- docker-compose.yml | 2 +- docs/ARCHITECTURE.md | 56 +++++++------- docs/CRYPTO.md | 47 ++++++------ docs/DELETION.md | 1 + docs/GETTING_STARTED.md | 21 ++--- docs/RECEIPTS.md | 15 ++-- docs/RUNBOOK.md | 23 +++--- docs/THREAT_MODEL.md | 63 ++++++++------- .../adr/0001-record-architecture-decisions.md | 4 +- docs/adr/0002-polyglot-architecture.md | 10 +-- .../provisioning/dashboards/dashboards.yml | 4 +- infra/loki/loki.yml | 2 +- infra/promtail/promtail.yml | 10 +-- 55 files changed, 489 insertions(+), 643 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feb312b..87c57a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,11 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20.18" + # "20" pulls the latest 20.x. We were pinned to 20.18.3 which + # tripped eslint-visitor-keys@5.0.1's engines requirement + # (^20.19.0 || ^22.13.0 || >=24). Floating to 20.x latest keeps + # us on Node 20 LTS while satisfying transitive deps. + node-version: "20" cache: "pnpm" - name: Install @@ -134,7 +138,9 @@ jobs: version: 9.12.3 - uses: actions/setup-node@v4 with: - node-version: "20.18" + # See comment in `node` job — float to 20.x latest for engines + # compat with transitive deps (eslint-visitor-keys@5.0.1+). + node-version: "20" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm format:check diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5d0f1dd..80fc364 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,5 +1,17 @@ name: Deploy +# Two jobs: +# 1. build — multi-arch Docker build for all 5 services, push to GHCR. +# Runs on every master push and every v* tag. +# 2. deploy — SSH to the production Hetzner box and `docker compose pull` + +# `up -d`. Gated behind the repo variable AUTO_DEPLOY=true so it +# only fires when secrets are configured. Manual runs always +# attempt deploy. +# +# Build platforms intentionally include linux/arm64 because the production +# host is an ARM cax11. linux/amd64 is kept so contributors can `docker pull` +# on a normal x86 dev box. + on: push: branches: [master] @@ -27,19 +39,28 @@ jobs: strategy: fail-fast: false matrix: + # `context` is the build context Docker uploads. `file` is the path to + # the Dockerfile relative to that context. The two TS workspaces use + # the monorepo root as context (their Dockerfiles reference the root + # package.json + pnpm-lock.yaml + workspace packages); the .NET / Go + # services are self-contained so their context can be the service + # directory itself. include: - image: web - context: ./apps/web + context: . + file: ./apps/web/Dockerfile - image: api-gateway - context: ./apps/api-gateway + context: . + file: ./apps/api-gateway/Dockerfile - image: ingest context: ./services/ingest + file: ./services/ingest/Dockerfile - image: receipt context: ./services/receipt + file: ./services/receipt/Dockerfile - image: reaper context: ./services/reaper - outputs: - image-tag: ${{ steps.meta.outputs.version }} + file: ./services/reaper/Dockerfile steps: - uses: actions/checkout@v4 with: @@ -70,8 +91,12 @@ jobs: uses: docker/build-push-action@v6 with: context: ${{ matrix.context }} + file: ${{ matrix.file }} push: true - platforms: linux/amd64 + # Multi-arch. The Hetzner production host is ARM; contributors + # tend to dev on AMD64. Both platforms get pushed under the same + # tag and Docker pulls the right one based on the host's arch. + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha @@ -82,7 +107,13 @@ jobs: needs: build runs-on: ubuntu-24.04 environment: production - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') + # Only attempt SSH deploy when AUTO_DEPLOY is enabled in repo variables + # OR the workflow was triggered manually. Without this gate the job + # would fail on every push for any fork that hasn't configured the + # 3 production secrets (HETZNER_HOST / HETZNER_USER / HETZNER_SSH_KEY). + if: | + (github.event_name == 'workflow_dispatch') || + (vars.AUTO_DEPLOY == 'true' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))) steps: - uses: actions/checkout@v4 with: @@ -94,14 +125,21 @@ jobs: host: ${{ secrets.HETZNER_HOST }} username: ${{ secrets.HETZNER_USER }} key: ${{ secrets.HETZNER_SSH_KEY }} + # Real install path is /home/slothbox/slothbox (per the slothbox + # user's homedir). Earlier draft assumed /opt/slothbox. script: | - cd /opt/slothbox + cd /home/slothbox/slothbox + git pull --ff-only origin master export IMAGE_TAG=${{ github.sha }} docker compose -f docker-compose.yml -f docker-compose.prod.yml pull docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans docker compose -f docker-compose.yml -f docker-compose.prod.yml ps - name: Smoke test + # Skip if PRODUCTION_DOMAIN isn't configured yet — the project starts + # life on a bare IP (no HTTPS). Once a real domain + Caddy TLS are in + # place, set the secret and this guard auto-enables the check. + if: ${{ secrets.PRODUCTION_DOMAIN != '' }} run: | for i in {1..30}; do if curl -fsS https://${{ secrets.PRODUCTION_DOMAIN }}/healthz; then diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c656e19..087de0e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -6,7 +6,7 @@ on: pull_request: branches: [master] schedule: - - cron: "23 6 * * 1" # weekly Monday 06:23 UTC + - cron: "23 6 * * 1" # weekly Monday 06:23 UTC permissions: contents: read @@ -45,7 +45,9 @@ jobs: version: 9.12.3 - uses: actions/setup-node@v4 with: - node-version: "20.18" + # See ci.yml — float to 20.x latest for engines compat with + # transitive deps that require >=20.19 (eslint-visitor-keys, etc.). + node-version: "20" cache: "pnpm" - run: pnpm install --frozen-lockfile --ignore-scripts - name: Audit (high+) @@ -121,24 +123,31 @@ jobs: strategy: fail-fast: false matrix: + # web + api-gateway use monorepo root as build context; .NET / Go + # services are self-contained. See deploy.yml for the same matrix. include: - image: web - context: ./apps/web + context: . + file: ./apps/web/Dockerfile - image: api-gateway - context: ./apps/api-gateway + context: . + file: ./apps/api-gateway/Dockerfile - image: ingest context: ./services/ingest + file: ./services/ingest/Dockerfile - image: receipt context: ./services/receipt + file: ./services/receipt/Dockerfile - image: reaper context: ./services/reaper + file: ./services/reaper/Dockerfile steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Build image - run: docker build -t slothbox-${{ matrix.image }}:scan ${{ matrix.context }} + run: docker build -t slothbox-${{ matrix.image }}:scan -f ${{ matrix.file }} ${{ matrix.context }} continue-on-error: true # some images may not build until services are fleshed out - name: Scan diff --git a/CHANGELOG.md b/CHANGELOG.md index fe3e6f2..4c696ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Planned for v0.5.0 + - Lucia v3 / better-auth + Argon2id + magic-link primary - Account dashboard with share history, manual revoke - RFC 3161 timestamp receipt issuance @@ -16,6 +17,7 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Grafana dashboards published ### Planned for v1.0.0 + - Per-recipient asymmetric encryption via `age` - Verifiable deletion proofs anchored to a public Merkle root - Standalone offline verifier CLI (full feature) @@ -23,6 +25,7 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Public bug bounty program ### Planned for v1.1.0 + - WebRTC P2P file transfer - MitID OIDC for verified senders - Time-locked / deadman's-switch shares @@ -31,6 +34,7 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.0-alpha.1] — 2026-05-07 ### Added + - Initial scaffold of the v0.1.0-alpha public repository - Monorepo with pnpm workspaces: - `apps/web` — Next.js 15 frontend @@ -66,6 +70,7 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - MIT license ### Security + - Server-side cannot decrypt files — encryption key lives in URL fragment - Audited cryptographic primitives only (no roll-your-own) - Branch protection requires signed commits + CODEOWNERS review @@ -74,6 +79,7 @@ the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Encrypted backups via `age` ### Known limitations (tracked for v0.5 / v1.0) + - No accounts / dashboard yet (anonymous shares only) - RFC 3161 receipts return 501 Not Implemented - Per-recipient encryption not yet implemented diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71409b4..1ae4040 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,7 +84,7 @@ is held to a higher bar. ### Soft rules -- Document the *why* in the PR description, not just the *what*. Cryptographic +- Document the _why_ in the PR description, not just the _what_. Cryptographic changes need their threat-model justification spelled out. - Reference the audited reference implementation you're following. - If the change affects the threat model, update [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) @@ -119,14 +119,15 @@ Pre-commit hook runs the formatters. CI fails on diff. ## Filing issues -| Type | Use | -|---|---| -| Bug | Issue template `bug` | -| Feature request | Issue template `feature` | +| Type | Use | +| ---------------------- | ------------------------------------------------------------------------------------------- | +| Bug | Issue template `bug` | +| Feature request | Issue template `feature` | | Security vulnerability | **DO NOT** file an issue. Email security@philipsloth.com — see [`SECURITY.md`](SECURITY.md) | -| Question | GitHub Discussions (when enabled) | +| Question | GitHub Discussions (when enabled) | When filing a bug, please include: + - SlothBox version (commit SHA or tag) - Browser + OS (for frontend) or Docker version (for backend) - Steps to reproduce @@ -155,4 +156,4 @@ agreement. Anyone whose PR is merged, or whose vulnerability report leads to a fix, gets listed in `CONTRIBUTORS.md` (with consent). For security reports, the standard -"Reported by ___" credit is in the release notes. +"Reported by \_\_\_" credit is in the release notes. diff --git a/MILESTONES.md b/MILESTONES.md index 2c3c271..d9b3b23 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -9,22 +9,23 @@ The phased plan from scaffold to externally-reviewed v1.0. **Status:** in progress (this scaffold) **Goal:** the core encrypted-transfer flow works end-to-end locally. -| Area | Scope | -|---|---| -| Frontend | Drag-drop upload, progress UI, share-link generation, decryption page | -| Crypto | XChaCha20-Poly1305 symmetric encryption in browser via libsodium | -| Key handling | Key in URL fragment (never sent to server) | -| API gateway | `POST /shares`, `GET /shares/:id`, WebSocket progress | -| Ingest | Chunked upload to MinIO via .NET service | -| Storage | MinIO self-hosted bucket | -| Reaper | Expiry sweep daemon (Go) | -| Auth | None (anonymous shares only) | -| Receipts | Out of scope | -| Docs | Full README, SECURITY, ARCHITECTURE, CRYPTO, THREAT_MODEL | -| CI | typecheck + lint + test + gitleaks + npm audit on every push | -| Deploy | Docker Compose works locally; production deploy script | +| Area | Scope | +| ------------ | --------------------------------------------------------------------- | +| Frontend | Drag-drop upload, progress UI, share-link generation, decryption page | +| Crypto | XChaCha20-Poly1305 symmetric encryption in browser via libsodium | +| Key handling | Key in URL fragment (never sent to server) | +| API gateway | `POST /shares`, `GET /shares/:id`, WebSocket progress | +| Ingest | Chunked upload to MinIO via .NET service | +| Storage | MinIO self-hosted bucket | +| Reaper | Expiry sweep daemon (Go) | +| Auth | None (anonymous shares only) | +| Receipts | Out of scope | +| Docs | Full README, SECURITY, ARCHITECTURE, CRYPTO, THREAT_MODEL | +| CI | typecheck + lint + test + gitleaks + npm audit on every push | +| Deploy | Docker Compose works locally; production deploy script | **Exit criteria:** + - `docker compose up -d` brings up all services - Drag a file at , get a share link, open in another browser, file downloads decrypted - All security gates green in CI @@ -36,17 +37,18 @@ The phased plan from scaffold to externally-reviewed v1.0. **Goal:** Accounts let you see history. Receipts make delivery provable. -| Area | Scope | -|---|---| -| Auth | Lucia v3 + Argon2id, magic-link primary, optional password | -| Dashboard | Share history, manual revoke, per-share stats | -| Receipts | RFC 3161 timestamped receipt over file hash on download | -| Audit chain | Append-only log with hash-chain integrity | -| Docs | RECEIPTS.md, full RUNBOOK.md, deployment guide | +| Area | Scope | +| ------------- | ------------------------------------------------------------------------- | +| Auth | Lucia v3 + Argon2id, magic-link primary, optional password | +| Dashboard | Share history, manual revoke, per-share stats | +| Receipts | RFC 3161 timestamped receipt over file hash on download | +| Audit chain | Append-only log with hash-chain integrity | +| Docs | RECEIPTS.md, full RUNBOOK.md, deployment guide | | Observability | Grafana dashboards for upload throughput, share lifetime, receipt latency | -| Stripe | Free vs Pro tiers (paid = bigger files, longer expiry, audit export) | +| Stripe | Free vs Pro tiers (paid = bigger files, longer expiry, audit export) | **Exit criteria:** + - Sign up, upload, download → receipt appears in dashboard - Receipt verifiable via the standalone CLI (skeleton from v0.1) - Stripe webhook lands a paid plan correctly @@ -57,16 +59,17 @@ The phased plan from scaffold to externally-reviewed v1.0. **Goal:** This is what production-grade users can rely on. -| Area | Scope | -|---|---| -| Per-recipient encryption | `age` sealed-boxes — file encrypted to recipient's public key, not just URL key | -| Verifier CLI | Full offline verification of receipts and deletion proofs (brew/scoop/apt) | -| Verifiable deletion | Hash chain of destroyed encryption keys, anchored to a public read-only endpoint | -| External audit | Independent cryptographer review published under `/audits/` | -| Pen test | Third-party application pen test | -| Bug bounty | Public program (low budget, scope-limited) | +| Area | Scope | +| ------------------------ | -------------------------------------------------------------------------------- | +| Per-recipient encryption | `age` sealed-boxes — file encrypted to recipient's public key, not just URL key | +| Verifier CLI | Full offline verification of receipts and deletion proofs (brew/scoop/apt) | +| Verifiable deletion | Hash chain of destroyed encryption keys, anchored to a public read-only endpoint | +| External audit | Independent cryptographer review published under `/audits/` | +| Pen test | Third-party application pen test | +| Bug bounty | Public program (low budget, scope-limited) | **Exit criteria:** + - Audit report published, all critical and high findings fixed - Pen test report published, all critical and high findings fixed - Verifier CLI works against the live service @@ -78,12 +81,12 @@ The phased plan from scaffold to externally-reviewed v1.0. **Goal:** Last-mile features for the regulated-professions wedge. -| Area | Scope | -|---|---| -| WebRTC P2P | Browser-to-browser file transfer when both endpoints online (server only relays signaling) | -| MitID OIDC | Danish digital ID for verified-sender identity attached to receipts | -| Time-locked shares | Files unlock at a specific future date or after a heartbeat lapse | -| Audit export | Bogføringsloven-compliant CSV/JSON export of share history for accountants | +| Area | Scope | +| ------------------ | ------------------------------------------------------------------------------------------ | +| WebRTC P2P | Browser-to-browser file transfer when both endpoints online (server only relays signaling) | +| MitID OIDC | Danish digital ID for verified-sender identity attached to receipts | +| Time-locked shares | Files unlock at a specific future date or after a heartbeat lapse | +| Audit export | Bogføringsloven-compliant CSV/JSON export of share history for accountants | --- diff --git a/apps/api-gateway/Dockerfile b/apps/api-gateway/Dockerfile index 22d1f1f..6db6dbc 100644 --- a/apps/api-gateway/Dockerfile +++ b/apps/api-gateway/Dockerfile @@ -14,7 +14,9 @@ # 3. runner — minimal Node 20 alpine, only the bundled dist + prod deps # --- Stage 1: base ---------------------------------------------------- -FROM node:20.18-alpine AS base +FROM node:20-alpine AS base +# Float to 20.x latest — see apps/web/Dockerfile for the engines-compat +# explanation (eslint-visitor-keys@5.0.1 needs >=20.19). ENV PNPM_HOME=/pnpm \ PATH="/pnpm:$PATH" \ NPM_CONFIG_UPDATE_NOTIFIER=false \ @@ -60,7 +62,7 @@ RUN pnpm --filter @slothbox/api-gateway build RUN pnpm --filter @slothbox/api-gateway --prod deploy /out # --- Stage 3: runtime -------------------------------------------------- -FROM node:20.18-alpine AS runner +FROM node:20-alpine AS runner ENV NODE_ENV=production \ API_PORT=3022 \ diff --git a/apps/api-gateway/src/app.ts b/apps/api-gateway/src/app.ts index 817be44..de7682f 100644 --- a/apps/api-gateway/src/app.ts +++ b/apps/api-gateway/src/app.ts @@ -84,8 +84,7 @@ export function buildApp(): { // HSTS is only meaningful behind HTTPS — skip in dev/local. ...(isProduction ? { - strictTransportSecurity: - "max-age=63072000; includeSubDomains; preload", + strictTransportSecurity: "max-age=63072000; includeSubDomains; preload", } : {}), xContentTypeOptions: "nosniff", @@ -105,8 +104,7 @@ export function buildApp(): { const requestId = c.get("requestId"); await next(); const status = c.res.status; - const durationSec = - Number(process.hrtime.bigint() - start) / 1_000_000_000; + const durationSec = Number(process.hrtime.bigint() - start) / 1_000_000_000; const route = c.req.routePath ?? c.req.path; const method = c.req.method; const cls = statusClass(status); diff --git a/apps/api-gateway/src/lib/config.ts b/apps/api-gateway/src/lib/config.ts index 7179dd5..dce3541 100644 --- a/apps/api-gateway/src/lib/config.ts +++ b/apps/api-gateway/src/lib/config.ts @@ -24,9 +24,7 @@ import { z } from "zod"; */ const ConfigSchema = z.object({ // ─── Runtime ─────────────────────────────────────────────────── - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), // ─── HTTP server ─────────────────────────────────────────────── /** Port the public-facing gateway binds. Caddy reverse-proxies to this. */ @@ -63,15 +61,11 @@ const ConfigSchema = z.object({ * required at startup so that v0.5 auth can land without a config * migration. Must be at least 32 chars. */ - AUTH_SECRET: z - .string() - .min(32, "AUTH_SECRET must be at least 32 characters"), + AUTH_SECRET: z.string().min(32, "AUTH_SECRET must be at least 32 characters"), // ─── Logging ─────────────────────────────────────────────────── /** Pino log level. trace|debug|info|warn|error|fatal. */ - LOG_LEVEL: z - .enum(["trace", "debug", "info", "warn", "error", "fatal"]) - .default("info"), + LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error", "fatal"]).default("info"), // ─── Rate limiting (sliding-window via Valkey) ───────────────── /** Anonymous: max share creates per IP per minute. */ @@ -87,10 +81,7 @@ const ConfigSchema = z.object({ * Caddy strips /api when proxying — the gateway emits absolute URLs the * browser can fetch directly. Defaults are the local dev compose layout. */ - INGEST_PUBLIC_URL: z - .string() - .url() - .default("http://localhost:3023"), + INGEST_PUBLIC_URL: z.string().url().default("http://localhost:3023"), // ─── Limits ──────────────────────────────────────────────────── /** Maximum share lifetime (days) — enforced at create time. */ diff --git a/apps/api-gateway/src/lib/nats.ts b/apps/api-gateway/src/lib/nats.ts index 4debc39..e35dc44 100644 --- a/apps/api-gateway/src/lib/nats.ts +++ b/apps/api-gateway/src/lib/nats.ts @@ -44,10 +44,7 @@ export async function getNats(): Promise { // every caller to await it. void (async () => { for await (const status of nc.status()) { - logger.info( - { component: "nats", type: status.type, data: status.data }, - "nats status" - ); + logger.info({ component: "nats", type: status.type, data: status.data }, "nats status"); } })(); diff --git a/apps/api-gateway/src/middleware/requestId.ts b/apps/api-gateway/src/middleware/requestId.ts index cd0d7f1..3fde30f 100644 --- a/apps/api-gateway/src/middleware/requestId.ts +++ b/apps/api-gateway/src/middleware/requestId.ts @@ -31,8 +31,7 @@ const SAFE_ID = /^[A-Za-z0-9_.-]{1,128}$/; export const requestIdMiddleware = createMiddleware<{ Variables: RequestIdVars }>( async (c, next) => { const incoming = c.req.header("x-request-id"); - const id = - incoming && SAFE_ID.test(incoming) ? incoming : randomUUID(); + const id = incoming && SAFE_ID.test(incoming) ? incoming : randomUUID(); c.set("requestId", id); c.header("x-request-id", id); diff --git a/apps/api-gateway/src/routes/shares.ts b/apps/api-gateway/src/routes/shares.ts index c63f583..eadaa0f 100644 --- a/apps/api-gateway/src/routes/shares.ts +++ b/apps/api-gateway/src/routes/shares.ts @@ -30,16 +30,9 @@ import { getDb, shares, type ShareState } from "@slothbox/db"; import { config } from "../lib/config.js"; import { logger } from "../lib/logger.js"; import { getNats } from "../lib/nats.js"; -import { - rateLimit, - type RateLimitRule, -} from "../middleware/rateLimit.js"; +import { rateLimit, type RateLimitRule } from "../middleware/rateLimit.js"; import type { RequestIdVars } from "../middleware/requestId.js"; -import { - sharesCreatedTotal, - sharesDestroyedTotal, - sharesFetchedTotal, -} from "../lib/metrics.js"; +import { sharesCreatedTotal, sharesDestroyedTotal, sharesFetchedTotal } from "../lib/metrics.js"; /** Hono env shared across routers — every router carries the request id. */ type RouterEnv = { Variables: RequestIdVars }; @@ -104,11 +97,7 @@ function encodeBase64Url(buf: Uint8Array | Buffer): string { * the `digest` extension) to take down the create path. */ async function appendAudit( - eventType: - | "share_created" - | "share_downloaded" - | "share_destroyed" - | "chain_anchor", + eventType: "share_created" | "share_downloaded" | "share_destroyed" | "chain_anchor", shareId: string | null, payload: Record, requestId: string @@ -300,9 +289,7 @@ export function sharesRouter(): Hono { shortId: row.shortId, chunkCount: row.chunkCount, burnAfterRead: body.burnAfterRead, - ttlSeconds: Math.floor( - (new Date(body.expiresAt).getTime() - Date.now()) / 1000 - ), + ttlSeconds: Math.floor((new Date(body.expiresAt).getTime() - Date.now()) / 1000), }, requestId ); @@ -440,12 +427,7 @@ export function sharesRouter(): Hono { } const destroyedId = updated.id; - void appendAudit( - "share_destroyed", - destroyedId, - { shortId, reason: "manual" }, - requestId - ); + void appendAudit("share_destroyed", destroyedId, { shortId, reason: "manual" }, requestId); sharesDestroyedTotal.inc({ reason: "manual" }); // Signal the reaper so blobs are purged ASAP. Best-effort. @@ -455,9 +437,7 @@ export function sharesRouter(): Hono { try { nc.publish( "slothbox.share.destroyed", - new TextEncoder().encode( - JSON.stringify({ shareId: destroyedId, reason: "manual" }) - ) + new TextEncoder().encode(JSON.stringify({ shareId: destroyedId, reason: "manual" })) ); } catch (err) { logger.warn( @@ -467,10 +447,7 @@ export function sharesRouter(): Hono { } })(); - logger.info( - { requestId, event: "share_destroyed", reason: "manual" }, - "share destroyed" - ); + logger.info({ requestId, event: "share_destroyed", reason: "manual" }, "share destroyed"); return c.json({ state: updated.state }, 200); } @@ -493,19 +470,17 @@ export function sharesRouter(): Hono { // The increment_download RPC is the source of truth for state // transitions on download — burn-after-read fires destroyed, // hitting maxDownloads fires expired, both atomically. - let updated: - | { id: string; state: ShareState; burnAfterRead: boolean } - | null = null; + let updated: { id: string; state: ShareState; burnAfterRead: boolean } | null = null; try { const result = await db.execute( sql`SELECT id, state, burn_after_read AS "burnAfterRead" FROM increment_download(${shortId})` ); // postgres-js returns an array-like with rows; defensive read. - const rows = (result as unknown as ReadonlyArray<{ + const rows = result as unknown as ReadonlyArray<{ id: string; state: ShareState; burnAfterRead: boolean; - }>); + }>; const first = rows[0]; if (first) updated = first; } catch (err) { @@ -523,9 +498,7 @@ export function sharesRouter(): Hono { } const becameDestroyed = updated.state === "destroyed"; - const eventType = becameDestroyed - ? "share_destroyed" - : "share_downloaded"; + const eventType = becameDestroyed ? "share_destroyed" : "share_downloaded"; void appendAudit( eventType, diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 61d5e9d..db9422c 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -17,7 +17,10 @@ # Node arch-specific deps (sharp, esbuild) install correctly. # --- Stage 1: base image ------------------------------------------------- -FROM node:20.18-alpine AS base +FROM node:20-alpine AS base +# Float to 20.x latest. Pinned 20.18 fails on transitive deps that require +# >=20.19 (eslint-visitor-keys@5.0.1's engines field). 20-alpine resolves +# to whichever 20.x is current on Docker Hub at build time. ENV PNPM_HOME=/pnpm \ PATH="/pnpm:$PATH" \ NEXT_TELEMETRY_DISABLED=1 \ @@ -73,7 +76,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ RUN pnpm --filter @slothbox/web build # --- Stage 4: minimal runtime image ------------------------------------- -FROM node:20.18-alpine AS runner +FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production \ diff --git a/apps/web/package.json b/apps/web/package.json index fbfdd2f..c81a8cd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,8 @@ "build": "next build", "start": "next start -p 3021", "lint": "next lint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "echo 'web e2e tests live in services/qa; unit tests come in v0.5' && exit 0" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx index 56f25c0..b48925e 100644 --- a/apps/web/src/app/about/page.tsx +++ b/apps/web/src/app/about/page.tsx @@ -14,21 +14,20 @@ export default function AboutPage() { return (
-

+

About

-

+

What SlothBox is, and why it exists.

- SlothBox is an open-source, EU-hosted, end-to-end encrypted file - transfer service. Drop a file, get a link, send the link, your - recipient downloads. The bit that's different: the server - cannot decrypt anything you upload, and you don't have to take - our word for it — the entire stack is on{" "} + SlothBox is an open-source, EU-hosted, end-to-end encrypted file transfer service. Drop a + file, get a link, send the link, your recipient downloads. The bit that's different: + the server cannot decrypt anything you upload, and you don't have to take our word + for it — the entire stack is on{" "}

- WeTransfer scans your file and keeps a copy. Dropbox Transfer reads - your content and runs through US infrastructure (Schrems II problem - for EU users). ProtonDrive is end-to-end encrypted but paid, account-only, - and has no quick-share for unauthenticated recipients. There is no - good European, open-source, end-to-end encrypted file transfer with - court-admissible delivery receipts. SlothBox aims at that gap, with - a focus on the regulated professions where both confidentiality and - provable delivery are statutory requirements. + WeTransfer scans your file and keeps a copy. Dropbox Transfer reads your content and runs + through US infrastructure (Schrems II problem for EU users). ProtonDrive is end-to-end + encrypted but paid, account-only, and has no quick-share for unauthenticated recipients. + There is no good European, open-source, end-to-end encrypted file transfer with + court-admissible delivery receipts. SlothBox aims at that gap, with a focus on the + regulated professions where both confidentiality and provable delivery are statutory + requirements.

-

- Who built it -

+

Who built it

Hi — I'm{" "} Philip Sloth - , a sole-proprietor developer based in Denmark. I build software - where the security guarantees come from the architecture rather than - a marketing page. SlothBox is one of two open-source reference builds - I run alongside client work — the other is{" "} + , a sole-proprietor developer based in Denmark. I build software where the security + guarantees come from the architecture rather than a marketing page. SlothBox is one of two + open-source reference builds I run alongside client work — the other is{" "} -

- Status -

+

Status

- v0.1.0-alpha is a portfolio reference build. The cryptographic - primitives (libsodium, age) are battle-tested, but the SlothBox - integration has not yet been independently audited. Don't use - this for high-stakes secrets until v1.0 + external cryptographer - review. The full roadmap, exit criteria per release, and known gaps - are in{" "} + v0.1.0-alpha is a portfolio reference build. The cryptographic primitives (libsodium, age) + are battle-tested, but the SlothBox integration has not yet been independently audited. + Don't use this for high-stakes secrets until v1.0 + external cryptographer review. + The full roadmap, exit criteria per release, and known gaps are in{" "}

- Slow on purpose. Encryption that's rushed is encryption that - breaks. Every primitive in this stack is audited, every default is - conservative, and every shortcut is documented as such. The brand is - a reminder: trust earns itself slowly. + Slow on purpose. Encryption that's rushed is encryption that breaks. Every primitive + in this stack is audited, every default is conservative, and every shortcut is documented + as such. The brand is a reminder: trust earns itself slowly.

- Built on a Hetzner CCX13 box in Falkenstein with eight other people's - open-source projects holding it up. See{" "} + Built on a Hetzner CCX13 box in Falkenstein with eight other people's open-source + projects holding it up. See{" "} -

+

unexpected error

Something went wrong.

- A hiccup we didn't plan for. Try again, or refresh the page. If - it keeps happening, mention error code{" "} - - {error.digest ?? "unknown"} - {" "} - when reporting. + A hiccup we didn't plan for. Try again, or refresh the page. If it keeps happening, + mention error code{" "} + {error.digest ?? "unknown"} when + reporting.

diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 99dfb8e..c28f619 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -81,11 +81,7 @@ export const viewport: Viewport = { initialScale: 1, }; -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( -

- 404 -

+

404

That share doesn't exist.

- It may have expired, been burned after a previous download, or the - URL may be missing the part after the # - . Most chat clients strip URL fragments — ask the sender to copy and - paste the link directly. + It may have expired, been burned after a previous download, or the URL may be missing the + part after the #. Most chat clients strip URL fragments — + ask the sender to copy and paste the link directly.

- +
-

- {fileName} -

+

{fileName}

{formatBytes(fileSize)} · encrypted with XChaCha20-Poly1305

@@ -118,9 +107,9 @@ export function ShareLink({

- The portion after #key= is the - decryption key. It stays inside this URL — your browser never sends - it to any server. If you lose it, the file is unrecoverable. + The portion after #key= is the decryption key. It stays + inside this URL — your browser never sends it to any server. If you lose it, the file is + unrecoverable.

diff --git a/apps/web/src/components/UploadDrop.tsx b/apps/web/src/components/UploadDrop.tsx index f5ec147..9a18751 100644 --- a/apps/web/src/components/UploadDrop.tsx +++ b/apps/web/src/components/UploadDrop.tsx @@ -27,11 +27,7 @@ import { import { Switch } from "@/components/ui/switch"; import { ShareLink } from "@/components/ShareLink"; import { MAX_FILE_SIZE_BYTES } from "@/lib/config"; -import { - uploadFile, - type UploadProgressEvent, - type UploadResult, -} from "@/lib/upload"; +import { uploadFile, type UploadProgressEvent, type UploadResult } from "@/lib/upload"; import { cn, formatBytes } from "@/lib/utils"; // Expiry options offered to the sender. Server side has its own clamp via @@ -70,9 +66,7 @@ export function UploadDrop() { return; } if (file.size > MAX_FILE_SIZE_BYTES) { - toast.error( - `file is too large (max ${formatBytes(MAX_FILE_SIZE_BYTES)})`, - ); + toast.error(`file is too large (max ${formatBytes(MAX_FILE_SIZE_BYTES)})`); return; } @@ -85,16 +79,13 @@ export function UploadDrop() { burnAfterRead, signal: controller.signal, onProgress: (progress) => { - setState((prev) => - prev.kind === "uploading" ? { ...prev, progress } : prev, - ); + setState((prev) => (prev.kind === "uploading" ? { ...prev, progress } : prev)); }, }); setState({ kind: "done", result, file }); toast.success("Encrypted and uploaded."); } catch (err) { - const message = - err instanceof Error ? err.message : "upload failed"; + const message = err instanceof Error ? err.message : "upload failed"; // The user clicking "cancel" routes through here too — render that as // a neutral idle state, not an error. if (message === "upload cancelled") { @@ -105,7 +96,7 @@ export function UploadDrop() { toast.error(message); } }, - [expiryHours, burnAfterRead], + [expiryHours, burnAfterRead] ); // ---- DOM event handlers ---------------------------------------------- @@ -119,7 +110,7 @@ export function UploadDrop() { void startUpload(file); } }, - [startUpload], + [startUpload] ); const onPick = React.useCallback( @@ -131,7 +122,7 @@ export function UploadDrop() { // Reset so picking the same file twice still fires `change`. e.target.value = ""; }, - [startUpload], + [startUpload] ); const reset = React.useCallback(() => { @@ -180,10 +171,7 @@ export function UploadDrop() { if (state.kind !== "uploading") fileInputRef.current?.click(); }} onKeyDown={(e) => { - if ( - (e.key === "Enter" || e.key === " ") && - state.kind !== "uploading" - ) { + if ((e.key === "Enter" || e.key === " ") && state.kind !== "uploading") { e.preventDefault(); fileInputRef.current?.click(); } @@ -191,9 +179,9 @@ export function UploadDrop() { className={cn( "relative flex min-h-[260px] cursor-pointer flex-col items-center justify-center gap-4 border-b border-[var(--color-border)] p-8 text-center transition-colors", isDragOver - ? "bg-[color-mix(in_srgb,var(--color-accent)_10%,var(--color-card))] border-[var(--color-accent)]" + ? "border-[var(--color-accent)] bg-[color-mix(in_srgb,var(--color-accent)_10%,var(--color-card))]" : "bg-[var(--color-card)]", - state.kind === "uploading" && "cursor-not-allowed", + state.kind === "uploading" && "cursor-not-allowed" )} >

- Up to {formatBytes(MAX_FILE_SIZE_BYTES)} · encrypted in your - browser before upload + Up to {formatBytes(MAX_FILE_SIZE_BYTES)} · encrypted in your browser before upload

{state.kind === "error" ? ( -

- {state.message} -

+

{state.message}

) : null} ) : ( - + )} @@ -237,7 +218,7 @@ export function UploadDrop() {
@@ -263,9 +244,7 @@ export function UploadDrop() {
-

- Self-destruct on first download. -

+

Self-destruct on first download.

- - Encryption happens in your browser. The key never leaves this tab. - + Encryption happens in your browser. The key never leaves this tab.
@@ -304,9 +281,7 @@ function UploadingPanel({
-

- {file.name} -

+

{file.name}

{progress ? `Chunk ${progress.chunksUploaded}/${progress.chunksTotal} · ${formatBytes(progress.bytesUploaded)} / ${formatBytes(progress.bytesTotal)}` @@ -330,13 +305,9 @@ function UploadingPanel({

- {fraction > 0 - ? `${Math.floor(fraction * 100)}% encrypted + uploaded` - : "encrypting…"} - - - XChaCha20-Poly1305 · 5 MiB chunks + {fraction > 0 ? `${Math.floor(fraction * 100)}% encrypted + uploaded` : "encrypting…"} + XChaCha20-Poly1305 · 5 MiB chunks
); diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index f29111f..cdb43d9 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -39,12 +39,11 @@ const buttonVariants = cva( variant: "primary", size: "md", }, - }, + } ); export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends React.ButtonHTMLAttributes, VariantProps { /** When true, render the styles onto the immediate child instead of a `