Author SHA1 Message Date
BBergleandClaude Opus 5 d0c0d98307 docs: record D18 (admin bootstrap) and the deploy bootstrap step
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 19s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
The deploy README described how to start the container but not how to get
into it, which left the first-run experience at a login page nobody can get
past. Adds the actual command, both the interactive and the piped form, and
says why there is no --password flag.

D18 records the three decisions worth arguing with later rather than
rediscovering: why this is a CLI instead of a bootstrap HTTP endpoint or an
env var (both rejected, with reasons), why it refuses an existing email, why
it is not restricted to the first user, and why the admin role is recorded
but not yet enforced.

Numbered D18 because D17 was taken by the registry-TLS decision that merged
while this branch was in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 22:31:15 -04:00
BBergleandClaude Opus 5 b7b4c31296 feat(auth): add velodrome create-admin to bootstrap the first user
A fresh deployment could not be used. Registration requires a valid invite
code, invites can only be issued by an existing admin, and a newly migrated
database has neither -- so there was no way to create the first account.
docs/PLAN.md always called for this command; it was never built during
Phase 0, and D16 (single-container deploy) made the gap reachable.

Adds a console script -- `[project.scripts]` -> velodrome.cli:main -- which
installs into the same venv as alembic and uvicorn, so the deployed image
already has it on PATH:

    docker exec -it velodrome velodrome create-admin --email you@example.com

The account-creating logic is `auth.service.create_admin`, not something in
cli.py, so that `db.auth_session` stays confined to auth/service.py as its
docstring requires. Its lookup is an exact match on a unique key, which is
the pattern db.py documents as safe on that session.

Deliberate constraints, all covered by tests (see docs/DECISIONS.md D18):

- Refuses an email that already exists rather than updating the row. An
  operator re-running a months-old command from shell history means "create",
  never "reset the password"; silently accepting would make this an
  undocumented password-reset tool that any container-exec grants.
- Not restricted to "only when there are zero users". The restriction buys
  nothing -- reaching the command already requires process execution inside
  the container, which already permits rewriting the SQLite file directly --
  while removing the cases that do happen: a second admin, and recovering an
  instance whose only admin was lost.
- No --password flag. An argument lands in shell history, in ps output, and
  in the Docker daemon's record of the exec'd command. A TTY prompt (with
  confirmation) and --password-stdin are the two forms that avoid all three.
- Pydantic's ValidationError is never printed verbatim: its rendering embeds
  the offending value, which for a short password prints the password itself.
  Only loc and msg are shown (CLAUDE.md invariant #5).

role="admin" is recorded but nothing enforces it yet -- there is no
admin-only endpoint until invite management in Phase 1. ROLE_ADMIN/
ROLE_MEMBER become named constants, and AuthenticatedSession carries the
role for that future check. It is deliberately absent from UserOut, so no
HTTP response and no OpenAPI contract changes. The register endpoint's
password and display-name constraints move to named aliases in schemas/auth
so the CLI applies exactly the same rules rather than a drifting copy.

Verified: ruff check, ruff format --check, mypy --strict, and the full
pytest suite (28 passed) from apps/api/; `alembic check` reports no model
drift. Also smoke-tested end to end against a scratch database -- creation,
the duplicate-email refusal, and the no-TTY message all behave as described.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 22:30:42 -04:00
BBergle 3b80034f0e Merge pull request 'fix(deploy): push through a TLS-terminating proxy, not raw Gitea HTTP' (#9) from fix/deploy-registry-tls into main
CI / Repo hygiene (push) Successful in 1s
CI / Web (lint, typecheck, build) (push) Successful in 15s
CI / Migrations reversible (push) Successful in 5s
CI / API (lint, types, tests) (push) Successful in 54s
Release image / Build and push single-container image (push) Successful in 1m39s
Reviewed-on: #9
2026-09-21 22:15:04 -04:00
BBergleandClaude Sonnet 5 6b0f28cf74 docs(deploy): note the /data uid/gid-mismatch trap on first start
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 14s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
Hit this deploying to the real Unraid host: the container runs as a fixed
non-root uid/gid (999), not root and not Unraid's usual nobody:users
(99:100). A freshly-created appdata directory is owned by nobody:users with
no write access for anyone else, so the container starts but uvicorn fails
immediately with "unable to open database file" — not obvious from the
error alone, worth documenting once rather than re-debugging it later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 21:08:03 -04:00
BBergle 8278d96875 Merge pull request 'fix(deploy): default the Unraid template's host port off 8080' (#7) from fix/deploy-unraid-template-port into main
CI / Repo hygiene (push) Successful in 3s
CI / Web (lint, typecheck, build) (push) Successful in 22s
CI / Migrations reversible (push) Successful in 9s
CI / API (lint, types, tests) (push) Successful in 1m5s
Reviewed-on: #7
2026-09-21 20:58:36 -04:00
BBergleandClaude Sonnet 5 45719f284c feat(deploy): build+push release image on every merge to main
CI / Repo hygiene (pull_request) Successful in 3s
CI / Web (lint, typecheck, build) (pull_request) Successful in 22s
CI / Migrations reversible (pull_request) Successful in 10s
CI / API (lint, types, tests) (pull_request) Successful in 1m4s
Was tag-push-or-manual-dispatch only. Adds a push:main trigger so main stays
continuously deployable without needing a version tag for every change.

Also fixes a real bug this surfaced while testing the D17 registry-TLS fix:
the old tag logic unconditionally retagged :latest on every run, including
manual test dispatches off a feature branch — one such dispatch, done while
verifying the previous commit, silently overwrote :latest with a
feature-branch build. Tag resolution now only moves :latest on an actual
main push or a version tag; a manual dispatch gets its own
manual-<timestamp>-<sha> tag and leaves :latest alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:57:52 -04:00
BBergleandClaude Sonnet 5 a114a7d3d8 fix(deploy): push through a TLS-terminating proxy, not raw Gitea HTTP
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 16s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
release.yml's first real run failed: docker/login-action against
192.168.0.3:3000 hit "server gave HTTP response to HTTPS client" — Docker
refuses any non-localhost registry over plain HTTP by default, so this was
never actually a workflow bug.

Rejected insecure-registries in daemon.json after reading this Unraid host's
own rc.docker script: applying it needs a full dockerd restart, and with
Live Restore disabled here, that stops every one of the ~40 other containers
on the box first. Also rejected a real Let's Encrypt cert on a public
bbergle.com subdomain — this host's other subdomains are Cloudflare-proxied,
which would terminate TLS at Cloudflare's edge and never reach our own cert
at all.

Chosen instead, scoped to touch nothing already working: a self-signed cert
for registry.bbergle.com behind a new NPMplus proxy host (found its real
HTTPS port, 9537, by reading `docker port NPMplus` rather than assuming 443,
which is a different nginx process on this box entirely); an /etc/hosts
entry on the Unraid host so only that host needs to resolve the name (no
DNS record, no router/NAT dependency); and its CA dropped into
/etc/docker/certs.d, which Docker's own docs confirm is read per-connection
with no daemon restart required. Also pins buildx to driver: docker instead
of setup-buildx-action's default docker-container driver, which runs an
isolated builder that doesn't see /etc/docker/certs.d and would have quietly
defeated all of the above.

Full record, including what was rejected and why, in docs/DECISIONS.md D17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:52:56 -04:00
BBergleandClaude Sonnet 5 32037b1190 fix(deploy): default the Unraid template's host port off 8080
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 13s
CI / Migrations reversible (pull_request) Successful in 5s
CI / API (lint, types, tests) (pull_request) Successful in 54s
8080 is already bound by qBittorrent on the actual Unraid host this gets
deployed to (found while placing the template for real) — defaulted to 8090
instead. Purely a template default; the container's own internal port is
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:21:01 -04:00
BBergle 70e0182177 Merge pull request 'chore(deploy): single-container Dockerfile, Caddy, and Unraid template' (#6) from chore/deploy-single-container into main
CI / Repo hygiene (push) Successful in 2s
CI / Web (lint, typecheck, build) (push) Successful in 14s
CI / Migrations reversible (push) Successful in 6s
CI / API (lint, types, tests) (push) Successful in 52s
Release image / Build and push single-container image (push) Failing after 1m42s
Reviewed-on: #6
2026-09-21 15:58:43 -04:00
BBergleandClaude Sonnet 5 6c48000d7b chore(deploy): single-container Dockerfile, Caddy, and Unraid template
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 15s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 53s
Builds the container the "1 container" decision (D15) actually needs, which
D15 itself deferred as follow-up work: Caddy + the FastAPI app + the static
SvelteKit build in one image, SQLite on a mounted volume. See docs/DECISIONS.md
D16 for the specific choices and why (entrypoint-run migrations instead of a
separate deploy-pipeline step, tini + a small supervisor script instead of
s6-overlay/supervisord, copying the Caddy binary out of its official image).

Removes apps/api/Dockerfile and apps/web/Dockerfile from the old 4-container
compose plan (PR #4, closed as superseded) — the root Dockerfile replaces both
with one multi-stage build.

deploy/unraid-template.xml turns VELODROME_PUBLIC_URL, VELODROME_SECRET_KEY,
etc. into fillable Unraid Community Applications web UI fields, per the
earlier decision to keep config there instead of a .env file.

.gitea/workflows/release.yml builds and pushes the image to the Gitea registry
on a version tag or manual dispatch; it does not touch the running container.

Verified by actually running the built image, not just building it: the
health endpoint responds through Caddy's proxy, the SPA serves with working
client-route fallback, alembic ran and produced a real (non-empty) SQLite file
under /data, the process runs as the non-root velodrome user, and killing the
uvicorn process brings the whole container down (exit 143) rather than
leaving Caddy serving alone — confirming the entrypoint's coupled-lifetime
behavior actually holds, not just that it reads correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 15:55:50 -04:00
BBergle 9fa50cb2ea Merge pull request 'refactor(api): move from Postgres+RLS to single-engine SQLite' (#5) from refactor/sqlite-single-container into main
CI / Repo hygiene (push) Successful in 2s
CI / Web (lint, typecheck, build) (push) Successful in 14s
CI / Migrations reversible (push) Successful in 5s
CI / API (lint, types, tests) (push) Successful in 53s
Reviewed-on: #5
2026-09-21 15:44:54 -04:00
19 changed files with 1170 additions and 99 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.gitea
docs
scripts
**/node_modules
**/.venv
**/__pycache__
**/*.pyc
apps/web/build
apps/web/.svelte-kit
apps/api/.pytest_cache
apps/api/.mypy_cache
apps/api/.ruff_cache
**/*.db
**/*.db-journal
+72
View File
@@ -0,0 +1,72 @@
name: Release image
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
jobs:
build-and-push:
name: Build and push single-container image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# driver: docker (not the action's default docker-container driver) so buildx reuses the
# host's own dockerd instead of spinning up an isolated builder container — the isolated
# one doesn't see the host's /etc/docker/certs.d, which is how the login step below trusts
# the registry's self-signed cert (docs/DECISIONS.md D17). We don't need multi-platform
# builds, so nothing the docker-container driver offers is actually lost here.
- uses: docker/setup-buildx-action@v3
with:
driver: docker
# secrets.GITEA_TOKEN cannot push to the Gitea container registry — a documented Gitea
# limitation, not a misconfiguration (see CLAUDE.md). REGISTRY_TOKEN is a separate PAT with
# package:write, expected to already exist as a repo secret.
#
# registry.bbergle.com:9537, not the raw 192.168.0.3:3000 Gitea talks HTTP on directly —
# Docker refuses any non-localhost registry over plain HTTP by default. This hostname is an
# NPMplus proxy host in front of Gitea's registry, terminating TLS with a self-signed cert;
# the runner host trusts it via /etc/docker/certs.d/registry.bbergle.com:9537/ca.crt (not
# committed here — host-local trust material, docs/DECISIONS.md D17 has the full setup).
- uses: docker/login-action@v3
with:
registry: registry.bbergle.com:9537
username: BBergle
password: ${{ secrets.REGISTRY_TOKEN }}
# `latest` should only ever mean "what's actually on main" (or a tagged release) — not
# whatever a manual test dispatch off some feature branch happened to build. Learned the
# hard way: a manual dispatch off this very branch, while verifying the fix above, silently
# overwrote `latest` under the old unconditional-tags logic. Building the full tag list here
# in bash (rather than a conditional expression inline in the tags: block below) means there's
# never a blank line for build-push-action to choke on when latest isn't included.
- name: Resolve image tags
id: tag
run: |
IMG=registry.bbergle.com:9537/bbergle/bike-app
if [ "${{ gitea.ref_type }}" = "tag" ]; then
VALUE="${{ gitea.ref_name }}"
UPDATE_LATEST=true
elif [ "${{ gitea.ref_name }}" = "main" ] && [ "${{ gitea.event_name }}" = "push" ]; then
VALUE="main-$(git rev-parse --short HEAD)"
UPDATE_LATEST=true
else
VALUE="manual-$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
UPDATE_LATEST=false
fi
{
echo "tags<<EOF"
echo "$IMG:$VALUE"
[ "$UPDATE_LATEST" = true ] && echo "$IMG:latest"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.tag.outputs.tags }}
+3 -2
View File
@@ -17,8 +17,9 @@ say so and argue it — but don't silently contradict it.
apps/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic apps/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic
apps/web/ SvelteKit static SPA (installable PWA) apps/web/ SvelteKit static SPA (installable PWA)
packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code
deploy/ single-container Dockerfile, Caddyfile, systemd units, backup scripts — Dockerfile single-container build (root, not deploy/ — needs both apps/api and apps/web
see docs/DECISIONS.md D15 for why this isn't docker-compose as build context). See docs/DECISIONS.md D15/D16 for why one container.
deploy/ Caddyfile, entrypoint.sh, unraid-template.xml, systemd backup units
docs/ plan, decisions, research docs/ plan, decisions, research
scripts/ repo tooling (PR helpers, etc.) scripts/ repo tooling (PR helpers, etc.)
.gitea/workflows/ CI .gitea/workflows/ CI
+73
View File
@@ -0,0 +1,73 @@
# syntax=docker/dockerfile:1
#
# Single-container image for Velodrome (docs/DECISIONS.md D15/D16): Caddy serves the SvelteKit
# static SPA and reverse-proxies /api/* to the FastAPI app, both running in the same container
# behind whatever TLS-terminating reverse proxy already exists on the host. Build context is the
# repo root, since this needs both apps/api and apps/web:
#
# docker build -t velodrome .
#
# Replaces apps/api/Dockerfile and apps/web/Dockerfile from the old 4-container compose plan
# (PR #4, closed as superseded) — those built two images meant to run as separate services;
# this builds one.
# ---- web: produces /app/build, nothing from this stage ends up running ----
FROM node:22-slim AS web-builder
WORKDIR /app
COPY apps/web/package.json apps/web/pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@9 --activate \
&& pnpm install --frozen-lockfile
COPY apps/web .
RUN pnpm run build
# ---- api: produces the venv at /app/.venv ----
FROM python:3.12-slim AS api-builder
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY apps/api/pyproject.toml apps/api/uv.lock ./
# Dependencies first, isolated from source changes, so touching velodrome/ doesn't invalidate
# this layer.
RUN uv sync --frozen --no-install-project --no-dev
COPY apps/api/velodrome ./velodrome
COPY apps/api/alembic ./alembic
COPY apps/api/alembic.ini ./
RUN uv sync --frozen --no-dev
# ---- runtime ----
FROM python:3.12-slim AS runtime
# Caddy's official images ship a single statically-linked Go binary (no CGO) — copying it out of
# the upstream image is the standard way to get Caddy into a non-Caddy base image without a
# second package manager or a source build.
COPY --from=caddy:2 /usr/bin/caddy /usr/bin/caddy
# tini is PID 1: reaps zombies and forwards signals correctly to entrypoint.sh's two background
# processes, which a bare `CMD` running a shell script as PID 1 would not do on its own.
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system velodrome && useradd --system --gid velodrome --create-home velodrome
WORKDIR /app
COPY --from=api-builder --chown=velodrome:velodrome /app /app
COPY --from=web-builder --chown=velodrome:velodrome /app/build /srv/web
COPY --chown=velodrome:velodrome deploy/Caddyfile /etc/caddy/Caddyfile
COPY --chown=velodrome:velodrome deploy/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
ENV PATH="/app/.venv/bin:$PATH"
# Absolute path into the mounted volume below. See deploy/README.md for the full env var table —
# this is the one variable NOT meant to be overridden per-deployment, since /data is the contract
# with the volume mount, not a per-instance setting.
ENV VELODROME_DATABASE_URL="sqlite+aiosqlite:////data/velodrome.db"
RUN mkdir -p /data && chown velodrome:velodrome /data
USER velodrome
VOLUME ["/data"]
# Non-privileged port: Caddy needs no root/setcap here, and TLS termination is the host reverse
# proxy's job (docs/PLAN.md "Service topology"), not this container's.
EXPOSE 8080
ENTRYPOINT ["tini", "--"]
CMD ["/app/entrypoint.sh"]
-31
View File
@@ -1,31 +0,0 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
# Split into two syncs so dependency installation caches independently of source changes: this
# first one installs only dependencies (--no-install-project), so touching velodrome/ doesn't
# invalidate this layer.
RUN uv sync --frozen --no-install-project --no-dev
COPY velodrome ./velodrome
COPY alembic ./alembic
COPY alembic.ini ./
# Now install the project itself into the same venv.
RUN uv sync --frozen --no-dev
FROM python:3.12-slim AS runtime
RUN groupadd --system velodrome && useradd --system --gid velodrome --create-home velodrome
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER velodrome
EXPOSE 8000
# Migrations run as an explicit step before this in deploy.yml (see docs/PLAN.md) — never from
# the entrypoint, so a failed migration fails the deploy visibly instead of crash-looping here.
CMD ["uvicorn", "velodrome.app:app", "--host", "0.0.0.0", "--port", "8000"]
+6
View File
@@ -15,6 +15,12 @@ dependencies = [
"uuid6>=2024.7.10", "uuid6>=2024.7.10",
] ]
[project.scripts]
# Installs into the venv's bin/ next to `alembic` and `uvicorn`, which deploy/entrypoint.sh
# already invokes by their installed script names — so `docker exec velodrome velodrome ...` works
# against the deployed image with no extra wiring (the Dockerfile puts /app/.venv/bin on PATH).
velodrome = "velodrome.cli:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff>=0.7", "ruff>=0.7",
+308
View File
@@ -0,0 +1,308 @@
"""Tests for the operator CLI (`velodrome create-admin`).
These drive `cli.run()` directly against the same real SQLite database and real Argon2id hashing
every other test uses — no mocks, per CLAUDE.md's test policy. That matters more than usual here:
this command is the only way to create the first account on a fresh deployment, it is run exactly
once by a human who has no way to debug it, and the failure mode of "it printed success but the
password doesn't actually work" is indistinguishable from a broken deployment. So the central test
below doesn't assert on a return code — it creates an admin through the CLI and then logs in as
that admin over HTTP, proving the hash the CLI wrote is one the login path accepts.
The other thing under test is what the CLI *refuses* to do: overwrite an existing account, and
echo a rejected password back to the terminal (CLAUDE.md invariant #5).
"""
import io
import sys
import tomllib
from pathlib import Path
import httpx
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from velodrome import cli
from velodrome.models import ROLE_ADMIN, ROLE_MEMBER
_PASSWORD = "correct horse battery staple"
_OTHER_PASSWORD = "an entirely different passphrase"
class _FakeTty:
"""Stands in for `sys.stdin` attached to a terminal, so `_read_password` takes the prompt
branch rather than the pipe branch. `readline` raises rather than returning a value: if the
prompt path ever silently starts reading stdin instead of calling getpass, that's a behaviour
change this should fail on, not absorb."""
def isatty(self) -> bool:
return True
def readline(self) -> str:
raise AssertionError("the prompt path must not read stdin directly")
def _pipe(password: str, *, newline: str = "\n") -> io.StringIO:
"""stdin as a pipe (isatty() is False on StringIO), carrying one line."""
return io.StringIO(f"{password}{newline}")
async def test_create_admin_creates_an_account_that_can_actually_log_in(
client: httpx.AsyncClient, db_auth: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The load-bearing test: bootstrap an admin through the CLI, then log in as them over HTTP.
This is deliberately an end-to-end assertion rather than "did a row appear with a hash in it".
The whole point of the command is to produce working credentials on a deployment where nobody
can yet log in to check, so the only assertion worth making is that the credentials work
through the same endpoint a real operator would use next.
"""
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
code = await cli.run(
["create-admin", "--email", "boss@example.com", "--name", "Boss", "--password-stdin"]
)
assert code == 0
resp = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _PASSWORD}
)
assert resp.status_code == 200, resp.text
assert "vd_session" in resp.cookies
me = await client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["email"] == "boss@example.com"
assert me.json()["display_name"] == "Boss"
async def test_create_admin_records_the_admin_role(
db_auth: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The role is the one thing that distinguishes this from registration, and nothing enforces
it yet (docs/DECISIONS.md D17) — so nothing else in the suite would notice if it silently
wrote `member`. Asserted against the stored column directly, and against ROLE_MEMBER too, so
this fails loudly rather than passing vacuously if the default ever changes."""
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
assert await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"]) == 0
role = (
await db_auth.execute(
text("SELECT role FROM users WHERE email = :email"), {"email": "boss@example.com"}
)
).scalar_one()
await db_auth.commit()
assert role == ROLE_ADMIN
assert role != ROLE_MEMBER
async def test_create_admin_defaults_display_name_to_the_email_local_part(
db_auth: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
assert await cli.run(["create-admin", "--email", "benny@example.com", "--password-stdin"]) == 0
name = (
await db_auth.execute(
text("SELECT display_name FROM users WHERE email = :email"),
{"email": "benny@example.com"},
)
).scalar_one()
await db_auth.commit()
assert name == "benny"
async def test_create_admin_refuses_an_existing_email_without_touching_the_account(
client: httpx.AsyncClient, db_auth: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Re-running the bootstrap command must not become an undocumented password reset.
"Refused" is asserted three ways, because exit code 1 alone would also be satisfied by a
command that failed *after* corrupting the row: the original password must still work, the new
one must not, and the display name must be unchanged.
"""
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
assert (
await cli.run(
["create-admin", "--email", "boss@example.com", "--name", "Boss", "--password-stdin"]
)
== 0
)
monkeypatch.setattr(sys, "stdin", _pipe(_OTHER_PASSWORD))
second = await cli.run(
["create-admin", "--email", "boss@example.com", "--name", "Impostor", "--password-stdin"]
)
assert second == 1
still_works = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _PASSWORD}
)
assert still_works.status_code == 200, "the original password must survive a refused re-run"
rejected = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _OTHER_PASSWORD}
)
assert rejected.status_code == 401, "the refused run's password must never become valid"
name = (
await db_auth.execute(
text("SELECT display_name FROM users WHERE email = :email"),
{"email": "boss@example.com"},
)
).scalar_one()
await db_auth.commit()
assert name == "Boss"
async def test_create_admin_error_message_never_echoes_the_password(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""CLAUDE.md invariant #5, in the one place it's easy to breach by accident.
Pydantic's default rendering of a ValidationError embeds the offending value — for a
too-short password that means printing the password itself to the operator's terminal, and
into whatever captured that output (a CI log, a `script` session, a scrollback buffer shared
in a bug report). `_validate` strips it deliberately; this proves it stays stripped.
"""
secret = "short"
monkeypatch.setattr(sys, "stdin", _pipe(secret))
code = await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"])
assert code == 1
captured = capsys.readouterr()
assert secret not in captured.out
assert secret not in captured.err
# ...while still being a useful message: it must name the offending field and the rule.
assert "password" in captured.err
assert "at least 8" in captured.err
async def test_create_admin_rejects_a_malformed_email(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
code = await cli.run(["create-admin", "--email", "not-an-email", "--password-stdin"])
assert code == 1
assert "email" in capsys.readouterr().err
async def test_password_stdin_rejects_an_empty_line(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""An empty pipe is nearly always `$PASSWORD` being unset in the operator's shell. Failing
loudly beats creating an account whose password is the empty string."""
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
code = await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"])
assert code == 1
assert "empty" in capsys.readouterr().err
async def test_password_stdin_preserves_a_trailing_space(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Only the line ending is stripped, not surrounding whitespace — a password with a trailing
space is legitimate, and trimming it would create an account whose password can never be typed
back in. Proven through a real login rather than by inspecting the hash."""
padded = f"{_PASSWORD} "
monkeypatch.setattr(sys, "stdin", _pipe(padded, newline="\r\n"))
assert await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"]) == 0
resp = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": padded}
)
assert resp.status_code == 200, "the trailing space must be part of the stored password"
trimmed = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _PASSWORD}
)
assert trimmed.status_code == 401
async def test_without_a_tty_or_password_stdin_it_explains_how_to_run_it(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The most likely first-run mistake is `docker exec` without `-t`. getpass would otherwise
fail with a bare OSError, so the command catches it first and prints both working forms."""
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
code = await cli.run(["create-admin", "--email", "boss@example.com"])
assert code == 1
err = capsys.readouterr().err
assert "docker exec -it" in err
assert "--password-stdin" in err
async def test_prompt_path_requires_the_confirmation_to_match(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A typo in a password nobody can see, on the one account that can't be recovered by another
admin, is worth a second prompt."""
monkeypatch.setattr(sys, "stdin", _FakeTty())
answers = iter([_PASSWORD, _OTHER_PASSWORD])
monkeypatch.setattr(cli.getpass, "getpass", lambda prompt="": next(answers))
code = await cli.run(["create-admin", "--email", "boss@example.com"])
assert code == 1
assert "did not match" in capsys.readouterr().err
async def test_prompt_path_creates_the_account_when_both_entries_match(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The interactive path is the one the docs tell operators to use, so it gets the same
end-to-end login proof as the piped path."""
monkeypatch.setattr(sys, "stdin", _FakeTty())
answers = iter([_PASSWORD, _PASSWORD])
monkeypatch.setattr(cli.getpass, "getpass", lambda prompt="": next(answers))
assert await cli.run(["create-admin", "--email", "boss@example.com"]) == 0
resp = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _PASSWORD}
)
assert resp.status_code == 200
async def test_success_output_never_contains_the_password(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The failure path is covered above; the success path prints more, so it gets its own check.
The realistic leak here is a well-meaning "created with password: ..." confirmation line."""
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
assert await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"]) == 0
captured = capsys.readouterr()
assert _PASSWORD not in captured.out
assert _PASSWORD not in captured.err
assert "boss@example.com" in captured.out
assert ROLE_ADMIN in captured.out
async def test_a_missing_required_option_exits_two_not_one(
capsys: pytest.CaptureFixture[str],
) -> None:
"""argparse's usage errors exit 2; a refusal from the command itself exits 1. A script driving
this should be able to tell "you called it wrong" from "it ran and declined"."""
with pytest.raises(SystemExit) as exc:
await cli.run(["create-admin"])
assert exc.value.code == 2
capsys.readouterr()
async def test_no_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exc:
await cli.run([])
assert exc.value.code == 2
capsys.readouterr()
def test_the_console_script_is_registered_under_the_name_the_docs_use() -> None:
"""deploy/README.md and the CLI's own error messages tell operators to run
`docker exec -it velodrome velodrome create-admin`. That only works because pyproject declares
the console script, which nothing else in the test suite would exercise — an editable install
imports `velodrome.cli` fine whether or not the entry point exists. Asserted against the
manifest so renaming the module or the function fails here rather than on a deployment.
"""
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
manifest = tomllib.loads(pyproject.read_text(encoding="utf-8"))
assert manifest["project"]["scripts"]["velodrome"] == "velodrome.cli:main"
+69 -4
View File
@@ -23,7 +23,7 @@ from velodrome.auth.security import (
) )
from velodrome.config import get_settings from velodrome.config import get_settings
from velodrome.db import auth_session from velodrome.db import auth_session
from velodrome.models import Invite, Session, User from velodrome.models import ROLE_ADMIN, Invite, Session, User
class AuthError(Exception): class AuthError(Exception):
@@ -51,6 +51,14 @@ class AuthenticatedSession:
user_id: UUID user_id: UUID
email: str email: str
display_name: str display_name: str
# Carried here so the role a user actually has is available wherever identity is — an
# authorization check on the first admin-only endpoint (realistically invite management) is
# then a comparison against a value already in hand, not another query bolted on later. This
# is data plumbing, not an authorization mechanism: nothing reads it yet, deliberately, since
# there is no admin-only endpoint to protect (docs/DECISIONS.md D18). It is not a secret and
# is not in any response model — `schemas.auth.UserOut` deliberately doesn't declare it, so
# adding it here changes no HTTP response and no OpenAPI contract.
role: str
async def register( async def register(
@@ -103,7 +111,58 @@ async def register(
invite.used_count += 1 invite.used_count += 1
return AuthenticatedSession( return AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
async def create_admin(*, email: str, password: str, display_name: str) -> AuthenticatedSession:
"""Create a user with the admin role, with no invite. Operator path only — see velodrome.cli.
This is the one deliberate hole in "open signup does not exist": a fresh deployment has no
users and therefore nobody who can issue the first invite, so the first account has to come
from outside the HTTP API. It lives here rather than in cli.py because this is where
`auth_session` belongs (see db.py's docstring — nothing outside this module imports it), and
because the lookup below is exactly the pattern that module documents as safe: an exact match
on a unique key, never a scan.
It is not reachable over HTTP and never will be — nothing in `api/` calls it. Reaching it
requires the ability to run a process inside the container, which is already the ability to
read and rewrite the SQLite file directly, so it grants an operator-turned-attacker nothing
they did not already have.
Refuses outright if the email is taken, rather than updating the row — see docs/DECISIONS.md
D18. Creating an account and resetting an existing account's password are different operations
with different blast radii, and an operator re-running a bootstrap command they last ran
months ago means the first, never the second. The check-then-insert is safe against a
concurrent `register()` for the same email the same way invite redemption is: db.py issues
`BEGIN IMMEDIATE`, so the two transactions serialize instead of interleaving, with the unique
index on `users.email` as the backstop underneath that.
"""
async with auth_session() as db:
async with db.begin():
existing = (
await db.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if existing is not None:
raise EmailAlreadyRegistered("an account with this email already exists")
user = User(
email=email,
display_name=display_name,
password_hash=hash_password(password),
role=ROLE_ADMIN,
)
db.add(user)
await db.flush() # populate user.id before we reference it below
return AuthenticatedSession(
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
) )
@@ -140,7 +199,10 @@ async def login(
db.add(session_row) db.add(session_row)
return raw_token, AuthenticatedSession( return raw_token, AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
) )
@@ -184,7 +246,10 @@ async def validate_session(raw_token: str) -> AuthenticatedSession:
) )
return AuthenticatedSession( return AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
) )
+181
View File
@@ -0,0 +1,181 @@
"""Operator CLI — `velodrome <command>`.
Installed as a console script (`[project.scripts]` in pyproject.toml) into the same venv as
`alembic` and `uvicorn`, which `deploy/entrypoint.sh` already invokes by their installed names, so
the deployed container has this on PATH with no extra wiring:
docker exec -it velodrome velodrome create-admin --email you@example.com
It exists because there is otherwise **no way to create the first user**. Registration requires a
valid invite (docs/PLAN.md "Auth": open signup does not exist as a setting), invites are created by
an existing admin, and a fresh database has neither — so a new deployment is unusable without a
path in from outside the HTTP API. docs/PLAN.md always called for this command; it was simply
never built during Phase 0. See docs/DECISIONS.md D18 for the three decisions recorded here: why
it refuses to touch an existing account, why it is *not* restricted to the very first user, and
why `role="admin"` is recorded but not yet enforced anywhere.
argparse rather than Typer/Click: one command with three options does not justify a runtime
dependency the deployed image would have to carry, and the stdlib covers this case completely.
There is deliberately **no `--password` flag** — see `_read_password`.
"""
import argparse
import asyncio
import getpass
import sys
from collections.abc import Sequence
from pydantic import BaseModel, EmailStr, ValidationError
from velodrome.auth import service
from velodrome.config import get_settings
from velodrome.schemas.auth import DisplayName, Password
class CliError(Exception):
"""An operator-facing failure: printed as `error: <message>`, exit code 1.
Distinct from argparse's own usage errors, which exit 2 — so a script driving this can tell
"you called it wrong" apart from "it ran and refused".
"""
class _CreateAdminInput(BaseModel):
"""The same constraints the HTTP register endpoint applies, reused rather than restated — a
CLI-created account must not be able to hold a password the API would have rejected.
"""
email: EmailStr
password: Password
display_name: DisplayName
def _validate(*, email: str, password: str, display_name: str) -> _CreateAdminInput:
try:
return _CreateAdminInput(email=email, password=password, display_name=display_name)
except ValidationError as exc:
# Deliberately not `str(exc)`: pydantic's rendered message embeds the offending value
# ("... [type=string_too_short, input_value='hunter2', input_type=str]"), which for the
# password field prints the password to the operator's terminal and into whatever
# captures that output. CLAUDE.md invariant #5 — only `loc` and `msg` are safe to show.
details = "; ".join(
f"{'.'.join(str(part) for part in err['loc'])}: {err['msg']}"
for err in exc.errors(include_url=False, include_input=False)
)
raise CliError(f"invalid input — {details}") from exc
def _read_password(*, from_stdin: bool) -> str:
"""Prompt for a password, or read one line from stdin.
No `--password` flag exists on purpose: an argument lands in the operator's shell history, in
`ps` output for as long as the process runs, and — because the realistic invocation here is
`docker exec` — in the Docker daemon's own record of the exec'd command. A TTY prompt and a
pipe are the two forms that avoid all three, and they're the same two forms `docker login`
offers for exactly this reason.
"""
if from_stdin:
line = sys.stdin.readline()
# Strip only the line ending, not surrounding whitespace — a trailing space in a password
# is legitimate, and silently trimming it would create a password that can never be typed
# back in correctly.
password = line.rstrip("\r\n")
if not password:
raise CliError("--password-stdin was given but the first line of stdin was empty")
return password
if not sys.stdin.isatty():
raise CliError(
"no terminal available to prompt on. Either allocate one (note the -t):\n"
" docker exec -it velodrome velodrome create-admin --email you@example.com\n"
"or pipe the password in:\n"
" printf '%s' \"$PASSWORD\" | docker exec -i velodrome \\\n"
" velodrome create-admin --email you@example.com --password-stdin"
)
password = getpass.getpass("Password: ")
if password != getpass.getpass("Confirm password: "):
raise CliError("passwords did not match")
return password
async def _create_admin(args: argparse.Namespace) -> int:
email: str = args.email
# The local part is a reasonable default for a name nobody but the operator will see until
# they change it in the UI; it keeps the common invocation to a single flag.
display_name: str = args.name if args.name is not None else email.partition("@")[0]
password = _read_password(from_stdin=args.password_stdin)
validated = _validate(email=email, password=password, display_name=display_name)
try:
created = await service.create_admin(
email=str(validated.email),
password=validated.password,
display_name=validated.display_name,
)
except service.EmailAlreadyRegistered as exc:
raise CliError(
f"an account already exists for {email} — refusing to modify it. This command only "
"ever creates a new account; it will not reset an existing one's password (see "
"docs/DECISIONS.md D18). To add a different admin, re-run with another --email."
) from exc
print("Created admin user:")
print(f" id {created.user_id}")
print(f" email {created.email}")
print(f" display name {created.display_name}")
print(f" role {created.role}")
print()
print(f"Log in at {get_settings().public_url}")
print(
"Note: the admin role is recorded on the account but nothing enforces it yet — no "
"admin-only endpoint exists (docs/DECISIONS.md D18)."
)
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="velodrome",
description="Velodrome operator commands. Run inside the container, e.g. "
"`docker exec -it velodrome velodrome create-admin --email you@example.com`.",
)
subcommands = parser.add_subparsers(dest="command", required=True)
create_admin = subcommands.add_parser(
"create-admin",
help="create a user with the admin role, bypassing the invite requirement",
description="Create a user with the admin role, bypassing the invite requirement. This is "
"how the first account on a fresh deployment is made — registration needs an invite, and "
"a fresh database has none. Refuses to modify an account that already exists.",
)
create_admin.add_argument("--email", required=True, help="the account's email address")
create_admin.add_argument(
"--name",
default=None,
help="display name (default: the part of the email address before the @)",
)
create_admin.add_argument(
"--password-stdin",
action="store_true",
help="read the password from the first line of stdin instead of prompting for it",
)
return parser
async def run(argv: Sequence[str] | None = None) -> int:
"""The async entrypoint. `main` wraps this in `asyncio.run`; tests call it directly."""
args = _build_parser().parse_args(argv)
try:
if args.command == "create-admin":
return await _create_admin(args)
except CliError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
raise AssertionError(f"unhandled command {args.command!r}") # pragma: no cover
def main(argv: Sequence[str] | None = None) -> int:
return asyncio.run(run(argv))
+2 -2
View File
@@ -1,4 +1,4 @@
from velodrome.models.base import Base from velodrome.models.base import Base
from velodrome.models.identity import ApiToken, Invite, Session, User from velodrome.models.identity import ROLE_ADMIN, ROLE_MEMBER, ApiToken, Invite, Session, User
__all__ = ["ApiToken", "Base", "Invite", "Session", "User"] __all__ = ["ROLE_ADMIN", "ROLE_MEMBER", "ApiToken", "Base", "Invite", "Session", "User"]
+12 -2
View File
@@ -24,6 +24,16 @@ def _now_utc() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
# The only two values `users.role` and `invites.role` are ever set to. Named constants so the set
# is discoverable from one place: `velodrome.cli`'s create-admin writes ROLE_ADMIN, and
# registration copies whatever role the redeemed invite carries. Nothing *enforces* a role yet —
# no admin-only endpoint exists (docs/DECISIONS.md D18) — and the column stays a plain string
# rather than a DB-level enum or CHECK constraint so adding a third role later is an application
# change, not a migration.
ROLE_MEMBER = "member"
ROLE_ADMIN = "admin"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -31,7 +41,7 @@ class User(Base):
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False) email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(200), nullable=False) display_name: Mapped[str] = mapped_column(String(200), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") role: Mapped[str] = mapped_column(String(20), nullable=False, default=ROLE_MEMBER)
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC") timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
# Display-only, per CLAUDE.md invariant #3 — storage is always SI, this never touches a query. # Display-only, per CLAUDE.md invariant #3 — storage is always SI, this never touches a query.
unit_system: Mapped[str] = mapped_column(String(10), nullable=False, default="imperial") unit_system: Mapped[str] = mapped_column(String(10), nullable=False, default="imperial")
@@ -53,7 +63,7 @@ class Invite(Base):
Uuid(as_uuid=True), ForeignKey("users.id"), nullable=False Uuid(as_uuid=True), ForeignKey("users.id"), nullable=False
) )
email: Mapped[str | None] = mapped_column(String(320), nullable=True) email: Mapped[str | None] = mapped_column(String(320), nullable=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") role: Mapped[str] = mapped_column(String(20), nullable=False, default=ROLE_MEMBER)
expires_at: Mapped[datetime] = mapped_column(nullable=False) expires_at: Mapped[datetime] = mapped_column(nullable=False)
max_uses: Mapped[int] = mapped_column(nullable=False, default=1) max_uses: Mapped[int] = mapped_column(nullable=False, default=1)
used_count: Mapped[int] = mapped_column(nullable=False, default=0) used_count: Mapped[int] = mapped_column(nullable=False, default=0)
+10 -2
View File
@@ -6,15 +6,23 @@ simply not being listed here is what keeps password_hash/token_hash out of every
adding a new field, ask whether it belongs in a response before adding it, not after. adding a new field, ask whether it belongs in a response before adding it, not after.
""" """
from typing import Annotated
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
# Named aliases rather than inline constraints, because these two rules are also applied outside
# the HTTP layer: `velodrome.cli` validates `create-admin`'s input against exactly the same ones,
# so an account created from the CLI can't hold a password the register endpoint would have
# rejected. Defined once here so the two can't drift apart.
Password = Annotated[str, Field(min_length=8, max_length=200)]
DisplayName = Annotated[str, Field(min_length=1, max_length=200)]
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(min_length=8, max_length=200) password: Password
display_name: str = Field(min_length=1, max_length=200) display_name: DisplayName
invite_code: str = Field(min_length=1, max_length=200) invite_code: str = Field(min_length=1, max_length=200)
-36
View File
@@ -1,36 +0,0 @@
# This image's ONLY purpose is to produce /app/build — the static SPA output
# (HTML/CSS/JS/manifest/service worker). There is no Node runtime in
# production: per docs/PLAN.md's "Stack" and "The PWA decision", Caddy serves
# these files directly and proxies /api/* to the FastAPI backend. This image
# is never run as a long-lived container in production.
#
# Build context: this directory (apps/web), e.g. `docker build -f
# apps/web/Dockerfile apps/web`.
#
# How deploy/ is expected to consume this: build this image, then copy its
# /app/build contents out into a location the `caddy` service in
# deploy/docker-compose.yml bind-mounts — either via a multi-stage
# `COPY --from=bennybergle/velodrome-web:<tag> /app/build /srv/web` in the
# Caddy image build, or with a one-shot `docker create` + `docker cp` /
# `docker run --rm -v ...` step in the deploy pipeline that dumps /app/build
# into a named volume or bind-mounted host directory before `caddy` starts.
# Picked this over a scratch/busybox "artifact-holder" final stage because a
# single builder stage is simpler to reason about and there's nothing here
# that needs to run — the consumer only ever needs the files, not a container.
# The deploy/ agent may adjust this to whatever's simplest for the compose
# setup; this is just the contract (build → /app/build).
FROM node:22-slim AS builder
WORKDIR /app
# Install dependencies first, isolated from source changes, for layer caching.
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@9 --activate \
&& pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
# Nothing further: /app/build is the artifact. No CMD/ENTRYPOINT — this image
# is not meant to be run.
+7 -19
View File
@@ -77,23 +77,11 @@ false`, and `vite.config.ts` configures `@sveltejs/adapter-static` with `fallbac
- **Styling.** Plain CSS (`src/app.css`), CSS custom properties for theming, `prefers-color-scheme` - **Styling.** Plain CSS (`src/app.css`), CSS custom properties for theming, `prefers-color-scheme`
for dark mode. No Tailwind — kept the dependency surface small for this scaffolding phase. for dark mode. No Tailwind — kept the dependency surface small for this scaffolding phase.
## Dockerfile ## Docker build
This image's **only** purpose is to produce `/app/build` (the static SPA output) as a buildable There is no `apps/web/Dockerfile` anymore. Per D15/D16 (`docs/DECISIONS.md`), the whole app ships
artifact — see the comment block at the top of `Dockerfile` for the full rationale. There is no as one container, so building this SPA is a stage in the root `Dockerfile`
Node runtime in production; Caddy serves the built files directly and proxies `/api/*` to the API (`web-builder`, `apps/web` as its `COPY` source), not a standalone image — the built output
container (`docs/PLAN.md`, "Service topology"). This image is never run as a long-lived container. (`/app/build`) is copied straight into the runtime stage at `/srv/web`, which is what the root
`Dockerfile`'s Caddy config (`deploy/Caddyfile`) serves. There is no Node runtime in production.
Build it with the `apps/web` directory as context: See `deploy/README.md` for the actual single-container build/run instructions.
```sh
docker build -f apps/web/Dockerfile -t velodrome-web-build apps/web
```
How `deploy/` is expected to consume it (my assumption — the `deploy/` work is happening in a
parallel worktree, so this may get adjusted there): a multi-stage `COPY --from=velodrome-web-build
/app/build /srv/web` in whatever image serves Caddy, or a one-shot `docker create` +
`docker cp` / bind-mount step in the deploy pipeline that populates the volume Caddy reads from
before it starts. Went with a single plain builder stage (no `scratch`/`busybox` artifact-holder
final stage) because nothing here needs to _run_ — the only thing anyone needs from this image is
the files in `/app/build`, and a second stage would add complexity without adding anything.
+31
View File
@@ -0,0 +1,31 @@
# Single-container Caddy config (docs/DECISIONS.md D15). Serves the static SvelteKit build and
# reverse-proxies /api/* to uvicorn on loopback — the same split apps/web/vite.config.ts's dev
# proxy describes, just as Caddy directives instead of Vite's dev-server proxy.
{
admin off
# Explicit, not just implied by using a bare port below: this container is never the TLS
# terminator (docs/PLAN.md "Service topology" — the host's existing reverse proxy is), so
# Caddy must never attempt to provision a certificate for whatever it's fronted by.
auto_https off
}
:8080 {
encode gzip
log {
output stdout
}
handle /api/* {
reverse_proxy 127.0.0.1:8000
}
# SPA fallback matching apps/web/vite.config.ts's `adapter({ fallback: 'index.html' })`:
# any path that isn't a real built file resolves to index.html so client-side routing works
# on a hard refresh / direct link.
handle {
root * /srv/web
try_files {path} /index.html
file_server
}
}
+137 -1
View File
@@ -1 +1,137 @@
docker-compose, Caddyfile, systemd backup units. Not yet written — Phase 0. # deploy/
Everything needed to run Velodrome as **one container**. See `docs/DECISIONS.md` D15 (why SQLite
and one container) and D16 (why migrations run from the entrypoint, and the Caddy/tini setup)
before changing anything here.
```
../Dockerfile Multi-stage build: web SPA + API venv + Caddy, into one runtime image
Caddyfile Serves the static SPA, proxies /api/* to uvicorn on loopback
entrypoint.sh Runs migrations, then supervises uvicorn + Caddy as PID 1's children
unraid-template.xml Unraid Community Applications template — turns the env vars below into
fillable web UI fields instead of a .env file
```
There is no docker-compose here, deliberately — the app is one container, not a set of services
that need orchestrating together. (Later phases may add genuinely separate containers — a
tileserver, a local LLM — see `docs/PLAN.md`'s "Service topology"; those would get their own
compose file or Unraid templates when a phase actually needs one, not speculatively now.)
## Build
From the repo root (the build context — the Dockerfile needs both `apps/api` and `apps/web`):
```sh
docker build -t velodrome .
```
## Run
```sh
docker run -d \
--name velodrome \
-p 8080:8080 \
-v velodrome-data:/data \
-e VELODROME_PUBLIC_URL=https://bikes.example.com \
-e VELODROME_SECRET_KEY=$(openssl rand -hex 32) \
-e VELODROME_ENVIRONMENT=production \
velodrome
```
Put a TLS-terminating reverse proxy (whatever's already fronting other services on the host) in
front of port 8080 — this container only ever serves plain HTTP itself.
`GET http://<host>:8080/api/v1/healthz` should return `{"status": "ok"}` once it's up.
## Create the first admin user
**A fresh deployment has no users and you cannot sign up for one.** Registration requires an invite
code, invites are issued by an existing admin, and a new database has neither — so the first account
is created from inside the container (`docs/DECISIONS.md` D18 for why it's a CLI and not a
first-run web page):
```sh
docker exec -it velodrome velodrome create-admin --email you@example.com
```
That prompts for the password twice and prints the new account's id, email and role. Then log in at
`VELODROME_PUBLIC_URL`. Note the **`-t`** — without a TTY there's nothing to prompt on; the command
says so rather than hanging. Add `--name "Your Name"` to set a display name (it defaults to the part
of the email before the `@`); it's editable in the UI later either way.
For a non-interactive run (a provisioning script), pipe the password in instead — note `-i` rather
than `-it`:
```sh
printf '%s' "$ADMIN_PASSWORD" | docker exec -i velodrome \
velodrome create-admin --email you@example.com --password-stdin
```
There is deliberately no `--password` flag: an argument would land in your shell history, in `ps`
output, and in the Docker daemon's record of the exec'd command.
Re-run it with a different `--email` to add another admin. Re-running it with an email that already
exists **refuses and changes nothing** — it is not a password-reset tool, and there isn't one yet
(D18). Minimum password length is 8 characters, the same rule the register endpoint applies.
Nothing enforces the admin role yet — no admin-only endpoint exists — so today this differs from an
invited account only in the role recorded on it. Invite management in a later phase is what starts
reading it.
## Environment variables
All read by `apps/api/velodrome/config.py` (prefix `VELODROME_`) — the app and Alembic both read
the same values, there's no separate migration-time config anymore (docs/DECISIONS.md D15).
| Variable | Required | Default (baked into the image) | Notes |
|---|---|---|---|
| `VELODROME_DATABASE_URL` | No — don't override | `sqlite+aiosqlite:////data/velodrome.db` | Fixed to the `/data` volume mount. Change the volume mapping, not this. |
| `VELODROME_PUBLIC_URL` | **Yes** | none | The externally-visible URL. Checked against `Origin` on cookie-authenticated mutations — get this wrong and every logged-in write silently 403s. |
| `VELODROME_SECRET_KEY` | **Yes** | insecure dev placeholder | `openssl rand -hex 32`. Not yet used for anything reachable (arrives with Bryton credential encryption in a later phase) — set a real value now anyway. |
| `VELODROME_ENVIRONMENT` | **Yes** | `development` | `development` \| `test` \| `production`. Gates the session cookie's `Secure` flag — always `production` behind real HTTPS. |
| `VELODROME_SESSION_TTL_DAYS` | No | `90` | Login session lifetime. |
| `VELODROME_SESSION_COOKIE_NAME` | No | `vd_session` | Only matters if it collides with another app on the same domain. |
## Volumes
| Path | Contents |
|---|---|
| `/data` | The SQLite database file. Will also hold the content-addressed blob store once Phase 1 builds ingestion. This is the only thing that needs backing up. |
The container runs as a fixed non-root user (uid/gid `999`), not root and not Unraid's usual
`nobody:users` (99:100). If `/data`'s host directory doesn't already exist, Docker/Unraid creates
it owned by `nobody:users` with no write access for other users — the container starts, but
uvicorn fails immediately with `sqlite3.OperationalError: unable to open database file`, since it
can't create the SQLite file inside a directory it can't write to. Fix once, before first start:
`chown -R 999:999 <host path>` (e.g. `/mnt/user/appdata/velodrome` on Unraid).
## Unraid
Import `unraid-template.xml` from the Docker tab's "Add Container" template picker — it exposes
the Port, Data path, and the env vars above as fillable web UI fields, matching the earlier
decision to keep configuration in Unraid's own UI rather than a `.env` file on disk. Every field
stays editable by hand afterward regardless of what the template pre-fills.
## Publishing the image
`.gitea/workflows/release.yml` builds this Dockerfile and pushes it to the Gitea container
registry at `registry.bbergle.com:9537/bbergle/bike-app` on every push to `main` (tagged
`main-<short-sha>`, and `latest`), on a `v*` tag push (tagged with the tag name, and `latest`), or
on manual `workflow_dispatch` (tagged `manual-<timestamp>-<short-sha>` only — a manual dispatch
never moves `latest`, so testing a feature branch can't clobber what's actually deployable). Not
`192.168.0.3:3000` (Gitea's own plain-HTTP address) directly — Docker
refuses any non-localhost registry over plain HTTP by default, so `registry.bbergle.com:9537` is
an NPMplus proxy host in front of Gitea's registry that terminates TLS with a self-signed cert.
See `docs/DECISIONS.md` D17 for the full setup (cert, NPMplus proxy host, `certs.d` trust, and the
buildx driver change this required) — none of it is committed here, since it's host-local trust
material and NPMplus config, not something this repo can or should own.
It does **not** SSH into the host and recreate the running container — rolling out a new image on
Unraid (pulling it and clicking "Apply" on the container, or via Unraid's own update-checking) is
left as a manual/Unraid-side step, not something CI does unattended.
## What's not here yet
Backups (`docs/PLAN.md` calls for a systemd timer running `restic` against `/data`, independent of
CI) and the `import_inbox` USB-watch bind mount are both Phase 1+ concerns — nothing in the schema
uses them yet.
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# Single-container entrypoint (docs/DECISIONS.md D16). Two responsibilities:
#
# 1. Run migrations before serving anything. There's no separate "run migrations, then start the
# app" step in front of this container the way docs/PLAN.md's original deploy.yml had one for
# the 3-container Postgres stack (see apps/api/Dockerfile's history) — a single container has
# nowhere else to put that step. `set -e` means a failed migration exits non-zero here, which
# still fails startup visibly (Docker/Unraid shows the container as exited/restarting) instead
# of silently serving a broken app; that's the property the separate step existed to protect,
# preserved by a different mechanism now that there's only one container to do it in.
# 2. Run uvicorn and Caddy as two background processes and tie their lifetimes together: if either
# one dies, kill the other and exit with its status, so Docker/Unraid restarts the whole
# container. Two half-alive processes (API up, web serving stale/nothing, or vice versa) is a
# worse failure mode than a clean restart.
set -euo pipefail
alembic -c /app/alembic.ini upgrade head
# uvicorn binds loopback only — Caddy is the sole process with an exposed port, and the sole
# thing that talks to uvicorn (see deploy/Caddyfile's reverse_proxy target).
uvicorn velodrome.app:app --host 127.0.0.1 --port 8000 &
API_PID=$!
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
CADDY_PID=$!
trap 'kill -TERM "$API_PID" "$CADDY_PID" 2>/dev/null || true' TERM INT
wait -n "$API_PID" "$CADDY_PID"
EXIT_CODE=$?
kill -TERM "$API_PID" "$CADDY_PID" 2>/dev/null || true
exit "$EXIT_CODE"
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0"?>
<!--
Unraid Docker "Template" for Community Applications' Add-Container form. This is what turns the
env vars in the table below into fillable fields in the Unraid web UI instead of a .env file —
see docs/DECISIONS.md D16 for why this exists.
Import: Docker tab -> Add Container -> Template drop-down -> pick this file (or paste the
"Template" URL if this repo is served over http from the Gitea instance). Every field is also
editable by hand afterward; nothing here is load-bearing beyond being a starting point.
-->
<Container version="2">
<Name>velodrome</Name>
<Repository>registry.bbergle.com:9537/bbergle/bike-app:latest</Repository>
<Registry>http://192.168.0.3:3000/BBergle/-/packages/container/bike-app</Registry>
<Network>bridge</Network>
<Privileged>false</Privileged>
<Support>https://192.168.0.3:3000/BBergle/bike-app/issues</Support>
<Project>http://192.168.0.3:3000/BBergle/bike-app</Project>
<Overview>Self-hosted cycling app: Bryton Rider 650 ride sync, mileage tracking, spare-parts inventory, and maintenance reminders. One container: Caddy + the FastAPI app + a SQLite database file on the Data path below. See docs/PLAN.md and docs/DECISIONS.md (D15/D16/D17) in the repo for the design.</Overview>
<Category>Productivity:</Category>
<WebUI>http://[IP]:[PORT:8090]/</WebUI>
<Icon/>
<ExtraParams/>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Description>Self-hosted cycling app: Bryton ride sync, mileage tracking, spare-parts inventory, maintenance reminders.</Description>
<Config Name="Web UI Port" Target="8080" Default="8090" Mode="tcp" Description="Host port mapped to the container's internal 8080. Defaulted off 8080 since that's already taken by qBittorrent on this host — check for a free port before changing it. Put a reverse proxy with TLS in front of this — the container itself only ever serves plain HTTP (docs/PLAN.md 'Service topology')." Type="Port" Display="always" Required="true" Mask="false">8090</Config>
<Config Name="Data" Target="/data" Default="/mnt/user/appdata/velodrome" Mode="rw" Description="The SQLite database file (and, in a later phase, the raw-file blob store) live here. This is the only thing worth backing up." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/velodrome</Config>
<Config Name="VELODROME_PUBLIC_URL" Target="VELODROME_PUBLIC_URL" Default="" Mode="" Description="The externally-visible URL this instance is reachable at, e.g. https://bikes.example.com. Must match exactly what's in the browser's address bar — it's checked against the Origin header on cookie-authenticated requests to stop cross-site request forgery." Type="Variable" Display="always" Required="true" Mask="false"></Config>
<Config Name="VELODROME_SECRET_KEY" Target="VELODROME_SECRET_KEY" Default="" Mode="" Description="A random secret, 32+ bytes. Generate one with: openssl rand -hex 32. The image ships an insecure development placeholder — always override this before exposing the container to anything." Type="Variable" Display="always" Required="true" Mask="true"></Config>
<Config Name="VELODROME_ENVIRONMENT" Target="VELODROME_ENVIRONMENT" Default="production" Mode="" Description="development | test | production. Gates the session cookie's Secure flag — leave this as production for any deployment reachable over HTTPS." Type="Variable" Display="always" Required="true" Mask="false">production</Config>
<Config Name="VELODROME_SESSION_TTL_DAYS" Target="VELODROME_SESSION_TTL_DAYS" Default="90" Mode="" Description="Days before a login session expires and re-authentication is required." Type="Variable" Display="advanced" Required="false" Mask="false">90</Config>
<Config Name="VELODROME_SESSION_COOKIE_NAME" Target="VELODROME_SESSION_COOKIE_NAME" Default="vd_session" Mode="" Description="Name of the session cookie. No reason to change this unless it collides with another app on the same domain." Type="Variable" Display="advanced" Required="false" Mask="false">vd_session</Config>
</Container>
+175
View File
@@ -181,6 +181,181 @@ containment), #6 (single ingestion path) — none of those were ever Postgres-sp
(D6, opaque bearer tokens) is unaffected. FastAPI/SQLAlchemy/Alembic stay exactly as chosen in D4; (D6, opaque bearer tokens) is unaffected. FastAPI/SQLAlchemy/Alembic stay exactly as chosen in D4;
only the database engine underneath them changed. only the database engine underneath them changed.
### D16 — Single-container packaging: entrypoint migrations, tini + a two-line supervisor, Caddy binary copy, Unraid template
**Chosen:** one Docker image (root `Dockerfile`), built by copying the SvelteKit static build and
the API's venv into a runtime stage alongside a copied-out `caddy` binary. `deploy/entrypoint.sh`
runs `alembic upgrade head`, then starts uvicorn (loopback-only) and Caddy as two background
processes under `tini` as PID 1, and kills+exits if either one dies. Config surfaces as env vars
read by the existing `VELODROME_`-prefixed Pydantic settings; `deploy/unraid-template.xml` exposes
the required ones as Unraid Community Applications web UI fields instead of a `.env` file.
**Why not a real process manager (s6-overlay, supervisord):** two long-running processes with no
dependency graph between them (Caddy doesn't need to wait on uvicorn — it just proxies) doesn't
need a supervisor with restart policies, readiness ordering, or log multiplexing. A ~20-line bash
script under `tini` (for correct signal forwarding and zombie reaping, which a bare shell script as
PID 1 doesn't do) gets the one property that matters — if either process dies, the whole container
exits non-zero so Docker/Unraid restarts it — without a new dependency or a config format to learn.
Revisit if a third long-running process gets added later; two is the reasonable ceiling for "just
write the script."
**Why migrations run from the entrypoint, contradicting what apps/api/Dockerfile's own comment
used to say** ("Migrations run as an explicit step before this in deploy.yml... never from the
entrypoint, so a failed migration fails the deploy visibly instead of crash-looping here"): that
comment described the 3-container Postgres plan, where a separate `run --rm api alembic upgrade
head` step existed *before* `compose up -d`. A single container has nowhere else to put that step.
The property it was protecting — a failed migration must be visible, not silently served — still
holds: `set -e` means the script exits non-zero on migration failure, so the container never starts
serving traffic and shows as exited/restarting in `docker ps`/Unraid, which is the same visibility
by a different mechanism. What's genuinely lost is the *old* mechanism's failure mode of "the
previous version keeps running while the bad migration is investigated" — a single container that
fails to start migrations has no previous version still up. Acceptable for a single-instance
home-lab deployment; would need reconsidering (e.g. a blue/green swap) if this ever needed
zero-downtime deploys.
**Why the Caddy binary is copied from `caddy:2` rather than using a Caddy base image:** the runtime
needs both Python (for uvicorn) and Caddy; picking either official base image as the starting
point means installing the other stack into it by hand. Caddy's official images are a single
statically-linked Go binary with no CGO, so `COPY --from=caddy:2 /usr/bin/caddy /usr/bin/caddy`
into a `python:3.12-slim` base is the documented, standard way to get both without a second
package manager or a source build.
**Why an Unraid template file, not just documentation:** the earlier decision (in-session) was to
move configuration out of a `.env` file and into fields the Unraid web UI can fill in — a plain env
var table in a README doesn't do that by itself, since Unraid still needs a `Config`-tagged XML
entry per field to render one. `deploy/unraid-template.xml` is that; every field stays hand-editable
in the UI afterward regardless of what the template pre-fills, so getting a default slightly wrong
here isn't load-bearing.
**What this doesn't do:** `.gitea/workflows/release.yml` builds and pushes the image to the Gitea
registry; it does not SSH into the Unraid host and recreate the running container. Rolling a new
image out is a manual/Unraid-side action (pull + Apply, or Unraid's own update check), not
something CI does unattended — consistent with treating "affects a shared, already-running system"
as something a human triggers, not automation.
### D17 — Registry TLS: self-signed cert behind NPMplus, not `insecure-registries`, not a real domain
**Problem:** `release.yml`'s first real run failed — `docker/login-action` against
`192.168.0.3:3000` (Gitea's plain-HTTP address) hit `server gave HTTP response to HTTPS client`.
Docker refuses TLS-less registries by default; this was never a workflow misconfiguration, it's
expected Docker behaviour for any non-localhost registry.
**Rejected: `insecure-registries` in `daemon.json`.** The obvious fix. Rejected after actually
reading `/etc/rc.d/rc.docker` on the Unraid host rather than assuming: applying a `daemon.json`
change requires a full `dockerd` restart, and (with `Live Restore` disabled on this host) both
Unraid's own restart path *and* a raw `kill` of `dockerd` stop every one of the ~40 other
containers running on the box first, as part of the restart/shutdown sequence — Plex, Home
Assistant, Vaultwarden, everything. Correct fix for the narrow problem, unacceptable blast radius
for this specific host.
**Rejected: a real Let's Encrypt cert on a new `bbergle.com` subdomain routed publicly.** The
user's other NPMplus-fronted subdomains resolve through Cloudflare's proxy (orange-cloud), not
directly to the home IP. A Cloudflare-proxied hostname would have terminated TLS at Cloudflare's
edge with Cloudflare's own cert, never reaching our self-signed cert or NPMplus's own TLS
config at all — the entire trust chain would depend on Cloudflare's origin SSL mode, and likely on
firewall rules restricting port 443 to Cloudflare's IP ranges, neither of which this problem
needed to involve.
**Chosen:** a small, fully self-contained fix, scoped to touch nothing already working:
- A 10-year self-signed cert for `registry.bbergle.com` (SAN-only, no real domain dependency).
- An NPMplus proxy host (`registry.bbergle.com` -> `192.168.0.3:3000` over plain HTTP internally)
terminating TLS with that cert, on NPMplus's existing HTTPS port (`9537` on this host — found by
reading `docker port NPMplus` rather than assuming 443, which is a *different* nginx process on
this box entirely).
- `/etc/hosts` on the Unraid host mapping `registry.bbergle.com` -> `192.168.0.103` (itself) —
chosen over a real DNS record specifically because the only client that ever needs to resolve
this hostname is the Unraid host's own `dockerd` (Gitea Actions runs in DooD mode against that
same host's Docker socket). This sidesteps Cloudflare, the router's NAT/hairpin behaviour, and
any port-forwarding question entirely — verified separately that hairpin NAT works by default on
this user's UniFi gateway, but it turned out to be unnecessary for this fix regardless.
- `/etc/docker/certs.d/registry.bbergle.com:9537/ca.crt` on the Unraid host, trusting that cert for
that host:port specifically. Confirmed (Docker's own docs) that `certs.d` is read per-connection,
not baked in at daemon start — no `dockerd` restart, no impact on any other container.
- `docker/setup-buildx-action@v3` pinned to `driver: docker` in `release.yml` instead of its
default `docker-container` driver — the default runs BuildKit in an isolated builder container
that does not see the host's `/etc/docker/certs.d`, which would have silently defeated the whole
point of the trust setup above. We don't build multi-platform images, so nothing the
`docker-container` driver offers is actually needed here.
**Not persisted across a reboot, deliberately, for now:** neither the `/etc/hosts` line nor the
`certs.d` file are wired into `/boot/config/go` — both live under `/`, which Unraid rebuilds fresh
from `/boot` on every boot. Raised explicitly rather than assumed: the user was (rightly) wary of
hand-editing anything under `/boot` after an earlier, unrelated discussion of what a broken `go`
script could do to boot. Persisting this is a five-minute follow-up (append two lines to `go`) once
they're ready to make that call deliberately, not bundled into this fix.
**What's unaffected:** Gitea's own web UI, git remote, and API — all still plain
`http://192.168.0.3:3000`, exactly as CLAUDE.md documents. NPMplus's existing public proxy hosts
and certs (`vaultwarden.bbergle.com` etc.) — untouched, new proxy host only. No other container on
the Unraid host was restarted, reconfigured, or otherwise touched to make this work.
### D18 — Admin bootstrap is a CLI command, not an HTTP endpoint or a first-run mode
**Chosen:** `velodrome create-admin`, a console script (`[project.scripts]` in
`apps/api/pyproject.toml``velodrome.cli:main`) installed into the same venv as `alembic` and
`uvicorn`, so the deployed image already has it on PATH:
```sh
docker exec -it velodrome velodrome create-admin --email you@example.com
```
**Why this exists at all:** registration requires a valid invite code (`docs/PLAN.md` "Auth" — open
signup does not exist, not even as a setting), invites can only be created by an existing admin,
and a freshly migrated database has neither. A new deployment was therefore unusable: there was no
way to create the first account. `docs/PLAN.md` always called for this command; it was simply never
built during Phase 0, and the gap only became visible once D16 made a real deployment possible.
**Why a CLI rather than the alternatives:**
- *A bootstrap HTTP endpoint that works only while the users table is empty* — rejected. It puts an
unauthenticated account-creating route on the public internet permanently, whose safety depends
entirely on a row count staying zero. The window is real (between first start and first login),
it's the exact window where the deployment is least watched, and the failure is silent: whoever
wins the race owns the instance.
- *An env var like `VELODROME_INITIAL_ADMIN_PASSWORD`* — rejected. A password in an env var is
visible in `docker inspect`, in the Unraid template's saved config on disk, and in the container's
own `/proc/1/environ` for the process's whole life. D16 deliberately moved configuration into
Unraid's UI, which would mean the bootstrap password sitting in that UI indefinitely.
- *Seeding a default account in a migration* — rejected outright. It would mean a known-credential
account existing on every deployment, and it contradicts the reason migrations are schema-only.
**Why it refuses an email that already exists, rather than updating it:** creating an account and
resetting an existing account's password are different operations with different blast radii, and
the realistic scenario — an operator re-running a command they last ran months ago, from shell
history — means the first, never the second. Silently accepting it would make this an undocumented
password-reset tool that any container-exec grants, and would make the command's behaviour depend
on state the operator can't see. It exits 1 and says what it refused. A genuine password reset is a
separate future command that should have to say so in its name.
**Why it is *not* restricted to "only when there are zero users":** that restriction sounds safer
and isn't. It buys nothing — the command already requires the ability to run a process inside the
container, which is already the ability to read and rewrite the SQLite file directly, so a
restriction only constrains the legitimate operator, never an attacker who is by definition already
past it. Meanwhile it removes the two cases that actually happen: a second admin for a family
member, and recovering an instance whose only admin account was lost. The invite system remains the
normal path for adding users; this stays the operator's escape hatch.
**Why `role="admin"` is recorded but nothing enforces it yet:** there is no admin-only endpoint to
protect. Invite management — the first thing that genuinely needs the distinction — is Phase 1.
Writing the column now means the first account is correctly marked when that check does arrive,
rather than needing a data fix-up later; writing an *enforcement* mechanism now would be guessing at
the shape of a check with no caller. `ROLE_ADMIN`/`ROLE_MEMBER` are named constants in
`models/identity.py`, and the column stays a plain string rather than a DB enum or CHECK constraint
so a third role later is an application change, not a migration. `AuthenticatedSession.role` carries
the value for that future check; it is deliberately absent from `schemas.auth.UserOut`, so this
changes no HTTP response and no OpenAPI contract.
**Why there is no `--password` flag:** an argument lands in shell history, in `ps` output for the
process's lifetime, and — because the realistic invocation is `docker exec` — in the Docker daemon's
record of the exec'd command. A TTY prompt (with confirmation) and `--password-stdin` are the two
forms that avoid all three, which is the same pair `docker login` offers for the same reason.
Pydantic's `ValidationError` rendering is also deliberately not printed verbatim: it embeds the
offending value, which for a too-short password prints the password to the terminal. Only `loc` and
`msg` are shown (CLAUDE.md invariant #5); `tests/test_cli.py` asserts this on both the failure and
success paths.
**argparse, not Typer/Click:** one command with three options doesn't justify a runtime dependency
the deployed image has to carry.
--- ---
## Deliberately deferred ## Deliberately deferred