diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02a41b6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Docker build-context excludes (shared by crates/ldgr-server/Dockerfile and +# apps/web/Dockerfile, both of which build from the repository root). Only build +# artifacts, dependencies, and VCS metadata are excluded — never source needed by +# either image build. +**/target +**/node_modules +**/*.tsbuildinfo +apps/web/out +apps/web/.next +apps/web/pkg +apps/web/public/sql.js + +.git +.github +.DS_Store diff --git a/.env.example b/.env.example index b712b24..d5b080b 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ LDGR_VERSION=latest # Host port to expose the server on (container always listens on 8080). LDGR_HOST_PORT=8080 +# Host port for the web app + admin panel (served by Caddy). Browse to +# http://:/admin. The container always listens on 8080. +LDGR_WEB_PORT=8081 + # ── Server settings (LDGR_*) ───────────────────────────────────────────────── # Address the server binds inside the container. Must be 0.0.0.0 so the port is @@ -54,6 +58,14 @@ LDGR_DEFAULT_QUOTA_BYTES=1073741824 # auth). Handy to label your instance. LDGR_SERVER_NAME=ldgr-server +# Cross-origin allowlist for the browser admin panel / web client +# (comma-separated origins). Empty by default = deny all cross-origin requests +# (the secure default for a zero-knowledge server). Set this ONLY when the web +# app is served from a DIFFERENT origin than this API (a separate admin host, or +# local development). A same-origin deployment behind one reverse proxy needs +# nothing here. +#LDGR_ALLOWED_ORIGINS=https://admin.example.com,https://ldgr.example.com + # ── Optional TLS reverse proxy (Caddy `tls` profile) ───────────────────────── # Only needed if you run `docker compose --profile tls up -d`. Point your # domain's DNS at this host first; Caddy will obtain a certificate automatically. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1af2067..f39018f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -269,6 +269,16 @@ jobs: - name: Run tests working-directory: apps/web run: npm test + - name: Add wasm target + run: rustup target add wasm32-unknown-unknown + - name: Install wasm-pack + run: curl -sSf https://rustwasm.github.io/wasm-pack/installer/init.sh | sh + - name: Build WASM bundle + working-directory: apps/web + run: npm run build:wasm + - name: Build static export + working-directory: apps/web + run: npm run build # ── Infra (Cloudflare Worker) ───────────────────────────────────────────────── diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b05782c..d10136a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -66,3 +66,50 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + # Web app + admin panel image (Apache-2.0 static export served by Caddy). + # Same tag/dispatch triggers as `docker` above — NOT a pull-request merge gate, + # so it needs no entry in kafkade/github-infra required_status_checks. + docker-web: + name: Build & push web (multi-arch) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/ldgr-web + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: apps/web/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c21005..7ca4a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ldgr watch` and `ldgr portfolio` now fetch market data through the shared caching proxy (`api.ldgr.dev/market/`) first, falling back to direct provider requests when it is unavailable, on top of the existing local price cache (local cache → proxy → direct). Set `LDGR_MARKET_PROXY` to point at your own proxy deployment, or use `LDGR_MARKET_PROXY=none` / the `--no-proxy` flag to bypass the proxy and fetch providers directly. Only symbol names are ever sent to the proxy — never vault, balance, or portfolio data - PDF report export: `ldgr export --format pdf --report --output ` renders a paginated, styled PDF of the chosen report — title, period (derived from `date:`/`begin:`/`end:` query filters), an indented account hierarchy, and right-aligned totals — with multi-currency amounts grouped per commodity and decimal values rendered exactly. The `export` command also gains an `--output` flag to write CSV/JSON/hledger output to a file instead of stdout - Advanced financial goal projections via a new `ldgr goals` command: `goals list` shows progress for each goal (pulling the current amount from a linked account when available), `goals project ` returns an optimistic/expected/pessimistic projection band with an optional inflation-adjusted (nominal vs real) target, `goals plan ` projects a variable contribution schedule (step-ups and one-off lump sums), and `goals allocate --budget ` divides a shared monthly budget across competing goals by priority, deadline, proportional, or equal strategy with per-goal shortfall reporting. Goals are defined in `~/.ldgr/goals.json` and every subcommand supports `--output table|json`. All projection math is deterministic and money is computed without floating point +- Batteries-included web admin panel in the self-hosting bundle: `docker compose up -d` now serves the `/admin` panel out of the box. The existing Caddy container serves a static build of the Apache-2.0 web app and reverse-proxies the API to the server, so the panel is reachable at `http://:8081/admin` with its **Server URL** pre-filled to the same origin — no separate hosting step. The AGPL `ldgr-server` binary still serves no web assets (Caddy only), the optional Caddy `tls` profile fronts both the panel and API on a single HTTPS origin, and the panel's published port is configurable with `LDGR_WEB_PORT` +- `LDGR_ALLOWED_ORIGINS` on `ldgr-server`: a comma-separated allowlist of browser origins permitted to call the admin/sync API cross-origin (methods `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`OPTIONS`, `authorization`/`content-type` headers, credentials disabled). It is empty by default, so cross-origin browser requests are denied unless you explicitly opt in — needed only when serving the web admin panel from a different origin than the server ### Fixed @@ -104,11 +106,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Web vault writes (add account, add/delete transaction) now record sync-outbox events, so locally created changes are actually included in the next push instead of being silently skipped (the `sync_events`/`sync_state` tables were previously dead scaffolding) - Resolving a sync conflict as "keep remote" now actually re-applies the remote change (via the Swift/iOS bindings it previously only marked the conflict resolved without materializing the remote version) - Sync against a self-hosted `ldgr-server` now lists and pulls every encrypted batch and snapshot in large vaults: blob listings were previously capped at a single server page (~1000 entries), so a vault with more than one page of batches or snapshots would silently drop — and never pull — the overflow. The client now follows the server's continuation cursor across all pages +- The `apps/web` admin panel static export builds again: the build now targets the webpack compiler the project's WASM config requires, and Tailwind CSS is aligned to the v3 toolchain its config is written against, so `npm run build` produces the static panel instead of failing ### Security - The local working store (`vault.db`) is now encrypted at rest with SQLCipher (AES-256): the database is keyed by a subkey derived from your vault key via HKDF, so account names, transaction descriptions, amounts, and commodities are no longer readable from disk without your master password. This applies to both the CLI and the shared Rust core used by the iOS/iPadOS app, so anyone with filesystem access to a locked vault — including another local user or a stolen locked laptop — sees only ciphertext. Existing plaintext CLI vaults are upgraded with `ldgr migrate` - The unlocked session key is no longer written to a plaintext `session.json`; it is stored in the operating system keystore (macOS Keychain, Linux kernel keyring, or Windows Credential Manager) while `session.json` holds only non-secret metadata. `ldgr lock` now removes the key from the keystore and clears the session, so locking renders the working store unreadable rather than merely dropping a marker +- First-admin bootstrap is now atomic on servers with no `LDGR_ADMIN_EMAIL` configured: the first registered user is elected admin inside a single guarded database transaction, so under concurrent sign-ups at most one admin can ever be created. The previous fallback counted existing users and inserted the new user in separate steps, so two simultaneous registrations could both read zero users and both become admin (and on an unconfigured internet-exposed instance, whoever registered first silently became admin). The env-seeded `LDGR_ADMIN_EMAIL` path is unchanged ## [1.2.0] - 2026-05-30 diff --git a/Caddyfile b/Caddyfile index 4de51b8..2884e1e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,10 +1,10 @@ -# ldgr-server — automatic-HTTPS reverse proxy (optional `tls` compose profile). +# ldgr — automatic-HTTPS reverse proxy (optional `tls` compose profile). # # Enable with: docker compose --profile tls up -d # Requires LDGR_DOMAIN (and optionally LDGR_ACME_EMAIL) in .env, plus DNS for # that domain pointed at this host. Caddy obtains and renews a certificate for -# you and proxies HTTPS traffic to the ldgr-server container on port 8080. - +# you and fronts the `web` service, which serves the panel and (via its own +# reverse proxy) the JSON API — so a single HTTPS origin serves everything. { # Uncomment to receive ACME/expiry notices (or set LDGR_ACME_EMAIL in .env). # email {$LDGR_ACME_EMAIL} @@ -12,5 +12,5 @@ {$LDGR_DOMAIN} { encode zstd gzip - reverse_proxy server:8080 + reverse_proxy web:8080 } diff --git a/apps/web/Caddyfile b/apps/web/Caddyfile new file mode 100644 index 0000000..054f64f --- /dev/null +++ b/apps/web/Caddyfile @@ -0,0 +1,28 @@ +# ldgr web — Caddy config baked into the `ldgr-web` image (apps/web/Dockerfile). +# +# Caddy serves the Apache-2.0 web app + admin panel as a static Next.js export +# and reverse-proxies the JSON API to the ldgr-server container, so the panel and +# the API share a single origin and the panel is reachable out of the box +# (`docker compose up`). The AGPL ldgr-server never serves these web assets +# (ADR-006 licensing split; ADR-008 §7). + +:8080 { + encode zstd gzip + + # The server's JSON API and health probe are proxied to the server + # container. `LDGR_API_UPSTREAM` lets non-compose deploys point elsewhere. + @api path /api/* /health + handle @api { + reverse_proxy {$LDGR_API_UPSTREAM:server:8080} + } + + # Everything else is the static export (index, /admin, /vault, assets). + # The export emits `route.html` files (e.g. admin.html) alongside same-named + # directories for nested routes, so try the `.html` form before the bare path + # to avoid matching an index-less directory. + handle { + root * /srv/www + try_files {path}.html {path} {path}/index.html /index.html + file_server + } +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..afce188 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 +# +# ldgr-web — static admin panel + web client, served by Caddy. +# +# The Apache-2.0 web app is a Next.js static export bundled with the in-browser +# WASM client. This image builds the export and serves it from Caddy, reverse- +# proxying the JSON API to the ldgr-server container so the panel is reachable +# out of the box (`docker compose up`). The AGPL ldgr-server never serves these +# assets (ADR-006 licensing split; ADR-008 §7). +# +# Build context is the repository root (the WASM crate lives outside apps/web): +# docker build -f apps/web/Dockerfile -t ldgr-web . + +# ── WASM builder ──────────────────────────────────────────────────────────────── +# Pinned to the same Rust as crates/ldgr-server/Dockerfile (workspace MSRV 1.95). +FROM rust:1.95-bookworm AS wasm +RUN rustup target add wasm32-unknown-unknown \ + && curl -sSf https://rustwasm.github.io/wasm-pack/installer/init.sh | sh +WORKDIR /src +COPY . . +# Mirrors apps/web `npm run build:wasm` (core + sync features only). +RUN wasm-pack build crates/ldgr-wasm --target web --release \ + --out-dir /src/apps/web/pkg -- --no-default-features --features core,sync + +# ── Web builder ───────────────────────────────────────────────────────────────── +FROM node:22-bookworm AS web +WORKDIR /src/apps/web +# Install deps first for layer caching. +COPY apps/web/package.json apps/web/package-lock.json ./ +RUN npm ci +# App sources + the WASM package produced above. +COPY apps/web/ ./ +COPY --from=wasm /src/apps/web/pkg ./pkg +# The sync engine loads sql.js at runtime; ship its wasm alongside the export. +RUN mkdir -p public/sql.js \ + && cp node_modules/sql.js/dist/sql-wasm.wasm public/sql.js/ \ + && npm run build + +# ── Runtime (Caddy static server) ─────────────────────────────────────────────── +FROM caddy:2 +COPY apps/web/Caddyfile /etc/caddy/Caddyfile +COPY --from=web /src/apps/web/out /srv/www + +EXPOSE 8080 + +# Server API endpoint Caddy proxies to (overridable for non-compose setups). +ENV LDGR_API_UPSTREAM=server:8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:8080/ >/dev/null 2>&1 || exit 1 + +# OCI image metadata. The web app is Apache-2.0 (see LICENSE at the repo root). +LABEL org.opencontainers.image.title="ldgr-web" \ + org.opencontainers.image.description="ldgr web app + admin panel (static export served by Caddy)" \ + org.opencontainers.image.source="https://github.com/kafkade/ldgr" \ + org.opencontainers.image.url="https://github.com/kafkade/ldgr" \ + org.opencontainers.image.documentation="https://github.com/kafkade/ldgr/blob/main/docs/self-hosting.md" \ + org.opencontainers.image.licenses="Apache-2.0" diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index e6fb056..5c59474 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -23,10 +23,23 @@ "@types/sql.js": "^1.4.11", "autoprefixer": "^10.5.2", "postcss": "^8.5.16", - "tailwindcss": "^4.3.2", + "tailwindcss": "^3.4.17", "typescript": "^6.0.3" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -503,6 +516,45 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@next/env": { "version": "16.2.10", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", @@ -649,6 +701,44 @@ "node": ">= 10" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -753,6 +843,34 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.5.2", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", @@ -802,6 +920,32 @@ "node": ">=6.0.0" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -845,6 +989,16 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -865,6 +1019,44 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -900,6 +1092,29 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -926,12 +1141,26 @@ "node": ">=8" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.381", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", @@ -945,6 +1174,16 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -955,6 +1194,59 @@ "node": ">=6" } }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -982,6 +1274,31 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -991,6 +1308,71 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1000,6 +1382,59 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1012,6 +1447,30 @@ "node": ">=8" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -1022,6 +1481,18 @@ "mini-svg-data-uri": "cli.js" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1131,8 +1602,38 @@ "node": ">=18" } }, - "node_modules/p-limit": { - "version": "2.3.0", + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", @@ -1176,12 +1677,52 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -1220,6 +1761,133 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -1244,6 +1912,27 @@ "node": ">=10.13.0" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1265,6 +1954,29 @@ "react": "^19.2.7" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1280,6 +1992,63 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1414,12 +2183,170 @@ } } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/tslib": { "version": "2.8.1", @@ -1479,6 +2406,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 2160591..9432b66 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,9 +5,9 @@ "description": "ldgr web app — zero-knowledge bookkeeping in the browser", "type": "module", "scripts": { - "dev": "next dev", + "dev": "next dev --webpack", "build:wasm": "wasm-pack build ../../crates/ldgr-wasm --target web --release --out-dir ../../apps/web/pkg -- --no-default-features --features core,sync", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "next lint", "test": "node --experimental-strip-types --test test/integration.mjs test/sync-wasm.mjs test/admin-api.mjs" @@ -28,7 +28,7 @@ "@types/sql.js": "^1.4.11", "autoprefixer": "^10.5.2", "postcss": "^8.5.16", - "tailwindcss": "^4.3.2", + "tailwindcss": "^3.4.17", "typescript": "^6.0.3" } } diff --git a/apps/web/src/components/admin/AdminSignIn.tsx b/apps/web/src/components/admin/AdminSignIn.tsx index 30539e7..4eb88ca 100644 --- a/apps/web/src/components/admin/AdminSignIn.tsx +++ b/apps/web/src/components/admin/AdminSignIn.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useAdmin } from '@/contexts/AdminContext'; import { fetchServerInfo, type ServerInfo } from '@/lib/admin'; import { @@ -26,6 +26,14 @@ export function AdminSignIn() { const [info, setInfo] = useState(null); const [probing, setProbing] = useState(false); + // Default the server URL to the panel's own origin so the batteries-included + // deploy (panel served by Caddy alongside the reverse-proxied API) works + // without typing a URL. Operators hosting the panel on a separate origin can + // still edit it. Runs client-side only to avoid a hydration mismatch. + useEffect(() => { + setServerUrl((prev) => prev || window.location.origin); + }, []); + const probe = async () => { const url = serverUrl.trim(); if (!url) return; diff --git a/crates/ldgr-cli/tests/server_sync.rs b/crates/ldgr-cli/tests/server_sync.rs index 063fca1..7be2487 100644 --- a/crates/ldgr-cli/tests/server_sync.rs +++ b/crates/ldgr-cli/tests/server_sync.rs @@ -35,6 +35,7 @@ async fn spawn_server() -> (String, Arc) { srp_handshake_ttl_secs: 120, registration_policy: RegistrationPolicy::Open, admin_email: None, + allowed_origins: Vec::new(), default_user_quota_bytes: 1024 * 1024 * 1024, server_name: "ldgr-test-server".into(), }; diff --git a/crates/ldgr-cli/tests/sync_round_trip.rs b/crates/ldgr-cli/tests/sync_round_trip.rs index 0dbee99..2883727 100644 --- a/crates/ldgr-cli/tests/sync_round_trip.rs +++ b/crates/ldgr-cli/tests/sync_round_trip.rs @@ -56,6 +56,7 @@ async fn spawn_server() -> String { srp_handshake_ttl_secs: 120, registration_policy: RegistrationPolicy::Open, admin_email: None, + allowed_origins: Vec::new(), default_user_quota_bytes: 1024 * 1024 * 1024, server_name: "ldgr-test-server".into(), }; diff --git a/crates/ldgr-server/src/api/auth.rs b/crates/ldgr-server/src/api/auth.rs index a21630a..b9d8a91 100644 --- a/crates/ldgr-server/src/api/auth.rs +++ b/crates/ldgr-server/src/api/auth.rs @@ -190,14 +190,60 @@ pub async fn register( // Two supported modes: // * Env-seeded (preferred for unattended docker-compose): if // `LDGR_ADMIN_EMAIL` is set, the account registering with that email - // becomes admin and bypasses the registration policy. + // becomes admin and bypasses the registration policy. Safe under + // concurrent registration via the UNIQUE(email) index. // * First-user fallback: if no admin email is configured, the very first // account to register (empty user table) becomes admin and bypasses the - // policy. After that, the policy governs all sign-ups. + // policy. This election + insert is performed ATOMICALLY (single + // transaction) so that at most one admin is ever elected even under + // concurrent registration. After that, the policy governs all sign-ups. + if state.config.admin_email.is_none() { + let elected = state + .db + .try_bootstrap_first_admin(&NewUser { + id: &user_id, + username: &req.username, + email: Some(&email), + salt: &salt, + verifier: &verifier, + role: "admin", + auth_scheme, + invited_by: None, + created_at: &created_at, + account_id: account_id.as_deref(), + account_kdf_salt: account_kdf.as_ref().map(AccountKdf::salt), + account_kdf_mem_kib: account_kdf + .as_ref() + .map(|k| i64::from(k.params().memory_cost_kib)), + account_kdf_iters: account_kdf + .as_ref() + .map(|k| i64::from(k.params().iterations)), + account_kdf_parallelism: account_kdf + .as_ref() + .map(|k| i64::from(k.params().parallelism)), + }) + .await?; + if let crate::storage::BootstrapElection::ElectedAdmin = elected { + tracing::info!("registered bootstrap admin: {} (role=admin)", req.username); + return Ok(( + StatusCode::CREATED, + Json(RegisterResponse { + user_id, + role: "admin".to_string(), + }), + )); + } + // NotFirst → fall through to the policy-governed path as a normal user. + } + let is_bootstrap_admin = if let Some(admin_email) = state.config.admin_email.as_deref() { email.eq_ignore_ascii_case(admin_email) && !state.db.email_exists(admin_email).await? } else { - state.db.count_users().await? == 0 + // The no-admin-email fallback election was already resolved atomically + // above (early return when this request won the admin slot), so any + // request reaching here in fallback mode is definitively NOT the first + // user and must be governed by the registration policy. + false }; // ── Registration policy enforcement ───────────────────────────────────── diff --git a/crates/ldgr-server/src/config.rs b/crates/ldgr-server/src/config.rs index a9d1907..bb496ad 100644 --- a/crates/ldgr-server/src/config.rs +++ b/crates/ldgr-server/src/config.rs @@ -49,6 +49,13 @@ pub struct Config { /// set, the account registering with this email becomes `admin` and bypasses /// the registration policy. Preferred for unattended docker-compose deploys. pub admin_email: Option, + /// Cross-origin request allowlist for the browser-based admin panel and web + /// client (`LDGR_ALLOWED_ORIGINS`, comma-separated). Empty by default, which + /// means **no** cross-origin access is granted — the secure posture for a + /// zero-knowledge product. Set this only when the web app is served from a + /// different origin than this API (e.g. a separate admin host); a same-origin + /// deployment (reverse proxy) needs no entry here. + pub allowed_origins: Vec, /// Default per-user storage quota in bytes, applied when a user's /// `storage_quota_bytes` is unset. pub default_user_quota_bytes: i64, @@ -88,6 +95,7 @@ impl Config { "invite-only", )), admin_email, + allowed_origins: parse_csv_list(&env_or("LDGR_ALLOWED_ORIGINS", "")), default_user_quota_bytes: env_or("LDGR_DEFAULT_QUOTA_BYTES", "1073741824") // 1 GiB .parse() .expect("LDGR_DEFAULT_QUOTA_BYTES must be a valid number"), @@ -106,3 +114,12 @@ impl Config { fn env_or(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_string()) } + +/// Split a comma-separated env value into a trimmed, non-empty list. +fn parse_csv_list(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} diff --git a/crates/ldgr-server/src/cors.rs b/crates/ldgr-server/src/cors.rs new file mode 100644 index 0000000..39c6eda --- /dev/null +++ b/crates/ldgr-server/src/cors.rs @@ -0,0 +1,64 @@ +//! Scoped CORS layer for the browser-based admin panel and web client. +//! +//! The admin UI (`apps/web`, Apache-2.0) is a static export that can be served +//! from a different origin than this API. Browsers then block its `fetch` calls +//! unless the server returns CORS headers. We grant access **only** to an +//! explicit, operator-configured allowlist (`LDGR_ALLOWED_ORIGINS`) — never a +//! wildcard/permissive default — because this is a zero-knowledge product where +//! the safe posture is to deny cross-origin access unless deliberately enabled. +//! +//! A same-origin deployment (the web app reverse-proxied behind the same host as +//! the API) needs no allowlist entry at all and therefore no CORS headers. + +use axum::http::{HeaderValue, Method, header}; +use tower_http::cors::{AllowOrigin, CorsLayer}; + +/// Build a scoped [`CorsLayer`] from an explicit origin allowlist. +/// +/// Returns `None` when the allowlist is empty (or contains no parseable +/// origins), in which case no CORS layer should be attached and cross-origin +/// browser requests remain blocked by default. Entries that are not valid +/// header values are skipped with a warning rather than aborting startup. +/// +/// The layer allows only the methods and headers the admin + sync API needs and +/// does **not** enable credentials: authentication is a `Bearer` token carried +/// in the `Authorization` header (never a cookie), so `Access-Control-Allow- +/// Credentials` is unnecessary and deliberately omitted. +#[must_use] +pub fn cors_layer(allowed_origins: &[String]) -> Option { + let origins: Vec = allowed_origins + .iter() + .filter_map(|origin| { + HeaderValue::from_str(origin) + .inspect_err(|_| { + tracing::warn!("ignoring invalid LDGR_ALLOWED_ORIGINS entry: {origin:?}"); + }) + .ok() + }) + .collect(); + + if origins.is_empty() { + return None; + } + + // Cache preflight results for an hour. Kept in seconds (rather than + // `Duration::from_hours`, which is newer than our MSRV); the pedantic + // "use a larger unit" lint is intentional here. + #[allow(clippy::duration_suboptimal_units)] + let preflight_max_age = std::time::Duration::from_secs(3600); + + Some( + CorsLayer::new() + .allow_origin(AllowOrigin::list(origins)) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) + .max_age(preflight_max_age), + ) +} diff --git a/crates/ldgr-server/src/lib.rs b/crates/ldgr-server/src/lib.rs index 892a997..acf4bd6 100644 --- a/crates/ldgr-server/src/lib.rs +++ b/crates/ldgr-server/src/lib.rs @@ -28,6 +28,7 @@ pub const MAX_PROTOCOL_VERSION: u32 = PROTOCOL_VERSION; pub mod api; pub mod auth; pub mod config; +pub mod cors; pub mod error; pub mod settings; pub mod state; diff --git a/crates/ldgr-server/src/main.rs b/crates/ldgr-server/src/main.rs index ea377f2..3f4381c 100644 --- a/crates/ldgr-server/src/main.rs +++ b/crates/ldgr-server/src/main.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use tower_http::trace::TraceLayer; -use ldgr_server::{api, auth, config, state, storage}; +use ldgr_server::{api, auth, config, cors, state, storage}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -25,7 +25,18 @@ async fn main() -> anyhow::Result<()> { config, }); - let app = api::router(state.clone()).layer(TraceLayer::new_for_http()); + let mut app = api::router(state.clone()).layer(TraceLayer::new_for_http()); + + // Attach the scoped CORS layer only when an allowlist is configured; with no + // `LDGR_ALLOWED_ORIGINS` set, cross-origin browser requests stay blocked + // (the secure default). Same-origin deployments need no allowlist. + if let Some(cors) = cors::cors_layer(&state.config.allowed_origins) { + tracing::info!( + "CORS enabled for {} allowed origin(s)", + state.config.allowed_origins.len() + ); + app = app.layer(cors); + } let listener = tokio::net::TcpListener::bind(state.config.bind_addr).await?; tracing::info!( diff --git a/crates/ldgr-server/src/storage/mod.rs b/crates/ldgr-server/src/storage/mod.rs index 7f8c0a7..fbd89ca 100644 --- a/crates/ldgr-server/src/storage/mod.rs +++ b/crates/ldgr-server/src/storage/mod.rs @@ -120,6 +120,15 @@ pub struct RelayOffer { pub expires_at: String, } +/// Outcome of the atomic first-admin election (`try_bootstrap_first_admin`). +pub enum BootstrapElection { + /// The users table was empty, so this registration was inserted as `admin`. + ElectedAdmin, + /// A user already existed; nothing was inserted. The caller must fall back + /// to the normal, policy-governed registration path. + NotFirst, +} + // ── Database ────────────────────────────────────────────────────────────────── /// Server-side `SQLite` storage. All operations run in `spawn_blocking` @@ -224,6 +233,31 @@ impl ServerDb { // ── Users ───────────────────────────────────────────────────────────────── + /// Insert a single `users` row on an already-held connection (or an open + /// transaction, via `Deref`). Shared by [`create_user`](Self::create_user) + /// and [`try_bootstrap_first_admin`](Self::try_bootstrap_first_admin) so the + /// column list can never drift between the two insert paths. A unique-index + /// violation (duplicate username/email) is mapped to [`ServerError::Conflict`]. + fn insert_user_row(conn: &Connection, new: &NewUser<'_>) -> Result<(), ServerError> { + conn.execute( + "INSERT INTO users \ + (id, username, email, salt, verifier, created_at, role, status, auth_scheme, invited_by, updated_at, account_id, \ + account_kdf_salt, account_kdf_mem_kib, account_kdf_iters, account_kdf_parallelism) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?9, ?6, ?10, ?11, ?12, ?13, ?14)", + params![new.id, new.username, new.email, new.salt, new.verifier, new.created_at, new.role, + new.auth_scheme, new.invited_by, new.account_id, new.account_kdf_salt, + new.account_kdf_mem_kib, new.account_kdf_iters, new.account_kdf_parallelism], + ).map_err(|e| match e { + rusqlite::Error::SqliteFailure(err, _) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + ServerError::Conflict("username or email already exists".into()) + } + other => ServerError::from(other), + })?; + Ok(()) + } + pub async fn create_user(&self, new: &NewUser<'_>) -> Result<(), ServerError> { let conn = self.conn.clone(); let id = new.id.to_string(); @@ -244,22 +278,97 @@ impl ServerDb { let conn = conn .lock() .map_err(|e| ServerError::Internal(format!("lock poisoned: {e}")))?; - conn.execute( - "INSERT INTO users \ - (id, username, email, salt, verifier, created_at, role, status, auth_scheme, invited_by, updated_at, account_id, \ - account_kdf_salt, account_kdf_mem_kib, account_kdf_iters, account_kdf_parallelism) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?9, ?6, ?10, ?11, ?12, ?13, ?14)", - params![id, username, email, salt, verifier, created_at, role, auth_scheme, invited_by, account_id, - account_kdf_salt, account_kdf_mem_kib, account_kdf_iters, account_kdf_parallelism], - ).map_err(|e| match e { - rusqlite::Error::SqliteFailure(err, _) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - ServerError::Conflict("username or email already exists".into()) - } - other => ServerError::from(other), - })?; - Ok(()) + Self::insert_user_row( + &conn, + &NewUser { + id: &id, + username: &username, + email: email.as_deref(), + salt: &salt, + verifier: &verifier, + role: &role, + auth_scheme: &auth_scheme, + invited_by: invited_by.as_deref(), + created_at: &created_at, + account_id: account_id.as_deref(), + account_kdf_salt: account_kdf_salt.as_deref(), + account_kdf_mem_kib, + account_kdf_iters, + account_kdf_parallelism, + }, + ) + }) + .await? + } + + /// Atomically elect the first-ever account as `admin` (first-run bootstrap + /// fallback, used only when `LDGR_ADMIN_EMAIL` is unset — ADR-008 Decision 5). + /// + /// In a single `BEGIN IMMEDIATE` write transaction on the serialized + /// connection: if `users` is empty, insert `new` (expected `role = "admin"`) + /// and commit, returning [`BootstrapElection::ElectedAdmin`]; otherwise insert + /// nothing, roll back, and return [`BootstrapElection::NotFirst`] so the + /// caller runs the normal, policy-governed registration path. + /// + /// This is the race-free replacement for the old `count_users() == 0` + /// check-then-insert: the count and the insert share one transaction, and the + /// single `Mutex` (plus `BEGIN IMMEDIATE` + WAL `busy_timeout`) + /// serialize writers, so **at most one** admin is ever elected even under + /// concurrent registration. + pub async fn try_bootstrap_first_admin( + &self, + new: &NewUser<'_>, + ) -> Result { + let conn = self.conn.clone(); + let id = new.id.to_string(); + let username = new.username.to_string(); + let email = new.email.map(str::to_string); + let salt = new.salt.to_vec(); + let verifier = new.verifier.to_vec(); + let role = new.role.to_string(); + let auth_scheme = new.auth_scheme.to_string(); + let created_at = new.created_at.to_string(); + let account_id = new.account_id.map(str::to_string); + let account_kdf_salt = new.account_kdf_salt.map(<[u8]>::to_vec); + let account_kdf_mem_kib = new.account_kdf_mem_kib; + let account_kdf_iters = new.account_kdf_iters; + let account_kdf_parallelism = new.account_kdf_parallelism; + tokio::task::spawn_blocking(move || { + let mut conn = conn + .lock() + .map_err(|e| ServerError::Internal(format!("lock poisoned: {e}")))?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(ServerError::from)?; + + let count: i64 = tx.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?; + if count != 0 { + // A user already exists; leave the table untouched. Dropping the + // transaction rolls it back. + return Ok(BootstrapElection::NotFirst); + } + + Self::insert_user_row( + &tx, + &NewUser { + id: &id, + username: &username, + email: email.as_deref(), + salt: &salt, + verifier: &verifier, + role: &role, + auth_scheme: &auth_scheme, + invited_by: None, + created_at: &created_at, + account_id: account_id.as_deref(), + account_kdf_salt: account_kdf_salt.as_deref(), + account_kdf_mem_kib, + account_kdf_iters, + account_kdf_parallelism, + }, + )?; + tx.commit().map_err(ServerError::from)?; + Ok(BootstrapElection::ElectedAdmin) }) .await? } diff --git a/crates/ldgr-server/tests/account_model.rs b/crates/ldgr-server/tests/account_model.rs index 06bbaec..e651968 100644 --- a/crates/ldgr-server/tests/account_model.rs +++ b/crates/ldgr-server/tests/account_model.rs @@ -37,6 +37,7 @@ fn base_config() -> Config { srp_handshake_ttl_secs: 120, registration_policy: RegistrationPolicy::Open, admin_email: None, + allowed_origins: Vec::new(), default_user_quota_bytes: 1_073_741_824, server_name: "ldgr-server".into(), } diff --git a/crates/ldgr-server/tests/admin_api.rs b/crates/ldgr-server/tests/admin_api.rs index f07515e..5b256bc 100644 --- a/crates/ldgr-server/tests/admin_api.rs +++ b/crates/ldgr-server/tests/admin_api.rs @@ -37,6 +37,7 @@ fn base_config() -> Config { srp_handshake_ttl_secs: 120, registration_policy: RegistrationPolicy::Open, admin_email: None, + allowed_origins: Vec::new(), default_user_quota_bytes: 1_073_741_824, server_name: "ldgr-server".into(), } diff --git a/crates/ldgr-server/tests/bootstrap_race.rs b/crates/ldgr-server/tests/bootstrap_race.rs new file mode 100644 index 0000000..9c70dd3 --- /dev/null +++ b/crates/ldgr-server/tests/bootstrap_race.rs @@ -0,0 +1,198 @@ +//! Concurrency regression tests for the **atomic first-admin bootstrap** +//! (issue #297, finding F7). +//! +//! The old election read `count_users() == 0` and then, in a *separate* DB call, +//! inserted the user — a check-then-insert race where two concurrent first-time +//! registrations could both become `admin`. These tests spawn many simultaneous +//! `register` calls against a fresh, no-`LDGR_ADMIN_EMAIL` server (each with a +//! distinct email, so the `UNIQUE(email)` index cannot mask the race) and assert +//! that **exactly one** admin is ever elected. +//! +//! Requests are dispatched straight into the axum router via `tower`'s +//! `oneshot`; the SRP `(salt, verifier)` come from the real `ldgr-core` client +//! primitives. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use ldgr_core::sync::server::register_with_salt; +use ldgr_server::auth::hex_encode; +use ldgr_server::config::{Config, RegistrationPolicy}; +use ldgr_server::{api, auth, state, storage}; + +fn config(policy: RegistrationPolicy) -> Config { + Config { + bind_addr: "127.0.0.1:8080".parse().unwrap(), + db_path: ":memory:".into(), + session_ttl_hours: 720, + relay_ttl_minutes: 10, + max_blob_bytes: 52_428_800, + srp_handshake_ttl_secs: 120, + registration_policy: policy, + admin_email: None, + allowed_origins: Vec::new(), + default_user_quota_bytes: 1_073_741_824, + server_name: "race-test".into(), + } +} + +/// Boot a fresh in-memory server (empty DB) with the given registration policy +/// and no configured admin email, so the first-user fallback election is active. +fn fresh_state(policy: RegistrationPolicy) -> state::SharedState { + let db = storage::ServerDb::open(":memory:").expect("open in-memory db"); + let config = config(policy); + let srp_ttl = std::time::Duration::from_secs(config.srp_handshake_ttl_secs); + Arc::new(state::AppState { + db, + srp_handshakes: auth::srp::SrpHandshakeStore::new(srp_ttl), + config, + }) +} + +/// Build a self-contained `register` request for `username` with a distinct +/// per-user email. +fn register_request(username: &str) -> Request { + let email = format!("{username}@example.org"); + let reg = register_with_salt(username, b"pw", vec![0x5a; 16]); + let body = json!({ + "username": username, + "email": email, + "salt": hex_encode(®.salt), + "verifier": hex_encode(®.verifier), + }); + Request::builder() + .method("POST") + .uri("/api/v1/auth/register") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap() +} + +async fn dispatch(state: state::SharedState, req: Request) -> (StatusCode, Value) { + let resp = api::router(state) + .oneshot(req) + .await + .expect("router oneshot"); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let json = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(Value::Null) + }; + (status, json) +} + +/// Fire `n` `register` calls concurrently against the same shared state and +/// return each `(status, role)` outcome (`role` is `None` when the response +/// carried no role, e.g. a rejection). +async fn register_concurrently( + state: &state::SharedState, + n: usize, +) -> Vec<(StatusCode, Option)> { + let mut handles = Vec::with_capacity(n); + for i in 0..n { + let state = state.clone(); + let req = register_request(&format!("user{i}")); + handles.push(tokio::spawn(async move { dispatch(state, req).await })); + } + let mut out = Vec::with_capacity(n); + for h in handles { + let (st, body) = h.await.expect("task join"); + let role = body.get("role").and_then(Value::as_str).map(str::to_string); + out.push((st, role)); + } + out +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_registration_elects_exactly_one_admin_under_open_policy() { + // Repeat to shake out interleavings. + for round in 0..10 { + let state = fresh_state(RegistrationPolicy::Open); + let results = register_concurrently(&state, 16).await; + + let admins = results + .iter() + .filter(|(_, r)| r.as_deref() == Some("admin")) + .count(); + let users = results + .iter() + .filter(|(_, r)| r.as_deref() == Some("user")) + .count(); + + assert_eq!( + admins, 1, + "round {round}: exactly one admin must be elected, got {admins} in {results:?}" + ); + assert_eq!( + users, 15, + "round {round}: the rest must be normal users, got {users} in {results:?}" + ); + assert!( + results.iter().all(|(st, _)| *st == StatusCode::CREATED), + "round {round}: every open-policy registration should succeed: {results:?}" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_registration_elects_exactly_one_admin_under_invite_only() { + for round in 0..10 { + let state = fresh_state(RegistrationPolicy::InviteOnly); + let results = register_concurrently(&state, 16).await; + + let admins = results + .iter() + .filter(|(_, r)| r.as_deref() == Some("admin")) + .count(); + let rejected = results + .iter() + .filter(|(st, _)| *st == StatusCode::FORBIDDEN) + .count(); + + assert_eq!( + admins, 1, + "round {round}: exactly one admin must be elected, got {admins} in {results:?}" + ); + assert_eq!( + rejected, 15, + "round {round}: non-winners must be rejected under invite-only, got {rejected} in {results:?}" + ); + } +} + +#[tokio::test] +async fn sequential_first_user_is_admin_and_second_is_user_under_open() { + let state = fresh_state(RegistrationPolicy::Open); + + let (s1, b1) = dispatch(state.clone(), register_request("alice")).await; + assert_eq!(s1, StatusCode::CREATED); + assert_eq!(b1["role"], "admin"); + + let (s2, b2) = dispatch(state.clone(), register_request("bob")).await; + assert_eq!(s2, StatusCode::CREATED); + assert_eq!(b2["role"], "user"); +} + +#[tokio::test] +async fn sequential_second_user_is_rejected_under_invite_only() { + let state = fresh_state(RegistrationPolicy::InviteOnly); + + let (s1, b1) = dispatch(state.clone(), register_request("alice")).await; + assert_eq!(s1, StatusCode::CREATED, "first user bootstraps as admin"); + assert_eq!(b1["role"], "admin"); + + let (s2, _b2) = dispatch(state.clone(), register_request("bob")).await; + assert_eq!( + s2, + StatusCode::FORBIDDEN, + "a second user without an invite is refused under invite-only" + ); +} diff --git a/crates/ldgr-server/tests/common/mod.rs b/crates/ldgr-server/tests/common/mod.rs index b76b6e6..0199a9f 100644 --- a/crates/ldgr-server/tests/common/mod.rs +++ b/crates/ldgr-server/tests/common/mod.rs @@ -39,6 +39,7 @@ pub fn open_config() -> Config { srp_handshake_ttl_secs: 120, registration_policy: RegistrationPolicy::Open, admin_email: None, + allowed_origins: Vec::new(), default_user_quota_bytes: 1_073_741_824, server_name: "e2e-server".into(), } diff --git a/crates/ldgr-server/tests/cors.rs b/crates/ldgr-server/tests/cors.rs new file mode 100644 index 0000000..fd903d6 --- /dev/null +++ b/crates/ldgr-server/tests/cors.rs @@ -0,0 +1,128 @@ +//! Tests for the **scoped CORS layer** (issue #297, finding F3). +//! +//! The admin/web panel (`apps/web`, a static export) is often served from a +//! different origin than the API, so the server must return CORS headers — but +//! **only** for the operator-configured allowlist (`LDGR_ALLOWED_ORIGINS`), and +//! nothing at all by default. These tests drive a minimal router with the layer +//! attached via `tower`'s `oneshot`. + +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use axum::routing::get; +use tower::ServiceExt; + +use ldgr_server::cors::cors_layer; + +/// A tiny router with the CORS layer applied exactly as `main.rs` does: attached +/// only when `cors_layer` yields a layer for the given allowlist. +fn app(allowed_origins: &[String]) -> Router { + let base = Router::new().route("/health", get(|| async { "ok" })); + match cors_layer(allowed_origins) { + Some(cors) => base.layer(cors), + None => base, + } +} + +#[test] +fn empty_or_invalid_allowlist_disables_cors() { + assert!(cors_layer(&[]).is_none(), "no origins => no CORS layer"); + assert!( + cors_layer(&["not a\nvalid header".to_string()]).is_none(), + "an all-invalid allowlist yields no layer" + ); +} + +#[tokio::test] +async fn preflight_allows_configured_origin() { + let origin = "https://admin.example.com"; + let resp = app(&[origin.to_string()]) + .oneshot( + Request::builder() + .method("OPTIONS") + .uri("/health") + .header(header::ORIGIN, origin) + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let acao = resp + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()); + assert_eq!(acao, Some(origin)); +} + +#[tokio::test] +async fn preflight_rejects_unlisted_origin() { + let resp = app(&["https://admin.example.com".to_string()]) + .oneshot( + Request::builder() + .method("OPTIONS") + .uri("/health") + .header(header::ORIGIN, "https://evil.example.com") + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none(), + "an unlisted origin must not receive an Access-Control-Allow-Origin header" + ); +} + +#[tokio::test] +async fn simple_request_carries_cors_header_for_allowed_origin() { + let origin = "https://admin.example.com"; + let resp = app(&[origin.to_string()]) + .oneshot( + Request::builder() + .method("GET") + .uri("/health") + .header(header::ORIGIN, origin) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let acao = resp + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()); + assert_eq!(acao, Some(origin)); +} + +#[tokio::test] +async fn no_cors_headers_when_allowlist_empty() { + let origin = "https://admin.example.com"; + let resp = app(&[]) + .oneshot( + Request::builder() + .method("GET") + .uri("/health") + .header(header::ORIGIN, origin) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!( + resp.headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none(), + "with no allowlist the server must not emit CORS headers" + ); +} diff --git a/crates/ldgr-server/tests/ffi_sync_client_e2e.rs b/crates/ldgr-server/tests/ffi_sync_client_e2e.rs index e215da8..99d999b 100644 --- a/crates/ldgr-server/tests/ffi_sync_client_e2e.rs +++ b/crates/ldgr-server/tests/ffi_sync_client_e2e.rs @@ -38,6 +38,7 @@ fn open_config() -> Config { srp_handshake_ttl_secs: 120, registration_policy: RegistrationPolicy::Open, admin_email: None, + allowed_origins: Vec::new(), default_user_quota_bytes: 1_073_741_824, server_name: "ffi-e2e-server".into(), } diff --git a/crates/ldgr-server/tests/server_info.rs b/crates/ldgr-server/tests/server_info.rs index 0702c2f..1323676 100644 --- a/crates/ldgr-server/tests/server_info.rs +++ b/crates/ldgr-server/tests/server_info.rs @@ -24,6 +24,7 @@ fn config_with(policy: RegistrationPolicy, server_name: &str) -> Config { srp_handshake_ttl_secs: 120, registration_policy: policy, admin_email: None, + allowed_origins: Vec::new(), default_user_quota_bytes: 1_073_741_824, server_name: server_name.into(), } diff --git a/docker-compose.yml b/docker-compose.yml index 755df85..da10c42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,16 +36,47 @@ services: retries: 3 start_period: 10s + # Web app + admin panel (Apache-2.0), served as a static export by Caddy and + # reverse-proxying the JSON API to `server`, so the panel is reachable out of + # the box at http://:${LDGR_WEB_PORT}/admin — no separate host needed. + # The AGPL `server` never serves these assets (ADR-006 / ADR-008 §7). + web: + image: ghcr.io/kafkade/ldgr-web:${LDGR_VERSION:-latest} + # To build locally from source instead of pulling, comment out `image:` + # above and uncomment the `build:` block below, then run + # `docker compose up -d --build`. + # build: + # context: . + # dockerfile: apps/web/Dockerfile + container_name: ldgr-web + restart: unless-stopped + depends_on: + - server + environment: + # Where Caddy proxies /api/* and /health. Points at the server container. + LDGR_API_UPSTREAM: server:8080 + ports: + # host:container — change the host port via LDGR_WEB_PORT in .env. + - "${LDGR_WEB_PORT:-8081}:8080" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + # Optional automatic-HTTPS reverse proxy. Enable with: # docker compose --profile tls up -d # Set LDGR_DOMAIN (and optionally LDGR_ACME_EMAIL) in .env and point your - # domain's DNS at this host first. See ./Caddyfile and docs/self-hosting.md. + # domain's DNS at this host first. It fronts the `web` service, so a single + # TLS origin serves the panel and (via web's proxy) the API. See ./Caddyfile. caddy: image: caddy:2 container_name: ldgr-caddy restart: unless-stopped profiles: ["tls"] depends_on: + - web - server ports: - "80:80" diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 00e7029..e8708d8 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -70,6 +70,9 @@ Your server is now listening on `http://:8080`. Point your ldgr clients at that URL (see the [sync setup guide](sync-setup.md) to create an account and register devices). +The **admin panel** is served for you at `http://:8081/admin` (the bundled +`web` container) — see [First-run admin onboarding](#first-run-admin-onboarding). + > **Personal instance tip:** the default `LDGR_REGISTRATION=invite-only` is the > most secure choice but requires an admin-issued invite. For a single-user or > family instance it's usually easiest to set `LDGR_ADMIN_EMAIL=you@example.com` @@ -87,7 +90,8 @@ itself. | Variable | Default | Description | | --- | --- | --- | | `LDGR_VERSION` | `latest` | GHCR image tag to pull (e.g. `v1.2.3`, `1.2`, `1`, `latest`). Pin an explicit version in production. | -| `LDGR_HOST_PORT` | `8080` | Host port to expose (container always listens on `8080`). | +| `LDGR_HOST_PORT` | `8080` | Host port for the server API (container always listens on `8080`). | +| `LDGR_WEB_PORT` | `8081` | Host port for the web app + admin panel served by the `web` (Caddy) container. | | `LDGR_DOMAIN` | (unset) | Public domain for the optional Caddy TLS profile. | | `LDGR_ACME_EMAIL` | (unset) | Email for ACME/Let's Encrypt notices (Caddy TLS profile). | @@ -108,9 +112,9 @@ itself. ## Building locally instead of pulling -By default compose pulls the published multi-arch image from GHCR. To build from -source instead, edit `docker-compose.yml`: comment out the `image:` line under -the `server` service and uncomment the `build:` block, then: +By default compose pulls the published multi-arch images from GHCR. To build from +source instead, edit `docker-compose.yml`: comment out the `image:` line and +uncomment the `build:` block under the `server` and/or `web` service, then: ```sh docker compose up -d --build @@ -201,10 +205,11 @@ docker compose up -d ## First-run admin onboarding -The server is headless — it exposes only a JSON API. The **admin experience lives -in the web app** at `/admin` (Apache-2.0), which talks to the server's +The server is headless — it exposes only a JSON API. The **admin panel lives in +the ldgr web app** at `/admin` (Apache-2.0), which talks to the server's `/api/v1/admin/*` API over HTTP (see [ADR-008 §7](adr/008-self-hosting-and-account-auth.md)). -The server itself serves no admin HTML. +The compose bundle serves that web app for you from the `web` (Caddy) container; +the AGPL server itself serves no web assets. ### 1. Create the bootstrap admin @@ -223,10 +228,20 @@ at your server URL — see [Point a client at your server](#point-a-client-at-yo ### 2. Open the admin panel -The admin panel ships with the ldgr **web app**. Run the web app (or use your -hosted deployment of it), then browse to `/admin`: +`docker compose up` serves the panel for you: the `web` container (Caddy) hosts +the ldgr web app as a static export and reverse-proxies the API, so the panel is +reachable out of the box at + +``` +http://:8081/admin +``` + +(or `https:///admin` when the [TLS profile](#tls-with-caddy) is enabled). +To sign in: -- Enter your **Server URL**, **admin username**, and **password**. +- The **Server URL** is pre-filled with the panel's own origin — the same Caddy + serves the panel and proxies the API, so you only need your **admin username** + and **password**. - Sign-in runs the same **SRP handshake** as any client via the in-browser WASM client: your password is processed locally and only a zero-knowledge proof is sent. After the handshake the panel checks that your account is an admin; @@ -243,8 +258,33 @@ The panel has five screens: | **Server** | Server name, version, and protocol info. | > The admin session token is held in memory / `sessionStorage` only (never in a -> vault or `localStorage`) and is cleared on sign-out. Building the web app's WASM -> bundle (`npm run build:wasm`) is required because sign-in uses the SRP client. +> vault or `localStorage`) and is cleared on sign-out. +> +> **Hosting the panel elsewhere (advanced).** The panel is a static site, so you +> can serve it from any static host or CDN instead of the bundled `web` container +> — build it with `npm run build` in `apps/web/` and serve the resulting `out/` +> directory. Hosted that way it runs on a **different origin** than the API, so +> allow that origin via [CORS](#3-allow-the-panels-origin-cors) and type the API's +> URL into the Server URL field. + +### 3. Allow the panel's origin (CORS) + +The admin panel makes browser `fetch` calls to the server API. When the web app +is served from a **different origin** than the server — a separate admin host, or +local development — the browser blocks those calls unless the server explicitly +allows the web app's origin. Set `LDGR_ALLOWED_ORIGINS` to a comma-separated +allowlist: + +``` +LDGR_ALLOWED_ORIGINS=https://admin.example.com +``` + +It is **empty by default**, which denies all cross-origin requests — the secure +posture for a zero-knowledge server. The **bundled compose deploy is same-origin** +(Caddy serves the panel and proxies the API under one origin), so it needs **no +allowlist** — only set `LDGR_ALLOWED_ORIGINS` when you host the panel on a separate +origin. Only the methods and headers the API needs are permitted, and credentials +are never enabled (the admin session uses a `Bearer` token, not cookies). ## Registration policy