diff --git a/.cargo/audit.toml b/.cargo/audit.toml
index 7a63df25..24e0b57b 100644
--- a/.cargo/audit.toml
+++ b/.cargo/audit.toml
@@ -23,10 +23,61 @@ ignore = [
"RUSTSEC-2026-0098",
"RUSTSEC-2026-0099",
+ # h2 0.3.27 — RUSTSEC-2026-0258 "unbounded empty DATA frames"
+ # (GHSA-q83h-524g-xf6h). Transitive via aws-smithy-http-client 1.1.12
+ # → hyper 0.14.32 → h2 0.3.27. The patched line is 0.4.16+, but hyper
+ # 0.14's `h2 = "0.3"` requirement pins us to the 0.3.x branch which
+ # will not receive a backport — real fix requires aws-smithy-http-client
+ # to migrate to hyper 1.x (which our other h2 copy — 0.4.16, already
+ # bumped — is on). The 0.4.x copy is fixed via `cargo update`; this
+ # ignore covers only the 0.3.x chain.
+ #
+ # Severity: low (advisory's own classification). Attack is empty-DATA-
+ # frame flooding by a malicious HTTP/2 peer → memory pressure or panic.
+ # In this codebase h2 0.3.x runs strictly on the CLIENT side of AWS
+ # SDK requests to S3 endpoints. Exploitation requires either
+ # compromising AWS S3 (out-of-scope) or MitM with a valid TLS cert
+ # for the configured S3 host (bigger problem than the DoS). No
+ # data-integrity or auth impact; panic path contained by
+ # request-level unwind.
+ #
+ # Un-ignore trigger: aws-smithy-http-client releases a version that
+ # switches to hyper 1.x (checkable with `cargo tree -i h2@0.3` — the
+ # command returns no rows once the chain is gone). Track upstream at
+ # https://github.com/smithy-lang/smithy-rs/issues (search "hyper 1").
+ "RUSTSEC-2026-0258",
+
# instant unmaintained — transitive via azure_core 0.21.0 (latest available).
# No direct security impact; no upgrade path exists.
"RUSTSEC-2024-0384",
+ # azure_core 0.21.0 writes the `authorization` header value to logs —
+ # `policies/transport.rs` does `debug!("…{request:#?}")` over the whole
+ # request. For a SharedKey entry that value is the per-request HMAC
+ # signature; for a SAS entry it is the token. Severity 6.5 (medium).
+ #
+ # The advisory says "upgrade to >=0.22.0". That version does not exist:
+ # `azure_core` jumped 0.21 → 1.x, and `azure_storage_blobs` never left
+ # 0.21.0 before being archived. So the stated remedy IS the official-SDK
+ # migration, tracked separately alongside the quick-xml pair above.
+ #
+ # Not reachable at our log levels: the line is `debug!` on the
+ # `azure_core::policies::transport` target, and the default filter is
+ # `info`. It fires only if an operator explicitly asks for
+ # `RUST_LOG=…,azure_core=debug`, which is not hypothetical — that is the
+ # invocation used to diagnose the Azurite migration hang. **Do not run
+ # `azure_core=debug` against a real Azure account**; it prints request
+ # signatures to the terminal. Against Azurite it only exposes the
+ # published dev key's signatures.
+ #
+ # A subscriber-level directive pinning that target off was prototyped
+ # and rejected 2026-09-02 — not worth carrying a filter hack for a
+ # dependency being replaced.
+ #
+ # Un-ignore trigger: the azure_storage_blob 1.x migration lands
+ # (`cargo tree -i azure_core@0.21` returns no rows).
+ "RUSTSEC-2026-0275",
+
# quick-xml 0.31.0 — transitive via azure_core 0.21.0 (unofficial SDK,
# now archived). Our direct dep is already on 0.41.0; the 0.31 copy is
# only reachable through the azure_storage_blobs chain, which parses
@@ -40,6 +91,38 @@ ignore = [
"RUSTSEC-2026-0195",
"RUSTSEC-2026-0194",
+ # azure_core 0.21.0 — "Legacy azure_core writes the authorization
+ # header value to logs" (RUSTSEC-2026-0275, 6.5 medium). Same
+ # unofficial archived SDK, same absent upgrade path as the other
+ # 0.21.0-chain advisories above: the advisory's "upgrade to
+ # >=0.22.0" applies to the official azure_core crate line, not to
+ # the archived 0.21.0 we're pinned on via the unofficial
+ # azure_storage_blobs SDK. Real fix is the official azure_core 1.0 /
+ # azure_storage_blob 1.0 SDK migration tracked separately (memory
+ # project_azure_sdk_migration_pending) — blocked upstream by the
+ # 1.0 SDK dropping shared-key auth.
+ #
+ # Exposure in this codebase is narrow. The advisory covers the
+ # HTTP client emitting the `Authorization` header value into log
+ # records; for our Azure backend usage that header value is
+ # `SharedKey :` — the shared key itself
+ # never appears, only a per-request HMAC signature bound to the
+ # request's `x-ms-date` and unusable outside the ~15 min clock-skew
+ # window. Reaching the log path further requires (a) an Azure
+ # backend actually being configured (S3 and local are the
+ # alternatives) and (b) the tracing subscriber emitting DEBUG
+ # records for the `azure_core` target — production defaults are
+ # INFO. Under both conditions the worst-case leak is replay of
+ # individual object operations within the skew window by an
+ # attacker who already has production log read access; the shared
+ # key cannot be derived.
+ #
+ # Un-ignore trigger: the official azure_core 1.0 migration lands —
+ # at which point this entry and the other azure_core 0.21.0-chain
+ # entries above (RUSTSEC-2026-0097, -2024-0384, -2026-0195,
+ # -2026-0194) all go away together.
+ "RUSTSEC-2026-0275",
+
# wasmtime 43.0.2 — "Stores can mix up type indices between engines"
# (GHSA-hgjw-h833-99q9). Transitive via extism 1.30.0 (latest published;
# extism `main` still pins wasmtime 43, no upgrade path). The advisory
@@ -58,6 +141,35 @@ ignore = [
# with its own Store (see infrastructure/services/plugins/runtime.rs).
"RUSTSEC-2026-0222",
+ # wasmtime 43.0.2 — "Filesystem sandbox escape when paths or symlinks
+ # contain trailing slashes" (RUSTSEC-2026-0269, 8.8 high). Same crate,
+ # same chain and same absent upgrade path as RUSTSEC-2026-0222 above:
+ # extism 1.30.0 is the latest published and pins wasmtime 43, while the
+ # advisory's fixed releases are >=24.0.13 <25, >=36.0.14 <37,
+ # >=46.0.3 <47, >=47.0.4 — none in the 43.x line, so there is no
+ # version satisfying extism's requirement that carries the fix.
+ #
+ # NOT REACHABLE, and for a stronger reason than the build-feature
+ # gating: this is a WASI filesystem sandbox escape, and OxiCloud's
+ # plugin runtime gives plugins no filesystem to escape from.
+ # `plugins/runtime.rs::compile` builds every plugin with
+ # `.with_wasi(false)` and declares no `allowed_paths`, so there are no
+ # preopened directories — the escape needs one to traverse out of.
+ # `.disallow_all_hosts()` removes outbound network on the same path.
+ #
+ # The build-level gating from the entry above still applies on top:
+ # `plugins` is opt-in and absent from `default`, so the CI release
+ # binary does not link wasmtime; runtime activation additionally needs
+ # OXICLOUD_ENABLE_PLUGINS=true; and plugin binaries are admin-supplied,
+ # not attacker input.
+ #
+ # Un-ignore trigger: EITHER extism releases a version on wasmtime
+ # >=46.0.3 (check with `cargo tree -i wasmtime --features plugins`),
+ # OR `plugins/runtime.rs` gains `allowed_paths` / `with_wasi(true)` —
+ # at which point this stops being unreachable and blocks release
+ # rather than being ignored.
+ "RUSTSEC-2026-0269",
+
# astral-tokio-tar 0.5.6 — tar extraction advisories, transitive via
# testcontainers → testcontainers-modules, a DEV-dependency used only by
# the `--cfg integration_tests` harness to spin up throwaway Postgres
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b880195c..7a19b1ea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -176,6 +176,21 @@ jobs:
with:
components: clippy
- uses: Swatinem/rust-cache@v2
+
+ # `--all-features` includes `bundled-assets`, whose build.rs guard
+ # requires `static-dist/index.html` at compile time (rust-embed
+ # scans the folder). Build the SPA first so the lint pass covers
+ # the embed code paths without needing to enumerate features
+ # around it. ~90 s once, cached by npm-cache on repeats.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 26.3.0
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+ - name: Build SPA (needed for --all-features / bundled-assets)
+ working-directory: frontend
+ run: npm ci && npm run build
+
- run: cargo clippy --all-targets --all-features -- -D warnings
# Mirrors the `wasm-check` justfile recipe. The wasm crate is a
@@ -306,6 +321,18 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
+ # `--all-features` enables `bundled-assets`, whose build.rs guard
+ # requires `static-dist/index.html` at compile time. Build the SPA
+ # first so tests can compile the embed code paths. ~90 s, cached.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 26.3.0
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+ - name: Build SPA (needed for --all-features / bundled-assets)
+ working-directory: frontend
+ run: npm ci && npm run build
+
- name: Initialize test database
# Applies every migration + seeds the integration-test admin row.
# Same script used by `just test-integration` locally.
@@ -460,6 +487,79 @@ jobs:
path: tests/api/storage/
retention-days: 7
+ bundled-binary-test:
+ # `--features bundled-assets` end-to-end integration test.
+ #
+ # Builds oxicloud with the SPA baked in via rust-embed, boots it
+ # against a nonexistent OXICLOUD_STATIC_PATH so the embed path is
+ # forced, and asserts SPA + locales + immutable-cache headers +
+ # CSP all serve correctly from the embedded corpus. Guards against
+ # three failure classes that don't surface in filesystem-served CI:
+ #
+ # 1. rust-embed configuration (glob patterns silently producing a
+ # 0-file embed — hit 2026-08-28).
+ # 2. Debug-vs-release drift (rust-embed's dynamic-read mode in
+ # debug builds masks embed bugs; `debug-embed` feature bakes
+ # files in for both profiles).
+ # 3. Axum `Path` extractor on fallback routes returning 500 (the
+ # `serve_root` handler needs `Request` extraction — hit 2026-08-28).
+ #
+ # See tests/bundled-binary/run.sh + docs/plan/bundled-binary.md § 2.
+ #
+ # Doesn't reuse the `build` job's artifact because that binary is
+ # compiled with `--features plugins`, not `--features bundled-assets`
+ # — different feature set = different target. `Swatinem/rust-cache`
+ # still shares dependency compilation between the two jobs.
+ name: Bundled-assets binary — embed + SPA-serve integration
+ needs: changes
+ if: |
+ github.event_name == 'pull_request' &&
+ (needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true')
+ timeout-minutes: 30
+ runs-on: ubuntu-latest
+ steps:
+ # Same disk-hygiene pattern as the `build` job — cargo release
+ # link + full node_modules install would otherwise squeeze the
+ # runner disk budget under peak concurrency.
+ - uses: jlumbroso/free-disk-space@main
+ with:
+ tool-cache: false
+ android: true
+ dotnet: true
+ haskell: true
+ large-packages: false
+
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 26.3.0
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ # Build the SPA up front so the test script can run with
+ # `SKIP_FRONTEND_BUILD=1` — makes the test-runner log clean
+ # (no duplicated npm ci noise) and puts the SPA build's cost
+ # in its own step for CI-side timing visibility.
+ - name: Build SPA (Vite → static-dist/)
+ working-directory: frontend
+ run: npm ci && npm run build
+
+ - name: Run bundled-binary integration test
+ run: bash tests/bundled-binary/run.sh
+ env:
+ SKIP_FRONTEND_BUILD: "1"
+
+ # Preserve the server log even on failure so a red run doesn't
+ # require re-running locally to see what happened at boot.
+ - uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: bundled-binary-server-log
+ path: tests/bundled-binary/server.log
+ retention-days: 7
+
litmus:
name: WebDAV RFC 4918 — litmus (59/59)
needs: build
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 1bd6e185..7e38a8a3 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -1,7 +1,30 @@
-name: "Docker Hub & GHCR Release"
+name: "Docker Publish (release, main, dry-run)"
+
+# Per-run title shown in the Actions tab list — makes it obvious at
+# a glance which channel a given run served and (for dispatched
+# runs) whether it was a dry-run. Without this, GitHub falls back
+# to the commit subject, which is uninformative when multiple
+# workflows fire on the same commit.
+#
+# Falls back to `github.ref_name` for push / release events (which
+# don't carry `inputs.*`), and stitches "[DRY-RUN]" onto the
+# dispatched cases where `inputs.dry_run` is checked.
+run-name: >-
+ Docker Publish
+ ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '[DRY-RUN]' || '' }}
+ — ${{ github.event.inputs.version || github.ref_name }}
on:
+ # Every merge to `main` republishes the mutable `:main` tag so users
+ # tracking the tip of development can pull the freshest image
+ # without waiting for a release. `:latest` is DELIBERATELY not
+ # touched on this trigger — it stays pointed at the last released
+ # version. Provenance for a specific `:main` pull is queryable via
+ # `docker inspect` (org.opencontainers.image.revision label carries
+ # the SHA).
push:
+ branches:
+ - "main"
tags:
- "v*"
release:
@@ -12,14 +35,44 @@ on:
version:
description: 'Version tag to publish (e.g. v0.5.3)'
required: true
+ dry_run:
+ description: 'Dry run — build only, skip push + verify. Prints the tag set that WOULD be pushed. Use to smoke-test workflow edits without touching the registry.'
+ required: false
+ type: boolean
+ default: false
env:
+ # Docker Hub image name is fixed to the canonical namespace — forks
+ # that opt in to publishing typically also override this with their
+ # own DockerHub account name (see the fork guide in
+ # docs/plan/docker-publish.md, if/when documented).
REGISTRY_IMAGE: diocrafts/oxicloud
- GHCR_REGISTRY_IMAGE: ghcr.io/atalayalabs/oxicloud
+ # GHCR image name follows the repo owner — canonical repo publishes
+ # to `ghcr.io/atalayalabs/oxicloud`; a fork opting in via
+ # `vars.ENABLE_DOCKER_PUBLISH=true` publishes to its own owner's
+ # namespace with zero config edits.
+ GHCR_REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/oxicloud
+
+# Cancel superseded `:main` builds if commits land in quick succession
+# — only the newest one matters, and having two racing builds pushing
+# to the same mutable tag is a coin-toss on which one wins. Release-tag
+# and manual-dispatch builds never cancel: each release is unique and
+# irreversible; every one must publish.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
- # Run tests before publishing
+ # Run tests before publishing.
+ #
+ # SKIPPED on push-to-`main`: the same test matrix already ran on the
+ # PR that produced this commit (branch protection ensures PRs pass
+ # CI before merge). Re-running here would double the CI cost per
+ # merge for zero signal. Release-tag / manual-dispatch builds still
+ # test — they're explicit "shipping this" moments where
+ # belt-and-suspenders matters.
test:
+ if: github.event_name != 'push' || !startsWith(github.ref, 'refs/heads/')
name: Pre-publish Tests
runs-on: ubuntu-latest
services:
@@ -60,30 +113,64 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 360
needs: test
+ # Publish gate — TWO conditions must hold:
+ #
+ # 1. The `test` job succeeded (or was skipped for push-to-main —
+ # branch protection ensures PR CI already ran; see the test
+ # job's `if:`). `always()` unblocks the `needs:` when test is
+ # skipped; the result check still blocks on real failures.
+ #
+ # 2. Publishing is enabled for THIS repo. Canonical
+ # `AtalayaLabs/OxiCloud` always publishes. Forks stay quiet by
+ # default (no accidental GHCR packages / wasted CI minutes when
+ # someone forks just to read code); a fork owner who wants to
+ # test-publish sets `ENABLE_DOCKER_PUBLISH=true` under
+ # Settings → Secrets and variables → Actions → Variables.
+ if: |
+ always() &&
+ (needs.test.result == 'success' || needs.test.result == 'skipped') &&
+ (github.repository == 'AtalayaLabs/OxiCloud' || vars.ENABLE_DOCKER_PUBLISH == 'true')
permissions:
contents: read
packages: write
+ # Job-level env — `secrets` context is legal here but NOT in
+ # step-level `if:` conditions. Precomputing the "is DH configured"
+ # signal as an env var lets downstream steps gate cleanly via
+ # `env.HAS_DOCKERHUB_TOKEN == 'true'` — see the DockerHub login
+ # step below.
+ env:
+ HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
- # Build the exact tag behind the published release or manual dispatch.
+ # Build the exact tag behind the published release or manual
+ # dispatch. On push-to-main, `github.ref` resolves to
+ # `refs/heads/main` and this checks out the freshly-merged
+ # commit — exactly what we want to publish as `:main`.
ref: ${{ github.event.inputs.version || github.event.release.tag_name || github.ref }}
- - name: Set version tag
- id: version
- run: |
- if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
- VERSION="${{ github.event.inputs.version }}"
- elif [ "${{ github.event_name }}" == "release" ]; then
- VERSION="${{ github.event.release.tag_name }}"
- else
- VERSION="${GITHUB_REF#refs/tags/}"
- fi
- # Strip leading 'v' if present for Docker tag
- VERSION="${VERSION#v}"
- echo "VERSION=$VERSION" >> $GITHUB_ENV
- echo "version=$VERSION" >> $GITHUB_OUTPUT
+ - name: Resolve channel + version + tags
+ id: meta
+ # Delegates to `scripts/compute-docker-tags.sh` — logic is
+ # unit-tested via `scripts/test-docker-publish-tags.sh` so
+ # any change to the tag policy gets caught before hitting a
+ # runner. The script emits `version`, `channel`, and `tags`
+ # to $GITHUB_OUTPUT (for later `steps.meta.outputs.*`),
+ # plus `VERSION` / `CHANNEL` / `SKIP_DOCKERHUB` to
+ # $GITHUB_ENV (for later steps that read env directly), plus
+ # a human-readable trailer to stdout for the run log —
+ # useful in dry-run mode where the tag set is the deliverable.
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ GITHUB_REF: ${{ github.ref }}
+ DISPATCH_VERSION: ${{ github.event.inputs.version }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ # Empty DOCKERHUB_TOKEN → skip DH tags cleanly (forks that
+ # opted in via ENABLE_DOCKER_PUBLISH but didn't set up
+ # DockerHub still get their GHCR image published).
+ SKIP_DOCKERHUB: ${{ secrets.DOCKERHUB_TOKEN == '' && 'true' || 'false' }}
+ run: bash "$GITHUB_WORKSPACE/scripts/compute-docker-tags.sh"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -92,6 +179,19 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
+ # Skipped cleanly when DOCKERHUB_TOKEN isn't configured on
+ # this repo — the meta step's SKIP_DOCKERHUB env drops DH
+ # tags from the push set in that case, so we don't need
+ # DH auth at all. Canonical repo always has the secret and
+ # always publishes; forks are opt-in via ENABLE_DOCKER_PUBLISH
+ # AND can further opt in / out of DH separately by
+ # adding / omitting DOCKERHUB_TOKEN.
+ #
+ # `secrets` context is not available in step-level `if:`
+ # conditions — we read it via the job-level env var
+ # `HAS_DOCKERHUB_TOKEN` computed above (which CAN reference
+ # secrets since it lives in `env:`, not `if:`).
+ if: env.HAS_DOCKERHUB_TOKEN == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -109,12 +209,17 @@ jobs:
with:
context: .
platforms: linux/amd64,linux/arm64
- push: true
- tags: |
- ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }}
- ${{ env.REGISTRY_IMAGE }}:latest
- ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }}
- ${{ env.GHCR_REGISTRY_IMAGE }}:latest
+ # `push` flips to `false` for a dry-run — the multi-arch
+ # build still runs (catches Dockerfile regressions), but
+ # nothing hits the registry. Only reachable via
+ # `workflow_dispatch` with `dry_run: true`. Real push
+ # events (release, branch push) always publish.
+ push: ${{ github.event.inputs.dry_run != 'true' }}
+ # Tag set computed in the meta step above — release channel
+ # publishes `:` + `:latest`; main channel publishes
+ # just `:main`. Emitted to the build log either way so the
+ # dry-run mode surfaces "what would ship" in plain sight.
+ tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
# GitHub Actions env piped through so build.rs stamps
@@ -129,7 +234,36 @@ jobs:
GITHUB_HEAD_REF=${{ github.head_ref }}
- name: Verify published image
+ # Skipped on dry-run — nothing was pushed to pull back.
+ # Verifies GHCR unconditionally (always pushed), then adds a
+ # Docker Hub pull when the DH branch actually ran. Simpler
+ # than a matrix — the two registries share the same content
+ # (same multi-arch manifest), so one pull confirms the build
+ # + push worked; the other is just a "did we auth to both"
+ # sanity check.
+ if: github.event.inputs.dry_run != 'true'
run: |
- docker pull ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }}
- docker image inspect ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }}
- echo "✅ Image ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} published successfully"
+ echo "─── Verify GHCR ───"
+ docker pull ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }}
+ docker image inspect ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }} > /dev/null
+ echo "✅ ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }} published"
+ if [ "${{ env.SKIP_DOCKERHUB }}" != "true" ]; then
+ echo "─── Verify Docker Hub ───"
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }}
+ docker image inspect ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} > /dev/null
+ echo "✅ ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} published"
+ else
+ echo "ℹ️ Skipped Docker Hub verification (DOCKERHUB_TOKEN not set on this repo)"
+ fi
+
+ - name: Dry-run summary
+ # Only surfaces in dry-run mode. Mirrors the "Verify" step's
+ # role — gives the operator running the dry-run a clear
+ # closing message with the exact tag set the workflow would
+ # have pushed. The meta step already logged it, this step
+ # just makes it prominent at the bottom of the run.
+ if: github.event.inputs.dry_run == 'true'
+ run: |
+ echo "🔍 DRY RUN — image built + tagged but NOT pushed."
+ echo "Would have published:"
+ echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ /'
diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml
new file mode 100644
index 00000000..a0eb3072
--- /dev/null
+++ b/.github/workflows/release-binaries.yml
@@ -0,0 +1,288 @@
+name: "Release Binaries (musl-linux + macOS)"
+
+# Per-run title shown in the Actions tab — makes it obvious at a
+# glance which tag is being packaged and whether a manual run is a
+# dry-run (build tarballs into workflow artifacts, DON'T attach to
+# any GitHub Release).
+run-name: >-
+ Release Binaries
+ ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '[DRY-RUN]' || '' }}
+ — ${{ github.event.inputs.version || github.ref_name }}
+
+# TRIGGERS — deliberately narrow. This workflow builds 4 platform
+# binaries (~15-25 min wall-clock, matrix of native runners) and
+# attaches them to a GitHub Release. Running on every push to main
+# would be gratuitous CI cost + noise — the point is to package
+# releases, not to sanity-check the tip. The bundled-binary
+# integration test in ci.yml already covers "does the embed still
+# work" on every PR.
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Existing tag to package (e.g. v0.9.0). Must exist on origin.'
+ required: true
+ dry_run:
+ description: 'Dry run — build + upload tarballs as workflow artifacts, skip attaching to a Release. Use to smoke-test workflow edits without publishing.'
+ required: false
+ type: boolean
+ default: false
+
+# Concurrency key includes the tag ref so different tags don't cancel
+# each other; `cancel-in-progress: false` because tag builds are
+# unique + immutable — a superseded release build has nothing to cancel.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: write
+
+jobs:
+ # ── Stage 1: Build the SPA once, share across all platforms ─────────
+ #
+ # The SvelteKit build is arch-independent so a single ubuntu runner
+ # produces static-dist/ for every downstream binary-build matrix
+ # entry — saves ~2 min × 4 = 8 min vs building it per platform.
+ frontend-build:
+ name: Build SPA (Vite → static-dist/)
+ # Publish gate — same fork-friendly pattern as docker-publish.yml.
+ # Canonical repo always builds; forks stay quiet unless the fork
+ # owner opts in via `vars.ENABLE_BINARY_RELEASE=true` under Settings
+ # → Secrets and variables → Actions → Variables.
+ if: |
+ github.repository == 'AtalayaLabs/OxiCloud' ||
+ vars.ENABLE_BINARY_RELEASE == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Build the exact tag being packaged. `github.ref` is
+ # refs/tags/vX.Y.Z on push, refs/heads/... on dispatch (we
+ # override via `inputs.version` in that case).
+ ref: ${{ github.event.inputs.version || github.ref }}
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 26.3.0
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Build SPA
+ working-directory: frontend
+ run: npm ci && npm run build
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: static-dist
+ # Repo-root output (SvelteKit adapter-static's `pages:
+ # '../static-dist'`). Downstream jobs restore it to the same
+ # location so rust-embed's `#[folder = "static-dist/"]`
+ # resolves without any path juggling.
+ path: static-dist/
+ retention-days: 1
+
+ # ── Stage 2: Build one binary per target ────────────────────────────
+ #
+ # 3-way matrix — 2 musl-linux (native amd64 + arm64) + macOS Apple
+ # Silicon. Windows and Intel macOS are deferred:
+ #
+ # * Intel macOS (`x86_64-apple-darwin` / macos-13 runner) — dropped
+ # 2026-08-29. Apple is phasing out Intel Macs from GitHub's hosted
+ # runners; the `macos-13` tier is scheduled for deprecation and
+ # queues stretched past 1 h during v0.9.0-rc1 build. Intel Mac
+ # users have three fallbacks: (1) `cargo install oxicloud
+ # --locked --features bundled-assets` from source, (2) `docker
+ # pull --platform linux/amd64 ghcr.io/atalayalabs/oxicloud`,
+ # (3) any of the two Linux musl tarballs via a Linux VM. The
+ # Intel-Mac install base is small and shrinking (Apple Silicon
+ # >90% of new sales) so first-class shipping isn't worth the
+ # CI-availability tax.
+ # * Windows — separate work when demand appears.
+ #
+ # All targets run natively on GitHub-hosted runners with the host's
+ # glibc + rustup, then cross-compile to their target triple via
+ # `rustup target add`. The musl-linux targets install `musl-tools`
+ # (which provides `musl-gcc`) so aws-lc-sys and friends can link
+ # against musl. macOS runners already have the apple-* triples
+ # pre-installed.
+ #
+ # History: an earlier draft ran Linux builds INSIDE the
+ # `rust:1.96-alpine3.24` container the Dockerfile uses — matched
+ # Docker image byte-for-byte. Broke on `ubuntu-22.04-arm`: GitHub
+ # Actions JS-based actions (`actions/checkout`, artifact steps,
+ # setup-node) can't run inside Alpine on ARM64 — the Node.js binary
+ # they ship depends on glibc, and the x64-Alpine workaround doesn't
+ # extend to arm64. Native ubuntu + musl-tools sidesteps the whole
+ # thing and produces the same output (both are `cargo build
+ # --target x86_64-unknown-linux-musl` / `aarch64-...-musl`).
+ binary-build:
+ name: Build ${{ matrix.triple }}
+ needs: frontend-build
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ strategy:
+ # `fail-fast: false` — one platform's compile failure shouldn't
+ # cancel the other three. Partial releases are better than none.
+ fail-fast: false
+ matrix:
+ include:
+ - triple: x86_64-unknown-linux-musl
+ runner: ubuntu-22.04
+ rustflags: "-C target-cpu=x86-64-v2"
+ - triple: aarch64-unknown-linux-musl
+ runner: ubuntu-22.04-arm
+ # ARMv8-A baseline — covers Pi 4/5, Graviton, every 64-bit
+ # ARM Linux server. `generic` is rustc's neutral baseline.
+ rustflags: "-C target-cpu=generic"
+ - triple: aarch64-apple-darwin
+ runner: macos-latest
+ rustflags: "-C target-cpu=apple-m1"
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.inputs.version || github.ref }}
+
+ # musl-tools ships `musl-gcc` — the C-compiler wrapper that rustc
+ # invokes for -musl targets. Without it, `cargo build --target
+ # aarch64-unknown-linux-musl` fails with "linker `musl-gcc` not
+ # found" on any C-linked dep (aws-lc-sys, ring, sqlx's native
+ # backend when enabled).
+ - name: Install musl-tools (Linux only)
+ if: contains(matrix.triple, '-linux-musl')
+ run: sudo apt-get update && sudo apt-get install -y musl-tools
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.triple }}
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ # Key by triple so the 4 targets don't share caches
+ # (different feature set + different target triple = different
+ # compiled artefacts).
+ key: ${{ matrix.triple }}
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: static-dist
+ path: static-dist/
+
+ # `--features bundled-assets` bakes static-dist/ into the binary
+ # via rust-embed. `--bin oxicloud` — the single binary the merge
+ # (Deliverable 1b) consolidated everything into.
+ - name: Build binary
+ env:
+ # Per-triple CPU baseline — release binaries target the widest
+ # realistic install base for their arch. See
+ # docs/plan/bundled-binary.md § 3.
+ RUSTFLAGS: ${{ matrix.rustflags }}
+ # Git metadata injection — build.rs reads these env vars to
+ # stamp GIT_HASH / GIT_BRANCH into the binary. Without them
+ # `oxicloud --version` reports "unknown".
+ GITHUB_SHA: ${{ github.sha }}
+ GITHUB_REF_NAME: ${{ github.ref_name }}
+ run: |
+ cargo build --release --features bundled-assets --bin oxicloud --target ${{ matrix.triple }}
+
+ # Assemble the tarball layout documented in
+ # docs/plan/bundled-binary.md § 4: oxicloud + example.env +
+ # LICENSE + README-install.md, rooted under a per-version-per-
+ # triple directory so `tar xzf` lands cleanly.
+ - name: Package tarball
+ run: |
+ set -euo pipefail
+ # Version = tag stripped of leading `v` (workflow_dispatch)
+ # or ref_name stripped (push tag). Falls back to ref_name
+ # verbatim if neither strip matches.
+ RAW_REF="${{ github.event.inputs.version || github.ref_name }}"
+ VERSION="${RAW_REF#v}"
+ DIST="oxicloud-${VERSION}-${{ matrix.triple }}"
+ mkdir -p "dist/${DIST}"
+ cp "target/${{ matrix.triple }}/release/oxicloud" "dist/${DIST}/oxicloud"
+ cp example.env "dist/${DIST}/example.env"
+ cp LICENSE "dist/${DIST}/LICENSE"
+ # README-install.md may not exist yet in early releases —
+ # ship a stub that points at the docs site so users have
+ # something in the tarball. Deliverable 6 replaces it with
+ # a proper install guide.
+ if [ -f docs/install/binary.md ]; then
+ cp docs/install/binary.md "dist/${DIST}/README-install.md"
+ else
+ cat > "dist/${DIST}/README-install.md" <<'MD'
+ # OxiCloud — Installation
+
+ Full documentation: https://github.com/AtalayaLabs/OxiCloud/tree/main/docs
+
+ Quickstart:
+ 1. Set DATABASE_URL to a PostgreSQL 13+ instance
+ (with pg_trgm + ltree extensions).
+ 2. Copy example.env → .env, edit as needed.
+ 3. Run ./oxicloud.
+
+ Optional: install ffmpeg for server-side video thumbnails
+ (or set OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false to disable).
+ MD
+ fi
+ # Deterministic tar (owner/group/mtime pinned) so re-running
+ # the build produces byte-identical archives — helps with
+ # reproducible-build audits and cheap hash verification.
+ tar --owner=0 --group=0 -czf "dist/${DIST}.tar.gz" -C dist "${DIST}"
+ ls -la "dist/${DIST}.tar.gz"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: tarball-${{ matrix.triple }}
+ path: dist/*.tar.gz
+ retention-days: 1
+
+ # ── Stage 3: Attach all tarballs + SHA256SUMS to the Release ────────
+ #
+ # `dry_run: true` (workflow_dispatch only) skips this job — the
+ # binary tarballs stay as workflow artifacts (accessible from the
+ # run page for 1 day) but nothing lands on any Release.
+ release:
+ name: Attach tarballs to GitHub Release
+ needs: binary-build
+ if: |
+ needs.binary-build.result == 'success' &&
+ (github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: tarball-*
+ path: dist/
+ merge-multiple: true
+
+ - name: Compute SHA256SUMS
+ run: |
+ set -euo pipefail
+ cd dist
+ # Sort output for stable ordering across re-runs — the file
+ # doubles as a manifest an operator can `diff` between two
+ # release runs to prove they're identical.
+ sha256sum *.tar.gz | sort > SHA256SUMS
+ cat SHA256SUMS
+
+ # softprops/action-gh-release@v2 semantics:
+ # - If the Release for this tag EXISTS (created by release.yml
+ # which runs in parallel on the same tag push), attaches the
+ # files to it.
+ # - If it doesn't yet exist (race — release.yml still running),
+ # creates a bare Release which release.yml then fills in with
+ # notes when it finishes.
+ # Benign either way; see docs/plan/bundled-binary.md § 5
+ # "Parallel-fire behaviour on tag push".
+ - name: Attach to Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ dist/*.tar.gz
+ dist/SHA256SUMS
+ fail_on_unmatched_files: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/Cargo.lock b/Cargo.lock
index 2ee0cc0f..96c38234 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -29,6 +29,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "adler32"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
+
[[package]]
name = "adobe-cmap-parser"
version = "0.4.1"
@@ -601,7 +607,7 @@ dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.3.27",
- "h2 0.4.13",
+ "h2 0.4.16",
"http 0.2.12",
"http 1.4.0",
"http-body 0.4.6",
@@ -1172,6 +1178,16 @@ dependencies = [
"tinyvec",
]
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -1962,6 +1978,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "dary_heap"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
+
[[package]]
name = "dashmap"
version = "6.2.1"
@@ -2884,6 +2906,19 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "globset"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "group"
version = "0.13.0"
@@ -2916,9 +2951,9 @@ dependencies = [
[[package]]
name = "h2"
-version = "0.4.13"
+version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -3197,7 +3232,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2 0.4.13",
+ "h2 0.4.16",
"http 1.4.0",
"http-body 1.0.1",
"httparse",
@@ -3507,6 +3542,39 @@ dependencies = [
"quick-error",
]
+[[package]]
+name = "include-flate"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce"
+dependencies = [
+ "include-flate-codegen",
+ "include-flate-compress",
+]
+
+[[package]]
+name = "include-flate-codegen"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0"
+dependencies = [
+ "include-flate-compress",
+ "proc-macro-error3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "include-flate-compress"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd"
+dependencies = [
+ "libflate",
+ "zstd",
+]
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3802,6 +3870,30 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "libflate"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c"
+dependencies = [
+ "adler32",
+ "crc32fast",
+ "dary_heap",
+ "libflate_lz77",
+ "no_std_io2",
+]
+
+[[package]]
+name = "libflate_lz77"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd"
+dependencies = [
+ "hashbrown 0.16.1",
+ "no_std_io2",
+ "rle-decode-fast",
+]
+
[[package]]
name = "libloading"
version = "0.9.0"
@@ -4278,6 +4370,15 @@ dependencies = [
"rawpointer",
]
+[[package]]
+name = "no_std_io2"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -4532,7 +4633,7 @@ dependencies = [
[[package]]
name = "oxicloud"
-version = "0.8.7"
+version = "0.8.9"
dependencies = [
"accept-language",
"aes-gcm",
@@ -4597,6 +4698,7 @@ dependencies = [
"rand_core 0.6.4",
"rayon",
"reqwest",
+ "rust-embed",
"serde",
"serde_json",
"sha2 0.11.0",
@@ -5006,6 +5108,28 @@ dependencies = [
"toml_edit",
]
+[[package]]
+name = "proc-macro-error-attr3"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "proc-macro-error3"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50"
+dependencies = [
+ "proc-macro-error-attr3",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
[[package]]
name = "proc-macro-utils"
version = "0.10.0"
@@ -5558,6 +5682,12 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rle-decode-fast"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
+
[[package]]
name = "rmp"
version = "0.8.15"
@@ -5597,6 +5727,44 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rust-embed"
+version = "8.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
+dependencies = [
+ "include-flate",
+ "rust-embed-impl",
+ "rust-embed-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-impl"
+version = "8.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
+dependencies = [
+ "mime_guess",
+ "proc-macro2",
+ "quote",
+ "rust-embed-utils",
+ "syn 2.0.117",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-utils"
+version = "8.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
+dependencies = [
+ "globset",
+ "include-flate",
+ "sha2 0.11.0",
+ "walkdir",
+]
+
[[package]]
name = "rust-stemmers"
version = "1.2.0"
@@ -7016,7 +7184,7 @@ dependencies = [
"axum",
"base64 0.22.1",
"bytes",
- "h2 0.4.13",
+ "h2 0.4.16",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
diff --git a/Cargo.toml b/Cargo.toml
index 346ba04a..16cbb6c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,16 +1,34 @@
[package]
name = "oxicloud"
-version = "0.8.7"
+version = "0.8.9"
edition = "2024"
default-run = "oxicloud"
+# `cargo binstall oxicloud` — fetches the prebuilt release tarball for the
+# host triple from GitHub Releases (attached by
+# `.github/workflows/release-binaries.yml`) instead of compiling from
+# source. Templates match the tarball naming
+# `oxicloud--.tar.gz` produced by that workflow.
+#
+# `pkg-fmt = "tgz"` — otherwise binstall guesses from the URL extension;
+# being explicit lets `cargo binstall oxicloud` succeed on Windows too
+# (where the URL string parsing differs).
+#
+# Once the first tagged release lands on GitHub, this becomes a one-line
+# install for anyone with the Rust toolchain who prefers not to build
+# from source and doesn't want Docker either.
+[package.metadata.binstall]
+pkg-url = "{ repo }/releases/download/v{ version }/oxicloud-{ version }-{ target }.tar.gz"
+pkg-fmt = "tgz"
+bin-dir = "oxicloud-{ version }-{ target }/{ bin }{ binary-ext }"
+
[dependencies]
mimalloc = { version = "0.1.52", default-features = false }
axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] }
# "process" was previously enabled implicitly through aws-config's feature
# unification; ffmpeg_video_frame_service needs it, so declare it ourselves.
-tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process"] }
+tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process", "signal"] }
tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] }
tokio-stream = { version = "0.1.18", features = ["fs", "sync"] }
bytes = "1.11.1"
@@ -47,6 +65,25 @@ futures = "0.3.32"
async-stream = "0.3.6"
async-trait = "0.1.89"
mime_guess = "2.0.5"
+# `rust-embed` — compile-time asset embedding for the `bundled-assets` feature.
+# Optional so default builds never pull it in.
+#
+# Features:
+# * `compression` — deflate-compress each embedded file at compile time; the
+# handler decompresses lazily on first read (cached per-file in a
+# `OnceCell`). Halves the on-disk contribution to the final binary.
+# * `include-exclude` — enables the `#[include]` / `#[exclude]` glob
+# attributes on the derive.
+# * `debug-embed` — CRITICAL. Without this, debug builds read files from
+# disk at runtime (dynamic mode) instead of compiling them in. The
+# runtime read is fragile (`CARGO_MANIFEST_DIR` resolution + working
+# directory dependency) and returned `total=0` for us on 2026-08-28.
+# With `debug-embed`, both `cargo build` and `cargo build --release`
+# produce a truly self-contained binary — the only sensible default
+# for the `bundled-assets` feature.
+#
+# See src/interfaces/web/embedded.rs for the actual embed struct + handlers.
+rust-embed = { version = "8", features = ["compression", "include-exclude", "debug-embed"], optional = true }
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
thiserror = "2.0.18"
arc-swap = "1.9"
@@ -168,6 +205,31 @@ faces-onnx = ["dep:ort", "dep:ndarray"]
# `examples/` can measure them. Off by default — adds nothing to prod builds.
# Run with: `cargo bench --features bench` / `cargo run --release --features bench --example bench_thumbnails_mem`.
bench = []
+# Empty marker feature that gates the `generate-openapi` binary out of the
+# default release build set. `just openapi` flips it when the SPA needs a
+# regenerated openapi.json; end-user release builds never do. Kept separate
+# from `test_utils` for the same reason `load_seed_bin` is — enabling
+# `dev_tools` on the CLI must not perturb the oxicloud dependency graph.
+dev_tools = []
+# Bake the SvelteKit build output (`static-dist/` at repo root) into the
+# binary at compile time via `rust-embed`. Opt-in and off by default — the
+# regular `cargo build --release` still produces a filesystem-served
+# binary (matching the current Docker image where assets are separate
+# layers, and the `just dev` HMR loop where Vite serves live). Release
+# tarballs (`docs/plan/bundled-binary.md` § 2) build with this flag to
+# ship a single self-contained executable.
+#
+# Precedence rule preserved: even when this feature is on, if
+# OXICLOUD_STATIC_PATH points at an existing directory, that wins over
+# the embedded fallback — ops can override embedded assets for locale
+# patches or theming without a rebuild.
+#
+# Build-time invariant: `cargo build --features bundled-assets` requires
+# `static-dist/` at the repo root (SvelteKit adapter-static emits there
+# — `frontend/svelte.config.js`'s `pages: '../static-dist'`). `build.rs`
+# fails fast with a pointer to `(cd frontend && npm run build)` when the
+# directory is missing.
+bundled-assets = ["dep:rust-embed"]
[dev-dependencies]
criterion = "0.5"
@@ -186,19 +248,12 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
[[bin]]
name = "generate-openapi"
path = "src/bin/generate-openapi.rs"
-
-[[bin]]
-name = "migrate-nfc-filenames"
-path = "src/bin/migrate-nfc-filenames.rs"
-
-[[bin]]
-name = "oxicloud-cli"
-path = "src/bin/oxicloud-cli.rs"
-# Operator toolbox — subcommand-driven CLI for tasks that don't belong
-# in the main server. Currently: `oxicloud-cli opaque {setup,reset}`.
-# Ships in the release Dockerfile as the single operator-facing helper
-# (replaces the earlier per-task `opaque-setup` bin, which was folded
-# into `oxicloud-cli opaque setup`).
+# Dev-only: regenerates `resources/gen/openapi.json` from the utoipa
+# `#[utoipa::path]` annotations in the API handlers. Gated behind the
+# `dev_tools` feature so `cargo build --release --bins` (and the prod
+# Dockerfile) skip it entirely — end users have no reason to run it.
+# Invoked by `just openapi`, which passes `--features dev_tools`.
+required-features = ["dev_tools"]
[[bin]]
name = "opaque-hurl-helper"
@@ -207,7 +262,10 @@ path = "src/bin/opaque-hurl-helper.rs"
# handshake against a running server. Invoked from tests/api/run.sh
# after opaque_substrate.hurl to cover the parts Hurl can't (OPRF
# blinding, AKE nonces are per-attempt-random). Not shipped in the
-# release Dockerfile (nothing outside tests/ calls it).
+# release Dockerfile (nothing outside tests/ calls it). Gated behind
+# `test_utils` so `cargo build --release --bins` skips it; `run.sh`
+# enables the feature explicitly when building the helper on demand.
+required-features = ["test_utils"]
[[bin]]
name = "dpop-hurl-helper"
@@ -216,7 +274,8 @@ path = "src/bin/dpop-hurl-helper.rs"
# the DPoP-Nonce challenge/retry loop, and covers the wire-protocol
# scenarios Hurl can't express (per-request fresh jti/iat, replay
# detection, malformed proofs, wrong htm/htu/alg/typ). Same
-# no-ship-in-release status as opaque-hurl-helper.
+# no-ship-in-release status as opaque-hurl-helper — gated identically.
+required-features = ["test_utils"]
[[bin]]
name = "load-seed"
@@ -938,11 +997,20 @@ opt-level = 1
debug = "line-tables-only"
split-debuginfo = "unpacked"
# Incremental compilation caches per-function IR fingerprints so a
-# small edit only recompiles what changed. On a single-crate rebuild
-# (oxicloud is one crate) the savings are modest — worth < the ~7 GB
-# incremental/ cache costs on disk. Rust-analyzer uses `cargo check`,
-# which has its own cache, so LSP responsiveness is unaffected.
-incremental = false
+# small edit only recompiles what changed. The `target/incremental/`
+# cache costs ~7 GB per profile, but at the current codebase size a
+# full rebuild is ~10 minutes and an incremental single-file edit is
+# seconds — the disk is worth it and then some. Rust-analyzer's own
+# `cargo check` cache is separate; LSP responsiveness is unaffected
+# either way.
+#
+# History: this was `= false` early on when the crate was small and
+# incremental's savings didn't cover the disk cost. Re-enabled
+# 2026-08-29 as the full-rebuild time crossed the "feels annoying"
+# threshold on typical dev-loop edits. If disk pressure ever spikes,
+# `cargo clean -p oxicloud --profile dev` clears the incremental cache
+# without wiping compiled deps.
+incremental = true
[profile.bench]
lto = "fat"
diff --git a/Dockerfile b/Dockerfile
index 0f86c094..39c1f1a2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -42,11 +42,8 @@ COPY build.rs ./
# Create a minimal project to download and cache dependencies
RUN mkdir -p src/bin && \
echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \
- echo 'fn main() {}' > src/bin/generate-openapi.rs && \
- echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \
- echo 'fn main() {}' > src/bin/oxicloud-cli.rs && \
echo 'fn main() {}' > src/bin/opaque-hurl-helper.rs && \
- cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin oxicloud-cli && \
+ cargo build --release --bin oxicloud && \
rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-*
# ─── Stage 3: Build the application ──────────────────────────────────────────
@@ -86,7 +83,7 @@ RUN DATABASE_URL="${DATABASE_URL}" \
GITHUB_SHA="${GITHUB_SHA}" \
GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \
- cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin oxicloud-cli
+ cargo build --release --bin oxicloud
# The SPA is built by the Vite frontend stage; bring it in for the runtime copy
# below (build.rs has no asset pipeline — it only injects git metadata).
COPY --from=frontend /static-dist ./static-dist
@@ -126,21 +123,24 @@ RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharin
GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \
cargo build --release && \
mkdir -p /app/bin && \
- cp target/release/oxicloud /app/bin/oxicloud && \
- cp target/release/migrate-nfc-filenames /app/bin/migrate-nfc-filenames && \
- cp target/release/oxicloud-cli /app/bin/oxicloud-cli
+ cp target/release/oxicloud /app/bin/oxicloud
# ─── Stage 3c: Select the builder & normalise the binary path ─────────────────
# FROM expands the global ${BUILDER} arg to alias the chosen builder stage
# (`builder` for CI/release, `builder-cache` for the e2e image). It then copies
-# the two shipped binaries from the builder-specific ${BIN_DIR} into a single
-# stable path (/app/release) so the runtime stage's COPYs are independent of
-# which builder ran. `static-dist` already lives at /app/static-dist in both
+# the shipped binary from the builder-specific ${BIN_DIR} into a single stable
+# path (/app/release) so the runtime stage's COPY is independent of which
+# builder ran. `static-dist` already lives at /app/static-dist in both
# builders, so it needs no normalisation.
+#
+# Single `oxicloud` binary since v0.9.0 — the operator toolbox
+# (`opaque setup`, `migrate nfc-filenames`, …) now lives under
+# `oxicloud ` rather than in standalone `oxicloud-cli` /
+# `migrate-nfc-filenames` bins. See docs/plan/bundled-binary.md § 1b.
FROM ${BUILDER} AS app
ARG BIN_DIR
RUN mkdir -p /app/release && \
- cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" "${BIN_DIR}/oxicloud-cli" /app/release/
+ cp "${BIN_DIR}/oxicloud" /app/release/
# ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
FROM alpine:3.24.0
@@ -163,21 +163,18 @@ RUN apk --no-cache upgrade && \
addgroup -g 1001 -S oxicloud && \
adduser -u 1001 -S oxicloud -G oxicloud
-# Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers)
+# Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers).
+#
+# Single `oxicloud` binary — since v0.9.0 the operator toolbox lives
+# under `oxicloud ` rather than as standalone helper bins:
+#
+# docker run --rm oxicloud opaque setup # print OPAQUE ServerSetup
+# docker exec oxicloud migrate nfc-filenames --dry-run
+# # NFC-normalize storage.files.name (pre-June-2026 dbs; safe on new installs)
+#
+# Bare `oxicloud` (Docker CMD default) still starts the server — backwards
+# compat preserved. See docs/plan/bundled-binary.md § 1b.
COPY --from=app --chmod=755 /app/release/oxicloud /usr/local/bin/
-# Ship the NFC filename migration binary alongside the server so
-# operators can run it inside the container without a separate Rust
-# toolchain — `docker exec migrate-nfc-filenames --dry-run`
-# to preview, drop `--dry-run` to execute. One-shot tool, safe to
-# ship; it only mutates `storage.files` rows whose name ≠ NFC(name).
-COPY --from=app --chmod=755 /app/release/migrate-nfc-filenames /usr/local/bin/
-# Ship the OPAQUE server-setup generator alongside the server so operators
-# can generate their `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` value inside the
-# container without a separate Rust toolchain:
-# docker run --rm oxicloud-cli opaque setup # prints the base64 value
-# One-shot, side-effect-free — safe to include; the runtime doesn't
-# invoke it, admins do (see docs/config/authentication.md §OPAQUE).
-COPY --from=app --chmod=755 /app/release/oxicloud-cli /usr/local/bin/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \
chmod 755 /usr/local/bin/entrypoint.sh
diff --git a/README.md b/README.md
index 0ff14ec5..b5b5450a 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,16 @@ docker compose up -d
Open `http://localhost:8086`.
+### Prebuilt binary
+
+Binary releases (Linux musl amd64/arm64, macOS Intel/Apple Silicon)
+are attached to every tagged release on GitHub — the whole SPA + all
+operator subcommands + migrations bake into a single self-contained
+executable. See [`docs/install/binary.md`](docs/install/binary.md) for
+the download / verify / systemd walkthrough.
+
+`cargo binstall oxicloud` works too once a release is out.
+
### Run from source
Requires Rust 1.93+ and PostgreSQL.
diff --git a/build.rs b/build.rs
index 282199e0..28ce7298 100644
--- a/build.rs
+++ b/build.rs
@@ -1,15 +1,58 @@
-//! build.rs — injects git build metadata into the binary.
+//! build.rs — injects git build metadata into the binary and, under the
+//! optional `bundled-assets` feature, guards the compile-time embed
+//! precondition.
//!
//! Exposes `GIT_HASH` and `GIT_BRANCH` (consumed via `env!()` in `main.rs`).
-//! There is no Rust-side asset pipeline: the frontend is built by Vite into
-//! `static-dist/` and served directly by the web layer (`interfaces::web`).
+//! The frontend is built by Vite into `static-dist/` at the repo root and
+//! served directly by the web layer (`interfaces::web`); when
+//! `bundled-assets` is on, `src/interfaces/web/embedded.rs` bakes that
+//! directory into the binary at compile time via `rust-embed`.
use std::env;
+use std::path::Path;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
git_status();
+ bundled_assets_guard();
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// Bundled-assets precondition guard
+//
+// When `--features bundled-assets` is on, `rust-embed`'s `#[folder = "static-dist/"]`
+// scans that directory at compile time and errors with a not-very-helpful
+// "No such file or directory" if it's missing. Users hit this first when they
+// try `cargo build --release --features bundled-assets` before running the
+// frontend build — we intercept it here with a clear, actionable message.
+//
+// Also emits `cargo:rerun-if-changed=static-dist/` so a fresh frontend build
+// re-triggers the embed step without needing `cargo clean` — matches what a
+// dev on the bundled feature would expect after `just fe-build`.
+// ═══════════════════════════════════════════════════════════════════════════════
+fn bundled_assets_guard() {
+ if env::var("CARGO_FEATURE_BUNDLED_ASSETS").is_err() {
+ return;
+ }
+ println!("cargo:rerun-if-changed=static-dist");
+
+ let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
+ let dist = Path::new(&manifest_dir).join("static-dist");
+ let index = dist.join("index.html");
+ if !index.exists() {
+ // `cargo:warning=` prefixes surface these in the terminal even
+ // when cargo's default output is quiet; the panic below turns
+ // them into a compile-time error so the missing prerequisite
+ // can't slip past a distracted dev.
+ println!("cargo:warning=`bundled-assets` feature requires static-dist/ at the repo root.");
+ println!("cargo:warning=Build the SvelteKit SPA first: (cd frontend && npm run build)");
+ println!("cargo:warning=Or via the workspace shortcut: just fe-build");
+ panic!(
+ "build.rs: missing {}/index.html — see the cargo:warning lines above",
+ dist.display()
+ );
+ }
}
// ═══════════════════════════════════════════════════════════════════════════════
diff --git a/clippy.toml b/clippy.toml
new file mode 100644
index 00000000..a59d2fd9
--- /dev/null
+++ b/clippy.toml
@@ -0,0 +1,28 @@
+# Clippy configuration overrides. Kept minimal — each entry documents
+# what it's for and when it should be revisited.
+
+# `clippy::result_large_err` — raise the "big Err variant" ceiling to
+# 512 bytes.
+#
+# Rationale: axum handler signatures shaped as
+# `Result` (or `AppError` variants
+# that wrap `axum::response::Response`) naturally exceed the default
+# 128-byte threshold. `Response` carries a `HeaderMap` (~256 B inline)
+# + status + body + extensions; a handful of handlers land in that
+# range without doing anything wrong. Fighting the lint per-handler
+# with `#[allow]` on every one is churn for zero runtime benefit —
+# these Results are constructed on the stack once per request and
+# never nested in a hot inner loop.
+#
+# 512 B keeps the lint's protective value: it still fires on genuinely
+# oversized Err variants (embedded `Vec` blobs, avatar payloads,
+# large enum aggregates) that WOULD be worth boxing.
+#
+# Revisit if:
+# * A future refactor slims axum Response OR extracts a small error
+# enum with an IntoResponse impl across the handler layer — then
+# drop this override back to the default 128.
+# * A specific handler exceeds 512 B and clippy re-fires — deal with
+# that handler individually (boxed error / small enum) rather than
+# raising the ceiling further.
+large-error-threshold = 512
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index fbd9da52..d0daecd3 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -170,6 +170,7 @@ export default defineConfig({
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
{ text: "Backend Storage", link: "/architecture/backend-storage" },
{ text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" },
+ { text: "Derived and attached blobs", link: "/architecture/derived-and-attached-blobs" },
{ text: "ReBAC & Authorization", link: "/architecture/rebac-authorization" },
{ text: "User lifecycle", link: "/architecture/user-lifecycle" },
{ text: "Authentication model", link: "/architecture/auth-model" },
diff --git a/docs/architecture/backend-storage.md b/docs/architecture/backend-storage.md
index cf22ff17..544ac22f 100644
--- a/docs/architecture/backend-storage.md
+++ b/docs/architecture/backend-storage.md
@@ -26,7 +26,7 @@ probe, and lifecycle behaviour are uniform.
Every entry is declared in `OXICLOUD_STORAGE_ENTRIES` (comma-separated
list of names). The active entry is stored in `admin_settings` and
-switched via `oxicloud --select-storage ` on the command line
+switched via `oxicloud storage select ` on the command line
or automatically at the end of a successful `backend_migration`.
Non-active entries stay reachable through the multi-entry API (test,
audit, migrate-into).
@@ -119,7 +119,7 @@ Rendered visually via `xxd -l 15 `:
Fingerprints are rendered the same colon-hex form (`15:f3:…:50`)
everywhere they appear: boot log, admin panel pair chain, `xxd`
-inspection, `oxicloud --fingerprint ` CLI, and the rotate /
+inspection, `oxicloud storage fingerprint ` CLI, and the rotate /
migration audit lines. That means an admin can cross-reference by
eye — same string means same key.
@@ -234,27 +234,38 @@ can be safely dropped.
---
-## 4. Blob consistency (`blobs_consistency`)
+## 4. Blob consistency — two jobs, split by what they read
-Read-only recoverable job that walks `storage.blobs` and reports
-divergence between the DB registry and the physical backend.
+The registry side and the physical side are separate tenants. They
+used to be one, with `blobs_consistency` probing the backend once per
+row; that probe found strictly less than the merge-join below, at N
+round-trips instead of one enumeration, so it was removed.
-### Shallow mode (default)
+### `blobs_consistency` — database only
-Per row:
+Walks `storage.blobs` and compares `ref_count` against the reference
+count computed from `storage.files.blob_hash` +
+`chunk_manifests.chunk_hashes[]`. On mismatch: `refcount_mismatch`
+(severity `inconsistent`), repairable under `?repair=true`.
-- `blob_exists(hash)` on the active backend → if false, record
- `blob_missing_from_backend` (severity `data_loss`)
-- Compare `ref_count` against the actual reference count computed
- from `SUM` over `storage.files.blob_hash` + `chunk_manifests.chunk_hashes[]`
- → if mismatch, record `refcount_mismatch` (severity `inconsistent`)
+It opens no backend and makes no network call. `?storage=` and
+`?deep=true` are inert. Cost is one aggregate SQL per row.
-Cost: one existence probe + one aggregate SQL per row. Fast on
-S3/Azure (single HEAD).
+### `backend_consistency` — everything physical
-### Deep mode (`?deep=true`)
+Merge-joins the backend's enumeration against `storage.blobs`, both
+ordered by hash, yielding both deltas in one pass:
-Adds a full read of every blob:
+- bytes with no registry row → `orphan_blob` (severity `inconsistent`)
+- a registry row with no bytes → `blob_missing_from_backend`
+ (severity `data_loss`)
+
+`?storage=` scopes it to any declared entry rather than the live
+backend.
+
+### Deep mode (`?deep=true`, on `backend_consistency`)
+
+For every hash present on both sides, adds a full read:
- Stream the blob through `EncryptedBlobBackend::get_blob_stream`
(strips header, decrypts if needed, applies BLAKE3 rescue for
@@ -336,7 +347,7 @@ readonly (source stays active — writes safe there), and returns
`RunOutcome::Failed`. Operator inspects findings, then either
retries (walk short-circuits on head-format matches → cheap
re-attempt), fixes the source, or explicitly accepts the partial
-via `oxicloud --select-storage `.
+via `oxicloud storage select `.
---
diff --git a/docs/architecture/caching.md b/docs/architecture/caching.md
index ed93aae6..7e2ba448 100644
--- a/docs/architecture/caching.md
+++ b/docs/architecture/caching.md
@@ -1,8 +1,13 @@
# Caching Architecture
-OxiCloud uses **moka** (a lock-free, concurrent cache) for write-behind caching that delivers sub-millisecond hot reads.
+OxiCloud runs two independent cache layers with different jobs:
-## Cache Layers
+1. **In-memory metadata caches** — [moka](https://docs.rs/moka) instances that sit in front of PostgreSQL and other hot-path lookups. Sub-millisecond hits, bounded by entry count, TTL-evicted. Cover file metadata, directory listings, blob hashes, audio metadata, small thumbnails, on-the-fly image transcodes.
+2. **On-disk blob cache** — an LRU cache of blob **bytes** on local SSD, only meaningful when the storage backend is remote (S3, Azure). Turns remote fetches into local reads for hot content; bounded by a disk-budget in bytes, LRU-evicted. Off by default.
+
+The two layers are orthogonal — the moka caches shave query round-trips regardless of backend; the disk-blob cache shaves network round-trips when the backend is elsewhere.
+
+## Layer 1 — In-memory metadata caches (moka)
| Cache | TTL | Max Entries | Purpose |
|---|---|---|---|
@@ -13,21 +18,63 @@ OxiCloud uses **moka** (a lock-free, concurrent cache) for write-behind caching
| Blob hash | 30 s TTI | 5 000 | BLAKE3 hashes for dedup lookups |
| Audio metadata | — | 2 000 | ID3 tags and duration |
-## How It Works
+### How it works
1. **Read path:** check cache → if hit, return immediately (sub-ms); if miss, query PostgreSQL, populate cache, return
2. **Write path:** update PostgreSQL → invalidate relevant cache entries
3. **TTL expiry:** entries are evicted after their time-to-live, ensuring eventual consistency
-## Why moka?
+### Why moka?
- **Lock-free** — no mutex contention under concurrent access
- **Bounded memory** — max entries prevent unbounded growth
- **TTL + TTI** — supports both time-to-live and time-to-idle eviction
- **Async-ready** — works natively with Tokio
+## Layer 2 — On-disk blob cache
+
+A local-SSD LRU cache of blob bytes, sitting between OxiCloud and remote storage backends (S3, Azure, or any other `BlobStorageBackend`). Every blob read probes the local cache first; misses fetch from the remote backend and populate the cache. Writes go to the remote backend AND the local cache simultaneously, so a just-uploaded blob is immediately hot for its own re-reads.
+
+Structurally: the bytes live on disk, one `.blob` file per hash, sharded by hash prefix under a configurable directory (default `{root}/.blob-cache//.blob`). The in-process index is a `moka::sync::Cache` with a byte-weigher — same crate as Layer 1, but weighing by content size not entry count, and only tracking file existence, not payload.
+
+### When it earns its keep
+
+Turn on for any deployment where the backend is not on the same box:
+
+- S3 (AWS, DigitalOcean Spaces, Cloudflare R2, MinIO on another host, …)
+- Azure Blob Storage
+- Any future network-attached backend
+
+Local backends (`LocalFilesystem`) don't need it — they're already on the same box. Enabling it there just doubles disk usage for zero latency win.
+
+**Thumbnails are the strongest reason to turn this on.** OxiCloud stores thumbnails as blobs alongside primary content (via `content_derived_blobs`, tracked in `storage.blobs` like any other blob) — the sidecar-on-disk layout is gone. On a remote backend this means every thumbnail render is a network fetch: a photos grid with 100 thumbnails is 100 S3 requests, per user, per visit. With Layer 2 on, that cost is paid once per thumbnail hash; every subsequent grid render is local-disk reads.
+
+Concrete impact for the photos / file-listing hot paths:
+
+- **Cold render** (all thumbnails uncached): one remote fetch per thumbnail, latency dominated by the backend's per-request round-trip (S3 typically 30-80 ms per object, more at distance).
+- **Warm render** (thumbnails cached): local `open()` + read, sub-millisecond per file.
+- **Hit rate in practice**: high — thumbnails are small (typically 5-30 KB per size variant), users re-visit the same folders repeatedly, and the LRU pattern strongly favours recency.
+
+Rule of thumb: if your backend is remote AND you have any user-facing photo grid or file browser, Layer 2 is worth the disk budget. On S3 backends it's the difference between a snappy gallery and a spinner-per-tile browsing experience.
+
+### Sizing guidance
+
+The cache is LRU on a disk-budget basis. A working set larger than the cache size will still work but re-fetch cold blobs from the remote — no correctness cost, just latency. Rough sizing:
+
+- **Home / personal cloud** — 5-10 GB is plenty; the working set for a household of active users is small.
+- **Small team / SMB** — 50-100 GB for a hot photo library or shared document store.
+- **Large deployment** — size against your top-decile access pattern; the cache doesn't need to cover the whole store.
+
+The default budget is 50 GB (only applied if the cache is enabled). Adjust to what your local SSD can spare.
+
+### Interaction with the moka layer
+
+Independent. A file-metadata hit in Layer 1 tells you the row exists and has a `blob_hash` — but reading the actual bytes still goes through Layer 2 (or straight to the remote backend if disabled). A hit in Layer 2 short-circuits the network fetch; a miss populates it for the next read.
+
## Configuration
+### In-memory metadata caches (Layer 1)
+
Cache parameters are currently hardcoded in `src/common/config.rs`. Key defaults:
```rust
@@ -35,3 +82,15 @@ file_cache_ttl_ms: 60_000, // 1 minute
directory_cache_ttl_ms: 120_000, // 2 minutes
max_cache_entries: 10_000,
```
+
+### On-disk blob cache (Layer 2)
+
+Environment-tunable — off by default; enable per deployment when the backend is remote:
+
+| Env var | Default | Purpose |
+|---|---|---|
+| `OXICLOUD_STORAGE_CACHE_ENABLED` | `false` | Master switch. Set `true` to wrap the blob backend with the cache decorator. |
+| `OXICLOUD_STORAGE_CACHE_MAX_SIZE` | `53687091200` (50 GB) | Disk-budget in bytes. LRU eviction fires when the cache exceeds this size. |
+| `OXICLOUD_STORAGE_CACHE_PATH` | `{root}/.blob-cache` | Where the cache files live. Point at a fast SSD; can be a separate volume from the primary storage root. |
+
+Restart the server after changing any of these — the cache is instantiated once at boot around the configured blob backend.
diff --git a/docs/architecture/derived-and-attached-blobs.md b/docs/architecture/derived-and-attached-blobs.md
new file mode 100644
index 00000000..19825431
--- /dev/null
+++ b/docs/architecture/derived-and-attached-blobs.md
@@ -0,0 +1,284 @@
+# Derived and attached blobs
+
+Two tables hang small artifacts off the blob store: thumbnails,
+transcodes, uploaded previews. They look almost identical, and the
+difference between them is a security boundary rather than a style
+choice.
+
+("Satellite tables" is the shorthand used in the code and in
+`satellites_consistency`, the job that walks both. It is a useful
+collective noun once you know what it covers; this page is what it
+covers.)
+
+- **`storage.content_derived_blobs`** — things the *server derived from
+ file content*. Keyed by the BLAKE3 of the source.
+- **`storage.file_attached_blobs`** — things a *user attached to one
+ specific file*. Keyed by `file_id`.
+
+Both point into the same blob store underneath (see
+[Backend Storage](./backend-storage.md)). The keying is what separates
+them.
+
+```mermaid
+erDiagram
+ FILES ||--o{ ATTACHED : "file_id — per FILE"
+ FILES }o--|| BLOBS : "blob_hash (its content)"
+ BLOBS ||--o{ DERIVED : "source_hash — per CONTENT"
+ DERIVED }o--o| ARTIFACT : "blob_hash (NULL = negative)"
+ ATTACHED }o--|| ARTIFACT : "blob_hash"
+
+ FILES {
+ uuid id PK
+ text blob_hash
+ }
+ DERIVED {
+ text source_hash PK
+ text kind PK
+ text variant PK
+ text blob_hash "NULL = not worth deriving"
+ text content_type "NULL iff blob_hash NULL"
+ }
+ ATTACHED {
+ uuid file_id PK
+ text kind PK
+ text variant PK
+ text blob_hash
+ uuid uploaded_by "no FK; nil = imported"
+ }
+ ARTIFACT {
+ text hash PK
+ }
+```
+
+Read the two arrows into `ARTIFACT`: `DERIVED` reaches it from
+**content**, `ATTACHED` from a **file**. Everything below follows from
+that.
+
+## Why two tables and not one with a `kind` column
+
+Content keying means identical bytes share one derivation. Upload the
+same photo twice and the server renders its thumbnail **once** — both
+files resolve to the same `source_hash`, find the same row, and serve
+the same blob. That is the entire point for server-derived artifacts:
+the derivation is a pure function of the content, so sharing it is
+free and correct.
+
+Apply the same keying to *user-supplied* bytes and it becomes an
+attack. If uploaded previews were content-keyed, uploading a file whose
+content matches someone else's would let you replace the preview they
+see — or read yours in place of theirs. The preview is not derived from
+the content; it is an assertion *about* a file, made by whoever uploaded
+it, and two people can hold different assertions about identical bytes.
+
+A single table with a `kind` discriminator could not express this. The
+key would have to be one thing or the other, and either choice is wrong
+for half the rows. The split is the enforcement mechanism, not a
+filing convenience — which is why `thumb_derived_import` explicitly
+refuses `ext-` filenames and `thumb_attached_import` explicitly refuses
+hash-named ones, rather than one job handling both trees.
+
+## `storage.content_derived_blobs`
+
+| column | type | notes |
+|---|---|---|
+| `source_hash` | `VARCHAR(64)` | PK. BLAKE3 of the **source** content. Dependent reference — holds no refcount; the row is reaped with its source. |
+| `kind` | `TEXT` | PK. `thumbnail` \| `transcode` (CHECK-constrained). |
+| `variant` | `TEXT` | PK. Opaque rendering discriminator — see [Variant](#variant-holds-every-axis-that-can-vary). |
+| `blob_hash` | `VARCHAR(64)` | The derived artifact, **or NULL** — see [Negative rows](#negative-rows). Reference **holder** when present. |
+| `content_type` | `TEXT` | MIME of the artifact. NULL exactly when `blob_hash` is NULL. |
+| `created_at` | `TIMESTAMPTZ` | |
+
+A CHECK keeps `blob_hash` and `content_type` NULL together: a type
+without bytes describes nothing, and bytes without a type cannot be
+served.
+
+## `storage.file_attached_blobs`
+
+| column | type | notes |
+|---|---|---|
+| `file_id` | `UUID` | PK. FK to `storage.files` **ON DELETE CASCADE**. |
+| `kind` | `TEXT` | PK. `preview` \| `subtitle` \| `cover_art` (CHECK-constrained). |
+| `variant` | `TEXT` | PK. |
+| `blob_hash` | `VARCHAR(64)` | `NOT NULL` — there is no negative case here. |
+| `content_type` | `TEXT` | `NOT NULL`. |
+| `uploaded_by` | `UUID` | `NOT NULL`, and deliberately **no FK**. |
+| `created_at` | `TIMESTAMPTZ` | |
+
+`uploaded_by` follows the provenance convention: an FK with
+`ON DELETE SET NULL` would erase the audit trail exactly when it matters
+most, and without an `ON DELETE` clause it would block deleting a user
+at all. Deleting the uploader must not rewrite history, so the id is
+kept even once it no longer resolves. Rows created by the migration
+carry the all-zeros sentinel — "imported, uploader unknown" — rather
+than a fabricated owner such as the file's `created_by`, which could
+later be misread as evidence that someone replaced a preview.
+
+## Negative rows
+
+`content_derived_blobs.blob_hash` is nullable, and a NULL row means:
+**this derivation was attempted and is known not to be worth storing
+for this content.**
+
+The case that motivated it: `ImageTranscodeService` can only discover
+that WebP comes out *larger* than the original by doing the full decode
+and re-encode. Without a record, every request repeats that work to
+throw the result away. The same applies to a source that cannot be
+decoded, or one over the decode ceiling.
+
+Only failures **deterministic in the content** may be recorded. A
+timeout, a closed semaphore, an I/O error reading the source are
+properties of the moment, not the bytes; persisting one marks a
+perfectly good image as underivable forever, with nothing to retry it.
+The asymmetry sets the default — a wrongly-cached transient is silent
+and permanent, a missing negative merely costs repeated work — so **when
+in doubt, do not write the row.**
+
+A sentinel hash was considered and rejected: it would stop `blob_hash`
+naming a real blob, and every consumer joining on it would need to learn
+the exception or silently mishandle it. NULL is already SQL's way of
+saying "no blob", and joins drop it naturally.
+
+`file_attached_blobs` has no negative case. There is nothing to attempt
+— the bytes either arrived from a client or they did not.
+
+### The NULL trap
+
+This has caused two bugs, both found before shipping, and it will cause
+more. SQL comparison against NULL yields NULL, so:
+
+```sql
+EXISTS (SELECT 1 FROM storage.blobs b WHERE b.hash = d.blob_hash)
+```
+
+is **false** for every negative row. Whether that is right depends
+entirely on what you are asking:
+
+- **Refcounts — correct.** A negative row holds no reference, so it must
+ not contribute. `content_derived_ref_sql` relies on exactly this.
+- **Dangling checks — wrong.** `satellites_consistency` reported every
+ negative row as `derived_dangling_blob` at `data_loss` severity: a row
+ correctly pointing at nothing, reported as an artifact that had gone
+ missing. It needs `d.blob_hash IS NULL OR `.
+- **Enumeration — wrong, and it fails loudly.**
+ `list_referenced_blobs` decodes `blob_hash` into `String`; the first
+ NULL takes the whole sweep down. It needs `WHERE blob_hash IS NOT NULL`.
+
+Anything joining on `blob_hash` has to decide which of these it is.
+
+## `variant` holds every axis that can vary
+
+`variant` is an opaque discriminator, and the schema states the rule:
+new axes go **inside this string, never into new columns**.
+
+The two tables therefore look asymmetric, and correctly so:
+
+| table | variant | why |
+|---|---|---|
+| `content_derived_blobs` | `icon.webp`, `preview.jpg` | size **and** format |
+| `file_attached_blobs` | `icon` | size only |
+
+A single source legitimately has two thumbnails at one size — WebP for
+capable clients, JPEG for the rest — and since the PK is
+`(source_hash, kind, variant)`, the format must be inside `variant` or
+those rows collide and only one can exist. Uploaded previews have no
+format axis: `store_external_thumbnail` re-encodes to JPEG on write, so
+`image/jpeg` is a constant and `.jpg` in every variant would carry no
+information.
+
+**Rejected alternative: key on `content_type` instead.** Making
+`content_type` part of the key would express the same thing, and it was
+reasonable until negative rows landed. It is now foreclosed —
+`content_type` must be nullable for negative rows, and PostgreSQL does
+not allow a nullable column in a primary key. A UNIQUE constraint would
+not rescue it either: NULLs compare as *distinct* in a unique index, so
+duplicate negative rows for one `(source_hash, kind, variant)` would
+become possible, and that row's singularity is what the mechanism
+depends on. Two softer objections stand regardless — `content_type` is a
+presentation value, and MIME strings are not canonical (`image/jpg` and
+`image/jpeg` name one format).
+
+**If the assumption changes**, the migration has a known shape.
+`20261022000000_derived_variant_encodes_format.sql` did it once for the
+derived table: append the format to existing variants, update the
+callsites that build the string. Nothing is lost in the meantime —
+`content_type` records the real format — it simply is not part of the
+key, so two formats cannot coexist until it is.
+
+## Worked examples
+
+**The same image uploaded twice.** Two `storage.files` rows, one
+`blob_hash` between them, **one** `content_derived_blobs` row per
+`(kind, variant)`, one thumbnail blob. The second upload renders
+nothing; it finds the existing row. Copying either file adds no row at
+all — the copy shares the source hash, so it resolves to the same
+derivation.
+
+**A PDF with a client-uploaded preview.** One `file_attached_blobs` row
+keyed by that `file_id`. Copy the file and the row is **duplicated** for
+the new id (`storage.copy_file_satellites`), because the preview belongs
+to the file, not to the content. Without that duplication the copy
+silently loses its preview — and a PDF has no server-side render path,
+so nothing regenerates it.
+
+**A screenshot WebP cannot shrink.** One row, `blob_hash` and
+`content_type` both NULL. A reader concludes: the transcode was
+attempted, it is known not to help *for this content*, serve the
+original and do not retry. Every file sharing those bytes inherits the
+verdict.
+
+**One source, thumbnailed and transcoded.** Two rows, same
+`source_hash`, `kind` of `thumbnail` and `transcode`. Add a JPEG
+fallback thumbnail and it is a third row, differing only in `variant`
+(`icon.webp` vs `icon.jpg`).
+
+## Lifecycle
+
+**References.** A positive `blob_hash` is a reference *holder* — it
+bumps `chunk_manifests.ref_count` through `DedupService::add_reference`,
+so `dedup_gc` cannot reap an artifact a satellite still points at. A
+negative row holds none. `source_hash` is a *dependent* reference and
+holds nothing.
+
+**Reaping.** Derived rows are removed by `purge_derived_blobs` when
+their source blob is reaped. Attached rows vanish by
+`ON DELETE CASCADE` when their file is deleted — which happens **inside
+the database**, where the Rust lifecycle hooks cannot observe it, so
+`trg_file_attached_blobs_decrement_blob_ref` releases the blob reference
+on `DELETE`. The trigger fires on DELETE only; replacing a preview
+updates `blob_hash` in place and the Rust path handles that reference
+swap.
+
+**Writing a derived row requires its source to exist.**
+`store_derived_blob` guards the insert with an `EXISTS` on
+`chunk_manifests`/`blobs`. Without it, a row written just after its
+source was reaped would pin its artifact forever: nothing would ever
+reap that `source_hash` again, so `purge_derived_blobs` could never
+fire. That is not hypothetical — it shipped once, as a permanent blob
+leak.
+
+**Consistency coverage.** `satellites_consistency` is the only job that
+walks these tables, and it exists because of a gap the others cannot
+close: a satellite row whose *source* is gone breaks no invariant any
+other check looks at. The reference is valid, the refcount is correct,
+the bytes are present — every Blob-centric job agrees the system is
+healthy while the artifact is pinned forever. Blob-side integrity
+(missing bytes, orphans, bit-rot) belongs to `backend_consistency`;
+refcount arithmetic to `blobs_consistency` and
+`manifests_consistency`.
+
+## Adding a third artifact type
+
+Ask one question first: **is it derived from the content, or asserted
+about a file?**
+
+Derived from content — a waveform, an extracted page count, an OCR
+layer — goes in `content_derived_blobs` under a new `kind`, and shares
+across identical content for free.
+
+Supplied by a user — a custom cover image, a hand-authored subtitle
+track — goes in `file_attached_blobs`, and must be duplicated on copy
+rather than shared.
+
+Getting that backwards is not a performance mistake. Putting
+user-supplied bytes in the content-keyed table means one user's upload
+is served to everyone whose file happens to match.
diff --git a/docs/architecture/index.md b/docs/architecture/index.md
index 62f12d10..2ed52f5d 100644
--- a/docs/architecture/index.md
+++ b/docs/architecture/index.md
@@ -75,4 +75,5 @@ src/
- [Resource Listing API →](/architecture/resource-listing)
- [Storage Quotas →](/architecture/storage-quotas)
- [Backend Storage →](/architecture/backend-storage)
+- [Derived and Attached Blobs →](/architecture/derived-and-attached-blobs) — thumbnails, transcodes and uploaded previews: why content-keyed and file-keyed artifacts need separate tables
- [Background Jobs →](/architecture/jobs)
diff --git a/docs/config/admin-settings.md b/docs/config/admin-settings.md
index 0dc1f082..e80b6820 100644
--- a/docs/config/admin-settings.md
+++ b/docs/config/admin-settings.md
@@ -93,19 +93,23 @@ Runs are recoverable — status, cursor, and per-blob failure findings all live
If an entry is renamed or removed from `.env` while the DB pointer still names the old one, boot aborts with a clear error pointing at:
```
-oxicloud --select-storage
+oxicloud storage select
```
This one-shot repair command re-runs the same env-parse the server does at boot, verifies `` is declared in `OXICLOUD_STORAGE_ENTRIES`, updates `admin_settings.storage.active_backend_name` in the DB, and exits. Operator then restarts normally. See [Environment Variables — Storage Entries](/config/env#storage-entries-multi-entry-recommended) for the model, and [`oxicloud --help`](https://github.com/oxicloud/oxicloud/blob/main/src/main.rs) for the full flag list.
### Auditing entries other than the active one
-`blobs_consistency` and `backend_consistency` (recoverable jobs on the Jobs tab) accept `?storage=` to probe any declared entry — not just the live one. Use this to verify a migration target before cutover, or to audit an old backend after cutover but before decommissioning:
+`backend_consistency` (a recoverable job on the Jobs tab) accepts `?storage=` to audit any declared entry — not just the live one. Use this to verify a migration target before cutover, or to audit an old backend after cutover but before decommissioning:
```
-POST /api/admin/jobs/blobs_consistency/trigger?storage=
+POST /api/admin/jobs/backend_consistency/trigger?storage=
```
+Add `?deep=true` to also read every blob back and re-hash it, which catches silent bit-rot. That is a full read of the entry and can take hours.
+
+`blobs_consistency` does *not* accept `?storage=`: it only reads the database, so there is no entry for it to scope.
+
Unknown names 400 at the HTTP layer.
## Data Storage
diff --git a/docs/config/authentication.md b/docs/config/authentication.md
index 967bc0b7..1b8b8e97 100644
--- a/docs/config/authentication.md
+++ b/docs/config/authentication.md
@@ -135,12 +135,12 @@ Password-using deployments will opt in via three env vars:
2. **`OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`** — generated once and persisted like your JWT secret. Rotating this invalidates every user's registration; treat it as one of the crown jewels. Two ways to generate:
```bash
# Docker (recommended in production — no toolchain needed):
- docker run --rm ghcr.io/atalayalabs/oxicloud:latest oxicloud-cli opaque setup
+ docker run --rm ghcr.io/atalayalabs/oxicloud:latest oxicloud opaque setup
# From a source checkout:
- cargo run --bin oxicloud-cli -- opaque setup
+ cargo run --bin oxicloud -- opaque setup
```
- Both print the base64 value on stdout (with guidance on stderr, so shell pipelines like `$(docker run ... oxicloud-cli opaque setup)` capture cleanly).
+ Both print the base64 value on stdout (with guidance on stderr, so shell pipelines like `$(docker run ... oxicloud opaque setup)` capture cleanly).
3. **`OXICLOUD_AUTH_OPAQUE_KSF_*`** — client-side Argon2id key-stretching cost. Defaults (46 MiB / 1 iter / 1 lane) match OWASP's interactive-auth recommendation. See the next section for the rationale + when to bump.
The `OXICLOUD_HASH_*` variables (server-side legacy Argon2) and `OXICLOUD_AUTH_OPAQUE_KSF_*` (client-side OPAQUE Argon2) are intentionally separate: the server-side path is RAM-bounded by concurrent-login traffic and needs to stay modest; the client-side path is single-user per attempt and can be tuned independently. Tuning them together would force a bad compromise in one direction or the other.
diff --git a/docs/config/env.md b/docs/config/env.md
index ffc09e5f..0d1e02a8 100644
--- a/docs/config/env.md
+++ b/docs/config/env.md
@@ -18,6 +18,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator
| `OXICLOUD_CHUNK_DIR` | `{STORAGE_PATH}/.uploads` | Root directory for chunked-upload sessions (REST + NextCloud). Direct (non-chunked) uploads stream straight into the blob store and need no spool directory. Placement guidance: see [Storage Fine Tuning](./storage-fine-tuning.md). |
| `OXICLOUD_REUSE_PORT` | `false` | Enable `SO_REUSEPORT` so multiple processes can share the same port. **Disabled by default** — a second accidental instance will fail with "address already in use". Enable only for deliberate multi-worker setups (process supervisor, rolling restart). Not supported on Windows. |
| `OXICLOUD_METRICS_LISTEN` | (unset) | Prometheus `/metrics` listener address (e.g. `127.0.0.1:9090`, IPv6 allowed as `[::1]:9090`). **Unset = disabled**: no `/metrics` endpoint is bound and no metrics recorder is installed (zero runtime cost). When set, a separate HTTP listener on this address serves the text-format scrape. **Deliberately NOT merged into the main API** — no auth, CSRF, or DPoP layer in front. Bind to loopback or a private interface unless you intend to expose metrics publicly. Starter counters: `oxicloud_dpop_verify_failed_total{reason}`, `oxicloud_dpop_proof_missing_total`, `oxicloud_dpop_header_missing_on_bound_session_total`, `oxicloud_dpop_replay_detected_total`, `oxicloud_dpop_nonce_challenges_issued_total`. |
+| `OXICLOUD_STARTUP_JOBS` | `thumb_derived_import?repair=true,thumb_attached_import?repair=true,transcode_import?repair=true` | Background jobs dispatched once at boot, comma-separated, each `name` or `name?flag=true` using the same syntax as `POST /api/admin/jobs/{name}/trigger`. Flags: `force`, `deep`, `repair`, `storage`. **The default migrates thumbnails out of the legacy `.thumbnails/` directory and deletes the originals**, so the migration completes without anyone triggering it from the admin panel; each sidecar is read back through the normal stack before it is unlinked, and every deletion is audited. An explicit value **replaces** the default; set it empty (`OXICLOUD_STARTUP_JOBS=`) to disable startup jobs, or to `thumb_derived_import,thumb_attached_import` to import without deleting. **Non-blocking** — readiness never waits on a job; entries run sequentially in the background. **Fail-fast** — an unknown job name or flag panics at boot, because a silently-dropped entry means a migration that never runs. A run interrupted by a restart resumes from its cursor on the next boot, so a long migration finishes across restarts. Safe to leave at the default: the jobs are idempotent, and once drained a run does nothing. See [Thumbnail Migration](./thumbnail-migration.md) for the upgrade runbook. |
## Database
@@ -58,7 +59,7 @@ OPAQUE (RFC 9807) is a zero-knowledge password-authenticated key exchange: the p
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_AUTH_OPAQUE_MODE` | `off` | Runtime mode. `off` = endpoints 404 (default). `migrate` = endpoints live, legacy `POST /api/auth/login` still accepted. `opaque_only` = endpoints live, legacy refused for users with an envelope. **Effective-mode cross-check**: when `password` is not in `OXICLOUD_AUTH_METHODS`, the mode is auto-downgraded to `off` with an audit-channel INFO line (OPAQUE only replaces the password path — nothing to shadow in an OIDC-only or magic-link-only deployment). So OIDC / magic-link-only operators can safely ignore every `OXICLOUD_AUTH_OPAQUE_*` variable. |
-| `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` | — | Base64-encoded `ServerSetup` blob. **Required** when `OXICLOUD_AUTH_OPAQUE_MODE != off` AND password is enabled — the server refuses to start with a helpful error otherwise. Generate once with the `oxicloud-cli opaque setup` subcommand and persist the value like your JWT secret. **Never rotate** — rotating invalidates every user's envelope (they'd all need to reset their passphrase). |
+| `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` | — | Base64-encoded `ServerSetup` blob. **Required** when `OXICLOUD_AUTH_OPAQUE_MODE != off` AND password is enabled — the server refuses to start with a helpful error otherwise. Generate once with the `oxicloud opaque setup` subcommand and persist the value like your JWT secret. **Never rotate** — rotating invalidates every user's envelope (they'd all need to reset their passphrase). |
| `OXICLOUD_AUTH_OPAQUE_KSF_MEMORY_KIB` | `47104` | Client-side Argon2id memory cost in KiB (46 MiB — matches OWASP interactive-auth recommendation). Runs on the user's device during OPAQUE login/registration, TWICE per login. Distinct from `OXICLOUD_HASH_MEMORY_COST` (server-side legacy path). Bumping raises brute-force cost after a hypothetical envelope leak but also raises login latency and risks WASM heap OOM on low-memory devices — see `authentication.md § OPAQUE — KSF parameters` for the full rationale + per-device latency table. |
| `OXICLOUD_AUTH_OPAQUE_KSF_ITERATIONS` | `1` | Client-side Argon2id iteration count (OWASP interactive-auth recommendation). |
| `OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM` | `1` | Client-side Argon2id parallelism lanes (OWASP recommendation). Higher only helps on multi-core hardware and can hurt single-core / older mobile devices. |
@@ -94,6 +95,8 @@ DPoP cryptographically binds a session cookie to a browser-held ECDSA keypair (P
| `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin |
| `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search |
| `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata |
+| `OXICLOUD_ENABLE_VIDEO_THUMBNAILS` | `true` | Server-side single-frame thumbnail extraction from uploaded videos (one frame → WebP). Requires `ffmpeg` on `PATH` (override with `OXICLOUD_FFMPEG_PATH`). When true and ffmpeg is missing at boot, a WARN log is emitted and videos fall back to a placeholder icon. Set to `false` to skip the ffmpeg lookup entirely — useful on hosts where ffmpeg can't be installed, or when the client uploads video previews itself (some desktop/mobile clients generate thumbnails locally and POST them alongside the video). |
+| `OXICLOUD_FFMPEG_PATH` | `ffmpeg` (on PATH) | Absolute path to the ffmpeg binary. Ignored when `OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false`. Useful for pinning a specific static build or when ffmpeg lives outside the default PATH. |
| `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` |
| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). |
| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. |
@@ -131,7 +134,7 @@ Each declared name `` then reads its own set of per-entry variables:
- A declared name whose required per-entry fields are missing (`_BACKEND` never set, S3 with no `_S3_BUCKET`, Azure with no `_AZURE_CONTAINER`).
- Setting `OXICLOUD_STORAGE_ENTRIES` alongside any of the legacy flat vars below (`OXICLOUD_STORAGE_BACKEND`, `OXICLOUD_S3_*`, `OXICLOUD_AZURE_*`, `OXICLOUD_STORAGE_ENCRYPTION_*`). Pick one mode; the error lists every conflicting var to remove.
-- A DB pointer (`admin_settings.storage.active_backend_name`) that names an entry not in the current `_ENTRIES`. The error points at the repair flag `oxicloud --select-storage ` — verify + UPDATE DB + exit.
+- A DB pointer (`admin_settings.storage.active_backend_name`) that names an entry not in the current `_ENTRIES`. The error points at the repair flag `oxicloud storage select ` — verify + UPDATE DB + exit.
**Example** — two entries, local disk plus an S3 target for planned migration:
diff --git a/docs/config/index.md b/docs/config/index.md
index 35d424f2..c47a6cf1 100644
--- a/docs/config/index.md
+++ b/docs/config/index.md
@@ -7,6 +7,7 @@ OxiCloud is configured entirely via **environment variables** (no config files n
- [Deployment & Docker](/config/deployment) — Docker Compose, Kubernetes Helm chart, image details
- [Environment Variables](/config/env) — complete reference of all `OXICLOUD_*` variables
- [Storage Fine Tuning](/config/storage-fine-tuning) — sizing the upload caps + spool directories; tmpfs vs real disk; NVMe split layouts
+- [Thumbnail Migration](/config/thumbnail-migration) — upgrading past `.thumbnails/`: what runs on first boot, taking a snapshot first, verifying afterwards
- [Authentication](/config/authentication) — JWT auth, login, refresh, password changes, and auth status
- [OIDC / SSO](/config/oidc) — single sign-on with Keycloak, Authentik, Authelia, Google, Azure AD
- [WOPI (Office Editing)](/config/wopi) — Collabora Online / OnlyOffice integration
diff --git a/docs/config/thumbnail-migration.md b/docs/config/thumbnail-migration.md
new file mode 100644
index 00000000..c9c762a7
--- /dev/null
+++ b/docs/config/thumbnail-migration.md
@@ -0,0 +1,153 @@
+# Thumbnail migration runbook
+
+Thumbnails used to live as files under `{STORAGE_PATH}/.thumbnails/`.
+They now live in the content-addressed blob store, alongside file
+content. This page is for operators upgrading across that change.
+
+**You do not have to do anything.** The migration runs itself, in the
+background, on the first boot after the upgrade. The rest of this page
+is for operators who want to verify it, take a safety net first, or
+understand what it did.
+
+## What runs, and when
+
+Two background jobs, dispatched once at startup and daily thereafter:
+
+| Job | Migrates | Regenerable if lost? |
+|---|---|---|
+| `thumb_derived_import` | Thumbnails the server rendered from file content | Yes — the next request re-renders |
+| `thumb_attached_import` | Previews a client uploaded (`ext-{file_id}.jpg`) | **No** — there is no render path for these |
+
+Both import each sidecar into blob storage, read it back to confirm the
+copy is byte-identical, and only then delete the original. When the
+directory is empty it is removed, and `.thumbnails/` stops existing.
+
+Startup dispatch is non-blocking — the server is ready immediately and
+the migration proceeds behind it. A run interrupted by a restart resumes
+from where it stopped, so a large installation finishes over several
+restarts rather than starting again each time.
+
+This is controlled by `OXICLOUD_STARTUP_JOBS`, which defaults to:
+
+```
+OXICLOUD_STARTUP_JOBS=thumb_derived_import?repair=true,thumb_attached_import?repair=true
+```
+
+To **import without deleting** — migrate now, inspect, delete later:
+
+```
+OXICLOUD_STARTUP_JOBS=thumb_derived_import,thumb_attached_import
+```
+
+The sidecars then stay on disk. Trigger the deletion when you are ready
+from **Admin → Jobs**, using each job's Repair action.
+
+To disable startup jobs entirely, set the variable to an empty value.
+
+## Taking a safety net first
+
+Recommended for any installation where the uploaded previews matter, and
+cheap enough to be worth it regardless. Both parts must be captured
+together — a database that references blobs a storage snapshot predates
+is worse than neither.
+
+**1. Stop the server.** A snapshot taken while writes are in flight can
+catch a blob that exists on disk without its database row, or the
+reverse.
+
+```bash
+systemctl stop oxicloud # or: docker compose stop oxicloud
+```
+
+**2. Snapshot the database.**
+
+```bash
+pg_dump --format=custom --file=oxicloud-preflight.dump "$DATABASE_URL"
+```
+
+Use `--format=custom`; restoring it needs `pg_restore --disable-triggers`,
+because the folder table carries a self-referencing foreign key that a
+plain SQL restore cannot order correctly.
+
+**3. Snapshot the storage directory.** At minimum `.thumbnails/`, which
+is what the migration touches:
+
+```bash
+tar -czf oxicloud-thumbnails-preflight.tar.gz -C "$STORAGE_PATH" .thumbnails
+```
+
+A whole-directory snapshot is better if you have the space — filesystem
+or volume snapshots (ZFS, LVM, EBS) are ideal, since they are atomic and
+near-instant:
+
+```bash
+zfs snapshot tank/oxicloud@preflight
+```
+
+**4. Start the server.** The migration begins in the background.
+
+Keep both snapshots until you have run the verification below and are
+satisfied.
+
+## Verifying the migration
+
+Two checks, both from **Admin → Jobs** or the API. Run them after the
+migration reports no remaining work.
+
+**1. Every mapping points at a blob that exists.** Run
+`satellites_consistency`. It walks both thumbnail tables and reports any
+row whose blob or source is gone. A clean run means nothing was lost in
+the bookkeeping.
+
+```
+POST /api/admin/jobs/satellites_consistency/trigger
+```
+
+**2. Every blob still hashes to what it claims.** Run
+`backend_consistency` with `?deep=true`. It reads every blob back from
+storage and re-hashes it, which covers the migrated thumbnails along
+with everything else. This is a full read of your storage and can take
+hours on a large installation — schedule it accordingly.
+
+```
+POST /api/admin/jobs/backend_consistency/trigger?deep=true
+```
+
+A clean pass on both means the thumbnails are readable, correctly
+referenced, and byte-intact in their new home. At that point the
+snapshots can be discarded.
+
+## Checking it finished
+
+`.thumbnails/` is gone. That is the whole test:
+
+```bash
+ls -d "$STORAGE_PATH/.thumbnails" # No such file or directory
+```
+
+If you instead find `.thumbnails.migrated/`, the migration completed but
+could not remove the directory, because something that is not a
+thumbnail was inside it — a `.DS_Store` from macOS Finder is the usual
+culprit. The tree was moved aside instead of deleted. Its contents are
+no longer used and it is safe to remove by hand once you have looked at
+what is in there.
+
+While either directory is absent, the server skips the legacy read path
+entirely, at no cost. While `.thumbnails/` is present, reads fall back
+to it on a miss, which is what makes the migration invisible to users
+while it runs.
+
+## If something looks wrong
+
+Every deletion is written to the audit log, naming the job, the file
+removed and the blob that replaced it. To review what a migration
+removed:
+
+```bash
+journalctl -u oxicloud | grep sidecar_deleted
+```
+
+A sidecar is only ever deleted after its replacement has been read back
+and compared byte-for-byte, so a file that failed that check is still on
+disk. Those show up as findings on the job's run in **Admin → Jobs**,
+with the reason recorded per file.
diff --git a/docs/guide/backend-storage.md b/docs/guide/backend-storage.md
index 19d04bdd..07bafce2 100644
--- a/docs/guide/backend-storage.md
+++ b/docs/guide/backend-storage.md
@@ -167,13 +167,13 @@ If you rename or remove a backend from `.env` while it was still the active one,
```
active_backend_name = `s3_prod`, but no entry with that name is declared in
OXICLOUD_STORAGE_ENTRIES. Available: [local_main]. […]
-oxicloud --select-storage
+oxicloud storage select
```
Run the command it suggests to pick a still-declared backend and the server will boot again on the next start:
```
-oxicloud --select-storage local_main
+oxicloud storage select local_main
```
This just updates which backend OxiCloud considers active — it doesn't move any data.
diff --git a/docs/install/binary.md b/docs/install/binary.md
new file mode 100644
index 00000000..9d29b139
--- /dev/null
+++ b/docs/install/binary.md
@@ -0,0 +1,245 @@
+# Installing OxiCloud from a Binary Release
+
+OxiCloud ships prebuilt binaries for common Linux and macOS platforms
+attached to every tagged release on GitHub. This page covers downloading,
+verifying, and running one.
+
+If you'd rather run OxiCloud as a container, see the Docker image at
+`ghcr.io/atalayalabs/oxicloud`. If you're a Rust developer who just
+wants the binary without hand-fetching a tarball, `cargo binstall
+oxicloud` picks the right archive for your host automatically.
+
+## Which tarball do I want?
+
+Every release attaches three tarballs plus a `SHA256SUMS` manifest.
+Pick by your host's architecture and OS:
+
+| Host | Tarball |
+|---|---|
+| Linux x86-64 (Intel / AMD servers, most VPS, WSL) | `oxicloud--x86_64-unknown-linux-musl.tar.gz` |
+| Linux ARM64 (Raspberry Pi 4/5, Ampere, Graviton, ARM servers) | `oxicloud--aarch64-unknown-linux-musl.tar.gz` |
+| macOS Apple Silicon (M-series) | `oxicloud--aarch64-apple-darwin.tar.gz` |
+
+The Linux tarballs link against musl, so they run on ANY glibc version
+— Alpine, Debian, Ubuntu, Fedora, Arch, Rocky, and every version in
+between. You never need to worry about `GLIBC_x.yy not found`.
+
+**Intel macOS, Windows, and 32-bit ARM are not currently shipped as
+prebuilt tarballs.** Intel Mac users have three fallbacks:
+
+1. `cargo install oxicloud --locked --features bundled-assets` from
+ source (needs the Rust toolchain).
+2. Docker: `docker pull --platform linux/amd64 ghcr.io/atalayalabs/oxicloud`.
+3. Run one of the two Linux musl tarballs inside a Linux VM
+ (Multipass, Lima, UTM, etc.).
+
+## Hardware notes
+
+| Model | Notes |
+|---|---|
+| Pi 5 (4 GB / 8 GB) | Good experience |
+| Pi 4 (4 GB / 8 GB) | Solid |
+| Pi 4 (2 GB) | Works with face indexing disabled; expect swap under load |
+| Pi 3 (any variant) | Marginal — only for a very light single-user personal cloud |
+| Pi 2 / Pi Zero / Pi 1 | Not supported (1 GB RAM is below the practical floor) |
+| Any ARM64 server | Good — the aarch64 tarball is what you want |
+| Any x86-64 server from 2010 or newer | Good — Nehalem / Bulldozer + newer, per the release CPU baseline |
+
+## Verifying the download
+
+Every release ships a `SHA256SUMS` manifest listing every tarball with
+its hash. Verify your download before extracting:
+
+```
+sha256sum -c SHA256SUMS
+```
+
+Only files present in the current directory are checked, so this
+succeeds when just the tarball you downloaded matches its entry.
+
+## Extracting
+
+The archive lands as a per-version-per-triple directory next to it:
+
+```
+tar xzf oxicloud--.tar.gz
+cd oxicloud--/
+ls
+# oxicloud example.env LICENSE README-install.md
+```
+
+The four files:
+
+- `oxicloud` — the single self-contained binary. The server, all
+ operator subcommands (`oxicloud opaque setup`, `oxicloud migrate
+ nfc-filenames`, `oxicloud storage select`), and the SvelteKit web
+ frontend are all baked in.
+- `example.env` — every OxiCloud environment variable documented with
+ defaults. Copy to `.env` and edit as needed.
+- `LICENSE` — the project license.
+- `README-install.md` — a shorter version of this page for offline
+ reference.
+
+## Prerequisites
+
+Only one moving part is required: a PostgreSQL 13+ instance with the
+`pg_trgm` and `ltree` extensions available. Anything else you might
+need is either baked into the binary or optional.
+
+### Required
+
+- **PostgreSQL 13+** with `pg_trgm` and `ltree` extensions. Any distro
+ package works (Debian/Ubuntu's `postgresql`, Alpine's `postgresql`,
+ Homebrew's `postgresql@17`, etc.). Cloud databases like Neon,
+ Supabase, and RDS also work provided the two extensions are enabled.
+
+### System libraries (usually pre-installed)
+
+- **`ca-certificates`** — for outbound HTTPS (OIDC discovery, S3, magic
+ links). Pre-installed on essentially every distribution.
+- **`tzdata`** — timezone database. Pre-installed on nearly every
+ distribution; alpine minimal images sometimes need it added.
+
+### Optional
+
+- **`ffmpeg`** — only needed if you want the server to extract a
+ thumbnail frame from uploaded videos. When ffmpeg is missing the
+ server logs a warning at boot and videos get a placeholder icon —
+ everything else keeps working. If your client uploads video
+ previews itself (some desktop and mobile clients do), or if you
+ simply don't want thumbnails, set
+ `OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` in your `.env` to silence
+ the warning.
+
+Distro install commands for the optional prerequisite:
+
+| Distro | Command |
+|---|---|
+| Alpine | `apk add ffmpeg` |
+| Debian / Ubuntu | `apt install ffmpeg` |
+| Fedora / RHEL | `dnf install ffmpeg` (RPM Fusion for the full codec set) |
+| Arch | `pacman -S ffmpeg` |
+| macOS | `brew install ffmpeg` |
+| Any Linux (portable) | grab a static build from https://github.com/BtbN/FFmpeg-Builds/releases and point `OXICLOUD_FFMPEG_PATH` at it |
+
+## First run
+
+The absolute minimum to boot the server is `DATABASE_URL`:
+
+```
+DATABASE_URL="postgres://oxicloud:secret@localhost:5432/oxicloud" \
+ ./oxicloud
+```
+
+The binary applies its embedded database migrations on startup, then
+listens on `127.0.0.1:8086` by default. Open your browser at
+`http://localhost:8086/` and follow the setup flow to create the first
+admin account.
+
+For anything more than a smoke test, copy `example.env` to `.env`,
+edit it, and run `./oxicloud --config .env` — that pins the config
+source and makes stray shell environment variables not silently leak
+in.
+
+## Running as a systemd service (Linux)
+
+Move the binary to a system location and create a systemd unit. The
+example below runs as a dedicated `oxicloud` user, loads config from
+`/etc/oxicloud/oxicloud.env`, and stores data under `/var/lib/oxicloud`.
+
+```
+sudo useradd --system --home /var/lib/oxicloud --create-home --shell /usr/sbin/nologin oxicloud
+sudo install -m 0755 oxicloud /usr/local/bin/oxicloud
+sudo mkdir -p /etc/oxicloud
+sudo cp example.env /etc/oxicloud/oxicloud.env
+sudo chown -R oxicloud:oxicloud /etc/oxicloud
+sudo chmod 0640 /etc/oxicloud/oxicloud.env
+```
+
+Create `/etc/systemd/system/oxicloud.service`:
+
+```
+[Unit]
+Description=OxiCloud self-hosted cloud storage
+After=network-online.target postgresql.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=oxicloud
+Group=oxicloud
+WorkingDirectory=/var/lib/oxicloud
+ExecStart=/usr/local/bin/oxicloud --config /etc/oxicloud/oxicloud.env
+Restart=on-failure
+RestartSec=5
+
+# Sandbox — plenty of room to tighten further per your policy
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/oxicloud
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Enable and start:
+
+```
+sudo systemctl daemon-reload
+sudo systemctl enable --now oxicloud
+sudo systemctl status oxicloud
+journalctl -u oxicloud -f
+```
+
+Terminate the reverse-proxy (nginx, Caddy, HAProxy, Traefik) in front
+of it for TLS and public exposure — OxiCloud itself binds plaintext
+HTTP on `127.0.0.1` by default.
+
+## Upgrading
+
+Replace the binary and restart the service:
+
+```
+# Download and verify the new tarball
+sha256sum -c SHA256SUMS
+tar xzf oxicloud--.tar.gz
+cd oxicloud--/
+
+sudo systemctl stop oxicloud
+sudo install -m 0755 oxicloud /usr/local/bin/oxicloud
+sudo systemctl start oxicloud
+```
+
+Database migrations apply automatically on startup. Rollbacks are not
+supported by sqlx's migration model; if you need to roll back, stop
+the server, roll back your Postgres data directory to a snapshot, and
+install the previous binary.
+
+## Installing via `cargo binstall`
+
+If you already have the Rust toolchain and just want the binary
+without hand-picking a tarball:
+
+```
+cargo binstall oxicloud
+```
+
+`cargo-binstall` reads the URL template baked into the release
+metadata, downloads the tarball for your host triple, verifies its
+signature (when present), and installs `oxicloud` into
+`~/.cargo/bin`. This resolves to the same tarball you'd download by
+hand.
+
+## Where to go from here
+
+- Environment reference — see [`docs/config/env.md`](../config/env.md)
+ for every `OXICLOUD_*` variable and its default.
+- Authentication setup (OPAQUE, OIDC, magic links) — see
+ [`docs/config/authentication.md`](../config/authentication.md).
+- Storage backends (local disk, S3, Azure Blob, encryption) — see
+ [`docs/config/storage.md`](../config/storage.md) if present, or the
+ entries under `OXICLOUD_STORAGE_*` in the environment reference.
+- File a bug or a feature request — GitHub issues at
+ https://github.com/AtalayaLabs/OxiCloud.
diff --git a/docs/plan/benchmake-and-performance-tracking.md b/docs/plan/benchmake-and-performance-tracking.md
index 46f40f17..d4bcdc39 100644
--- a/docs/plan/benchmake-and-performance-tracking.md
+++ b/docs/plan/benchmake-and-performance-tracking.md
@@ -41,7 +41,9 @@ Mirrors the `tests/api/` shell pattern (run.sh, test.env, separate server port).
### 2. Rust bulk seeder — `src/bin/load-seed.rs`
-New binary registered in `Cargo.toml` alongside `generate-openapi` and `migrate-nfc-filenames`.
+New binary registered in `Cargo.toml` alongside `generate-openapi`
+(the historical `migrate-nfc-filenames` bin has since been folded into
+`oxicloud migrate nfc-filenames` — see `docs/plan/bundled-binary.md` § 1b).
**CLI:**
```
@@ -158,7 +160,7 @@ Matches existing recipe naming (`test-*`, `front-*`, `api-test`).
- `.github/workflows/load-nightly.yml`, `load-smoke.yml`
**Modify:**
-- `Cargo.toml` — add `[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"` after the `migrate-nfc-filenames` entry
+- `Cargo.toml` — add `[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"` after the `generate-openapi` entry (the `migrate-nfc-filenames` bin referenced in earlier drafts has been folded into `oxicloud migrate nfc-filenames`)
- `justfile` — append four `load*` recipes
- `.gitignore` — add `tests/load/results/*.json` and `tests/load/storage/`
diff --git a/docs/plan/bundled-binary.md b/docs/plan/bundled-binary.md
new file mode 100644
index 00000000..d72f790a
--- /dev/null
+++ b/docs/plan/bundled-binary.md
@@ -0,0 +1,888 @@
+# Bundled Binary Distribution — Multi-Platform Plan
+
+## Context
+
+Users have asked for a way to run OxiCloud without Docker — a plain
+binary. Today `release.yml` only creates a GitHub Release with notes;
+no binary is attached. The Docker workflow (`docker-publish.yml`) ships
+multi-arch images, but that's a separate audience.
+
+The blocker for a "just download and run" experience is that the
+`oxicloud` binary depends on the SvelteKit build output (`static-dist/`
+under `/static-dist/`, resolved by
+`src/interfaces/web/mod.rs::resolve_static_path` at boot). Two files
+to distribute per platform is friction; a single self-contained binary
+is what users actually want.
+
+The ask has two parts:
+
+1. Ship **single-file binaries with frontend assets embedded**, for
+ the common Linux targets and macOS.
+2. Audit the current binary set — the crate produces 6+ binaries today,
+ some of which are test-only. Strip anything that shouldn't ship to
+ end users.
+
+The intended outcome: a `v0.9.0` release attaches **4 musl-static
+tarballs** (Linux amd64/arm64 + macOS Intel/Apple Silicon), each
+~15-30 MB, containing a **single `oxicloud` binary** with assets +
+operator tools + one-off migrations all baked in. User extracts the
+tarball, sets `DATABASE_URL`, runs `./oxicloud` — server up.
+Subcommands (`oxicloud opaque setup`, `oxicloud migrate
+nfc-filenames --dry-run`) provide operator access to the same tools
+currently split across `oxicloud-cli` and `migrate-nfc-filenames`.
+
+Design shape (confirmed 2026-08-27):
+
+- **musl-only Linux** — parity with the existing Docker image (Alpine
+ base), no glibc-version fragmentation
+- **Assets embedded via `rust-embed` with compile-time deflate
+ compression** — smaller binary
+- **`bundled-assets` is opt-in** — default `cargo build` unchanged;
+ `just dev` still uses the filesystem `ServeDir` with Vite HMR
+- **Single unified binary** — `oxicloud`, `oxicloud-cli`, and
+ `migrate-nfc-filenames` collapse into one clap-driven executable
+ with implicit-server default (backwards compat with existing Docker
+ CMD / systemd units)
+
+## Current binary inventory
+
+From `Cargo.toml` + `src/bin/`:
+
+| Binary | Path | Purpose | Ship to end users? |
+|---|---|---|---|
+| `oxicloud` | `src/main.rs` (implicit) | Server | **YES** |
+| `oxicloud-cli` | `src/bin/oxicloud-cli.rs` | Operator toolbox (`opaque setup/reset`) | **MERGED** — absorbed into `oxicloud` per Deliverable 1b |
+| `migrate-nfc-filenames` | `src/bin/migrate-nfc-filenames.rs` | One-off filename migration (historical, June 2026 fix) | **MERGED** — absorbed into `oxicloud migrate nfc-filenames` per Deliverable 1a→1b |
+| `generate-openapi` | `src/bin/generate-openapi.rs` | Regenerate `resources/gen/openapi.json` | NO — dev tool, gate behind `dev_tools` feature |
+| `opaque-hurl-helper` | `src/bin/opaque-hurl-helper.rs` | Hurl test companion (OPRF client) | NO — gate behind `test_utils` feature |
+| `dpop-hurl-helper` | `src/bin/dpop-hurl-helper.rs` | Hurl test companion (ES256 DPoP proof) | NO — gate behind `test_utils` feature |
+| `load-seed` | `src/bin/load-seed.rs` | Test fixture seeder | Already gated behind `load_seed_bin` feature ✅ |
+
+After Deliverables 1 + 1a + 1b, `cargo build --release --bins`
+produces exactly ONE binary: `oxicloud`. That single binary ships in
+the tarball and in the Docker image.
+
+## Deliverables
+
+### 1. Squash test/dev binaries with `required-features`
+
+Cargo respects `required-features` per `[[bin]]` — a binary is only
+built when its listed features are active. This gates test helpers
+out of `cargo build --release --bins` cleanly without needing custom
+Cargo commands or shell trimming.
+
+Edits to `Cargo.toml`:
+
+```toml
+[features]
+# ... existing features ...
+dev_tools = [] # NEW: gates ops tooling that shouldn't ship
+
+[[bin]]
+name = "opaque-hurl-helper"
+path = "src/bin/opaque-hurl-helper.rs"
+required-features = ["test_utils"] # NEW gate
+
+[[bin]]
+name = "dpop-hurl-helper"
+path = "src/bin/dpop-hurl-helper.rs"
+required-features = ["test_utils"] # NEW gate
+
+[[bin]]
+name = "generate-openapi"
+path = "src/bin/generate-openapi.rs"
+required-features = ["dev_tools"] # NEW gate — `just openapi` flips it
+
+# [[bin]] name = "migrate-nfc-filenames" ← DELETED per Deliverable 1a
+# [[bin]] name = "oxicloud-cli" ← DELETED per Deliverable 1b
+```
+
+Existing invocations that need adjustment:
+
+- `just openapi` recipe → add `--features dev_tools` to the underlying
+ `cargo run --bin generate-openapi` call (currently `cargo run --bin
+ generate-openapi` per justfile)
+- `tests/api/run.sh` → add `--features test_utils` when building the
+ two hurl helpers (shape confirmed: `cargo build [--release] --bin
+ opaque-hurl-helper` / same for dpop in each helper's build-if-missing
+ branch)
+
+After these edits + Deliverables 1a + 1b: `cargo build --release --bins`
+produces exactly ONE binary — `oxicloud`. Everything else falls out of
+the default build set.
+
+### 1a. Merge `migrate-nfc-filenames` into `oxicloud-cli`
+
+The standalone `migrate-nfc-filenames` binary is a June-2026 one-off:
+it cleans up NFD/NFC filename collisions in databases populated
+before the write-time fix (`normalize_storage_name()` at
+`src/domain/services/path_service.rs:36`, called from
+`src/infrastructure/repositories/pg/file_blob_read_repository.rs:1062`).
+New installs never need it; only pre-June 2026 databases do.
+
+`oxicloud-cli`'s header docstring (`src/bin/oxicloud-cli.rs:20-23`)
+already documents the growth pattern for absorbing tools like this:
+
+> *"each new domain gets its own module below (e.g. `mod opaque`)
+> with a `#[derive(Subcommand)]` enum for its actions and a
+> `run(args) -> ExitCode` entrypoint. Keep each module self-contained
+> so a future extraction is a file move."*
+
+Note: this Deliverable is an intermediate step. Deliverable 1b then
+absorbs `oxicloud-cli` itself into `oxicloud`, so the final CLI form
+becomes `oxicloud migrate nfc-filenames --dry-run` — but 1a lands
+first so the migration logic is proven inside the clap subcommand
+tree before the main-binary merge.
+
+Edits:
+
+- **New `mod migrate` in `src/bin/oxicloud-cli.rs`** — moves the ~149
+ non-boilerplate lines from `migrate-nfc-filenames.rs::main()` into
+ a `run_nfc_filenames(dry_run: bool) -> ExitCode` function.
+ `env::args()` parsing goes away; clap handles it.
+- **Delete `src/bin/migrate-nfc-filenames.rs`**.
+- **Delete the `[[bin]]` entry** in `Cargo.toml`.
+- **Update `Dockerfile`** — 6 references to `migrate-nfc-filenames`
+ (build commands at :46, :49, :89, `cp` steps at :130, :143, doc
+ comment at :170, `COPY --chmod=755 --from=app` at :173).
+- **Update `docs/plan/benchmake-and-performance-tracking.md`** — 2
+ references to `migrate-nfc-filenames` at lines :44 and :161. Reword
+ to reference `oxicloud-cli migrate nfc-filenames` (or, after 1b,
+ `oxicloud migrate nfc-filenames`) and update the Cargo.toml
+ placement example.
+- **Any operator runbook** that documents `docker exec
+ migrate-nfc-filenames --dry-run` becomes `docker exec
+ oxicloud-cli migrate nfc-filenames --dry-run` (intermediate) then
+ `docker exec oxicloud migrate nfc-filenames --dry-run`
+ after 1b.
+
+Effort: ~1.5 hours mechanical. Extracts the "should the tarball ship
+migrate-nfc-filenames?" question entirely — everything now ships as
+one operator toolbox binary that also happens to include the
+historical migration.
+
+Future v1.0 removal path (deferred): delete `mod migrate` block + one
+enum variant + docs. Much cleaner than removing a whole `.rs` file +
+Cargo entry + Dockerfile refs.
+
+### 1b. Merge `oxicloud-cli` into `oxicloud`
+
+Single binary — server + operator tools + migrations — with an
+**implicit-server** subcommand tree. `oxicloud` with no arguments
+starts the server (backwards compat with existing Docker CMD /
+systemd units / user configs). Subcommands add operator actions on
+top.
+
+After merge, the CLI shape is:
+
+```
+$ oxicloud --help
+Usage: oxicloud [OPTIONS] [COMMAND]
+
+Commands:
+ opaque OPAQUE aPAKE substrate management
+ migrate One-time data migrations
+
+If no command is given, oxicloud starts the server (see docs/config).
+```
+
+Concrete forms:
+
+- `oxicloud` — start server (unchanged)
+- `oxicloud opaque setup` — was `oxicloud-cli opaque setup`
+- `oxicloud opaque reset --user alice --dry-run` — was `oxicloud-cli
+ opaque reset ...`
+- `oxicloud migrate nfc-filenames --dry-run` — was
+ `migrate-nfc-filenames --dry-run` (via Deliverable 1a)
+
+**Backwards-compat guarantee**: `oxicloud` with no args continues to
+start the server. Every existing `CMD ["oxicloud"]`, `ExecStart=/usr/local/bin/oxicloud`,
+docker-compose entry, and k8s Deployment keeps working unchanged.
+Users updating to v0.9.0 see no surprise.
+
+**Migration impact**: the user-visible break is that `oxicloud-cli
+opaque setup` (etc.) no longer exists as a separate binary. Given the
+current audience for `oxicloud-cli` is very small (essentially only
+the maintainer), the migration cost is trivial. Any user who had
+scripted it can adapt with a one-line find/replace.
+
+Edits:
+
+- **`src/main.rs`** — top of `main()`, before the current server
+ init, parse args via clap. If a subcommand is provided, dispatch
+ to it and exit; otherwise fall through to the existing server-init
+ path. Zero-arg startup cost stays ≤ microseconds (clap parse of
+ empty args).
+- **`src/cli/mod.rs`** — NEW module. Contains the `Domain` enum + the
+ `opaque` and `migrate` submodules moved from
+ `src/bin/oxicloud-cli.rs`. Each subcommand module keeps its
+ self-contained shape per the growth pattern documented in the
+ old `oxicloud-cli.rs` header.
+- **Delete `src/bin/oxicloud-cli.rs`** entirely.
+- **Delete the `[[bin]] name = "oxicloud-cli"` block** in `Cargo.toml`.
+- **`Dockerfile`** — drop all 4 references to `oxicloud-cli` (build
+ target lines + COPY steps). Simplified build command becomes
+ `cargo build --release --bin oxicloud` — single-binary.
+- **Docs** — all `docker exec oxicloud-cli
+ ` become `docker exec oxicloud
+ `. Same shape, one fewer word.
+
+Effort: ~2 hours mechanical. Comparable to Deliverable 1a but with
+slightly more care at the `main.rs` entry point for the args-vs-server
+branch.
+
+**Tarball layout simplification** — the tarball now ships exactly
+ONE binary:
+
+```
+oxicloud-0.9.0-/
+├── oxicloud (single file, server + tools + embedded assets)
+├── example.env
+├── LICENSE
+└── README-install.md
+```
+
+That's the "just download and run" ethos in physical form: one file,
+one command, done.
+
+### 2. Add `bundled-assets` cargo feature
+
+Purpose: at compile time, choose between filesystem-served static
+assets (current behaviour — filesystem `ServeDir`) and
+embedded-into-binary assets (via `rust-embed`). Feature is
+**opt-in** — the default `cargo build --release` still produces a
+filesystem-based binary, matching the current Docker image behaviour
+(where assets are separate volume layers). Release tarballs are built
+with `--features bundled-assets`.
+
+**Dev mode is untouched.** `just dev` runs `PROFILE=dev cargo run` +
+`npm run dev`, neither of which activates `bundled-assets`. The dev
+workflow continues to:
+
+- Serve from `frontend/` via Vite's dev server with HMR
+- Backend reads static assets from `/static-dist/` via the
+ usual `ServeDir` (or falls back to `frontend/static/` when the
+ build hasn't been run)
+- No rebuild required to change locales, styles, or vendor JS
+
+The `bundled-assets` code paths only compile when the feature is
+explicitly enabled — under a `#[cfg(feature = "bundled-assets")]` gate.
+The non-feature build's binary shape, ergonomics, and dev loop stay
+identical to today.
+
+Measured footprint (2026-08-27):
+
+| Slice | Size | Notes |
+|---|---|---|
+| Total `static-dist/` uncompressed | **9.8 MB** | 499 files |
+| `_app/` (SvelteKit bundle) | 3.3 MB | JS + CSS chunks |
+| `vendors/` | 3.6 MB | maplibre-gl 1.0 MB, pdf.worker 1.0 MB, others |
+| `locales/` | 2.2 MB | 16 locales, ru.json + hi.json largest at ~116-140 KB |
+| `logo/`, `geo/`, `basemaps/`, `workers/`, misc | ~600 KB | |
+| **`.tar.gz` compressed** | **4.65 MB** | realistic embed cost after brotli/gzip inside binary |
+| **`.tar.xz` compressed** | **4.22 MB** | not what rust-embed uses; reference only |
+
+Expected release-binary size with embed: `oxicloud` today ships in
+the 30-60 MB range (stripped, LTO). Add ~5-10 MB for embedded
+static-dist. Tarball compression on top → ~20-30 MB shipped per
+platform. Four platforms × ~25 MB = ~100 MB per release. Well within
+GitHub Releases limits.
+
+Cargo.toml additions:
+
+```toml
+[features]
+bundled-assets = ["dep:rust-embed", "dep:mime_guess"]
+
+[dependencies]
+rust-embed = { version = "8", features = ["compression"], optional = true }
+mime_guess = { version = "2", optional = true }
+```
+
+Runtime shape — a new module `src/interfaces/web/embedded.rs`:
+
+```rust
+#[cfg(feature = "bundled-assets")]
+#[derive(rust_embed::RustEmbed)]
+#[folder = "static-dist/"] // ← repo-root, matches SvelteKit adapter-static output
+#[include = "*"]
+#[exclude = "*.br"] // Vite's precompressed sibling — response compression handles on wire
+#[exclude = "*.gz"] // ditto
+pub struct EmbeddedAssets;
+```
+
+The `#[folder]` path is relative to Cargo.toml (repo root), where the
+SvelteKit adapter-static config in `frontend/svelte.config.js` emits:
+
+```js
+adapter: adapter({
+ pages: '../static-dist',
+ assets: '../static-dist',
+ ...
+})
+```
+
+The current filesystem shape (at `src/interfaces/web/mod.rs:47-106`)
+is more than one `ServeDir` — the embed swap replaces FOUR sites, all
+downstream of `resolve_static_path()`:
+
+1. **`spa` ServeDir** (`mod.rs:60-63`) — root fallback with
+ `precompressed_br().precompressed_gzip()` and SPA-shell fallback
+ pointing at `/index.html`. Under embed: an axum handler
+ that resolves the request path against `EmbeddedAssets::get()`,
+ 200 with correct MIME (via `mime_guess`) if hit, otherwise return
+ the embedded `index.html` bytes with `text/html` for SPA client-routing.
+2. **`app_immutable` ServeDir** (`mod.rs:66-77`) — nested at
+ `/_app/immutable` with `Cache-Control: public, max-age=31536000,
+ immutable`. Under embed: same handler shape as (1), scoped to
+ the `_app/immutable/` prefix, plus a `.layer()` that stamps the
+ immutable cache header.
+3. **`ServeFile::new(index.html)`** SPA fallback (`mod.rs:63`) —
+ folds into (1)'s not-found path.
+4. **CSP inline-script scan** (`mod.rs:163-233`) — currently reads
+ every `.html` file in the resolved static dir via
+ `std::fs::read_dir` + `std::fs::read_to_string` at boot to compute
+ SHA-256 CSP source expressions for every inline `