A fresh deployment could not be used at all. 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. Now that D16/D17 made a real deployment possible, the first thing a working container gives you is a login page nobody can get past.
This adds velodrome create-admin, a console script ([project.scripts] → velodrome.cli:main) that installs into the same venv as alembic and uvicorn, so the deployed image already has it on PATH:
docs/PLAN.md always called for this command; it was simply never built during Phase 0.
The account-creating logic lives in auth/service.py as create_admin(), not in cli.py, so db.auth_session stays confined to that module as its docstring requires. Its lookup is an exact match on a unique key — the pattern db.py documents as safe on that session.
Deliberate constraints, each with a test (reasoning in 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". Accepting it would make this an undocumented password-reset tool that any container-exec grants.
Not restricted to "only when there are zero users." That restriction buys nothing — reaching the command already requires running a process inside the container, which already permits rewriting the SQLite file directly — while removing the two cases that do happen: adding 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 — the same pair docker login offers for the same reason.
argparse, not Typer/Click — one command with three options doesn't justify a runtime dependency the image has to carry.
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 so that future check has it in hand. It is deliberately absent from UserOut, so no HTTP response and no OpenAPI contract changes.
RegisterRequest's password and display-name constraints move to named aliases (Password, DisplayName) in schemas/auth.py, so the CLI applies exactly the same rules rather than a copy that drifts.
How this was verified
CI is green
Tests added or updated for the behaviour that changed
Verified manually (describe how):
Tests — apps/api/tests/test_cli.py, 15 new tests, real SQLite via the existing conftest (real Alembic migrations, real Argon2id, no mocks). Full suite: 28 passed. The ones that carry weight:
test_create_admin_creates_an_account_that_can_actually_log_in — deliberately not "a row appeared with a hash in it": it bootstraps an admin through the CLI and then logs in as them over HTTP. This command runs once, by a human with no way to debug it, and "printed success but the password doesn't work" is indistinguishable from a broken deployment.
test_create_admin_refuses_an_existing_email_without_touching_the_account — asserts refusal three ways (original password still works, new one rejected, display name unchanged), because exit code 1 alone would also be satisfied by a command that failed after corrupting the row.
test_create_admin_error_message_never_echoes_the_password — pydantic's default ValidationError rendering embeds the offending value, i.e. it prints the password. _validate shows only loc/msg; this pins that. test_success_output_never_contains_the_password covers the other path.
test_create_admin_records_the_admin_role — asserts against the stored column and against ROLE_MEMBER, so it can't pass vacuously if the default changes. Nothing else in the suite would notice a silent member.
test_password_stdin_preserves_a_trailing_space — proven through a real login, not by inspecting a hash.
test_the_console_script_is_registered_under_the_name_the_docs_use — an editable install imports velodrome.cli fine whether or not the entry point exists, so nothing else would catch the README's command being wrong.
Gates, all run from apps/api/ after the rebase:ruff check . clean · ruff format --check . clean (29 files) · mypy --strict velodrome clean (20 files) · pytest -q 28 passed · alembic check reports no model drift.
Manual, against a scratch SQLite database (migrated with alembic upgrade head, then driving the real entry point):
Creating an admin printed the id/email/display name/role and the login URL — and not the password.
Re-running with the same email: error: an account already exists for you@example.com — refusing to modify it…, exit 1.
Running without a TTY and without --password-stdin: the message naming both working forms (docker exec -it … and the piped form), exit 1 — rather than a bare OSError from getpass.
Not verified: this has not been run inside the actual built image on the Unraid host — only against a local venv and scratch database. The entry point's presence is asserted from pyproject.toml, and PATH already covers alembic/uvicorn by the same mechanism (deploy/entrypoint.sh invokes both by installed script name), but the first real docker exec is unproven. Worth running once after the next image build.
Invariants
No secret can reach a response model, log line, or error message
#5 is the one this change is most exposed to, in two places, both tested: the pydantic error rendering above, and the absence of a --password flag. The new role field on AuthenticatedSession is deliberately not added to UserOut.
Not applicable: no ingestion, no wear/odometer, no physical quantities, no new tables, no migration (alembic check confirms no model drift — ROLE_MEMBER is the same "member" string the column already defaulted to, now named).
Risks and follow-ups
create_admin() bypasses the invite requirement by design. It is not reachable over HTTP and nothing in api/ calls it; reaching it requires process execution inside the container, which already permits rewriting the SQLite file directly. Worth a reviewer's eye on that reasoning specifically — it's the load-bearing security argument, and it's the reason the "only when zero users" guard was rejected rather than forgotten.
The admin role is inert. Enforcement arrives with the first admin-only endpoint (invite management, Phase 1). Until then a CLI-created account differs from an invited one only in a string column.
There is no password reset. The refusal path tells the operator so. A reset should be a separate command that says what it does in its name.
Deliberately left out: listing/promoting/demoting users, and any --force overwrite.
Note for the reviewer: this branch was rebased onto current main (through #9). docs/DECISIONS.md conflicted — D17 was taken by the registry-TLS decision that merged while this was in flight, so this entry is D18, and all code comments reference D18.
## What and why
**A fresh deployment could not be used at all.** 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. Now that D16/D17 made a real deployment possible, the first thing a working container gives you is a login page nobody can get past.
This adds `velodrome create-admin`, a console script (`[project.scripts]` → `velodrome.cli:main`) that installs 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
```
`docs/PLAN.md` always called for this command; it was simply never built during Phase 0.
The account-creating logic lives in `auth/service.py` as `create_admin()`, not in `cli.py`, so `db.auth_session` stays confined to that module as its docstring requires. Its lookup is an exact match on a unique key — the pattern `db.py` documents as safe on that session.
Deliberate constraints, each with a test (reasoning in **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". Accepting it would make this an undocumented password-reset tool that any container-exec grants.
- **Not restricted to "only when there are zero users."** That restriction buys nothing — reaching the command already requires running a process inside the container, which already permits rewriting the SQLite file directly — while removing the two cases that do happen: adding 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 — the same pair `docker login` offers for the same reason.
- **argparse, not Typer/Click** — one command with three options doesn't justify a runtime dependency the image has to carry.
`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 so that future check has it in hand. It is deliberately **absent from `UserOut`**, so no HTTP response and no OpenAPI contract changes.
`RegisterRequest`'s password and display-name constraints move to named aliases (`Password`, `DisplayName`) in `schemas/auth.py`, so the CLI applies exactly the same rules rather than a copy that drifts.
## How this was verified
- [x] CI is green
- [x] Tests added or updated for the behaviour that changed
- [x] Verified manually (describe how):
**Tests** — `apps/api/tests/test_cli.py`, 15 new tests, real SQLite via the existing conftest (real Alembic migrations, real Argon2id, no mocks). Full suite: **28 passed**. The ones that carry weight:
- `test_create_admin_creates_an_account_that_can_actually_log_in` — deliberately **not** "a row appeared with a hash in it": it bootstraps an admin through the CLI and then logs in as them over HTTP. This command runs once, by a human with no way to debug it, and "printed success but the password doesn't work" is indistinguishable from a broken deployment.
- `test_create_admin_refuses_an_existing_email_without_touching_the_account` — asserts refusal three ways (original password still works, new one rejected, display name unchanged), because exit code 1 alone would also be satisfied by a command that failed *after* corrupting the row.
- `test_create_admin_error_message_never_echoes_the_password` — pydantic's default `ValidationError` rendering embeds the offending value, i.e. it prints the password. `_validate` shows only `loc`/`msg`; this pins that. `test_success_output_never_contains_the_password` covers the other path.
- `test_create_admin_records_the_admin_role` — asserts against the stored column *and* against `ROLE_MEMBER`, so it can't pass vacuously if the default changes. Nothing else in the suite would notice a silent `member`.
- `test_password_stdin_preserves_a_trailing_space` — proven through a real login, not by inspecting a hash.
- `test_the_console_script_is_registered_under_the_name_the_docs_use` — an editable install imports `velodrome.cli` fine whether or not the entry point exists, so nothing else would catch the README's command being wrong.
**Gates, all run from `apps/api/` after the rebase:** `ruff check .` clean · `ruff format --check .` clean (29 files) · `mypy --strict velodrome` clean (20 files) · `pytest -q` 28 passed · `alembic check` reports no model drift.
**Manual, against a scratch SQLite database** (migrated with `alembic upgrade head`, then driving the real entry point):
- Creating an admin printed the id/email/display name/role and the login URL — and **not** the password.
- Re-running with the same email: `error: an account already exists for you@example.com — refusing to modify it…`, exit **1**.
- Running without a TTY and without `--password-stdin`: the message naming both working forms (`docker exec -it …` and the piped form), exit **1** — rather than a bare `OSError` from getpass.
**Not verified:** this has not been run inside the actual built image on the Unraid host — only against a local venv and scratch database. The entry point's presence is asserted from `pyproject.toml`, and `PATH` already covers `alembic`/`uvicorn` by the same mechanism (`deploy/entrypoint.sh` invokes both by installed script name), but the first real `docker exec` is unproven. Worth running once after the next image build.
## Invariants
- [x] No secret can reach a response model, log line, or error message
**#5 is the one this change is most exposed to**, in two places, both tested: the pydantic error rendering above, and the absence of a `--password` flag. The new `role` field on `AuthenticatedSession` is deliberately not added to `UserOut`.
Not applicable: no ingestion, no wear/odometer, no physical quantities, no new tables, **no migration** (`alembic check` confirms no model drift — `ROLE_MEMBER` is the same `"member"` string the column already defaulted to, now named).
## Risks and follow-ups
- **`create_admin()` bypasses the invite requirement by design.** It is not reachable over HTTP and nothing in `api/` calls it; reaching it requires process execution inside the container, which already permits rewriting the SQLite file directly. Worth a reviewer's eye on that reasoning specifically — it's the load-bearing security argument, and it's the reason the "only when zero users" guard was rejected rather than forgotten.
- **The admin role is inert.** Enforcement arrives with the first admin-only endpoint (invite management, Phase 1). Until then a CLI-created account differs from an invited one only in a string column.
- **There is no password reset.** The refusal path tells the operator so. A reset should be a separate command that says what it does in its name.
- Deliberately left out: listing/promoting/demoting users, and any `--force` overwrite.
- **Note for the reviewer:** this branch was rebased onto current `main` (through #9). `docs/DECISIONS.md` conflicted — D17 was taken by the registry-TLS decision that merged while this was in flight, so this entry is **D18**, and all code comments reference D18.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
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
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
BBergle
merged commit 65879e8659 into main2026-09-21 22:34:58 -04:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What and why
A fresh deployment could not be used at all. 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. Now that D16/D17 made a real deployment possible, the first thing a working container gives you is a login page nobody can get past.
This adds
velodrome create-admin, a console script ([project.scripts]→velodrome.cli:main) that installs into the same venv asalembicanduvicorn, so the deployed image already has it on PATH:docs/PLAN.mdalways called for this command; it was simply never built during Phase 0.The account-creating logic lives in
auth/service.pyascreate_admin(), not incli.py, sodb.auth_sessionstays confined to that module as its docstring requires. Its lookup is an exact match on a unique key — the patterndb.pydocuments as safe on that session.Deliberate constraints, each with a test (reasoning in D18):
--passwordflag. An argument lands in shell history, inpsoutput, and in the Docker daemon's record of the exec'd command. A TTY prompt (with confirmation) and--password-stdinare the two forms that avoid all three — the same pairdocker loginoffers for the same reason.role="admin"is recorded but nothing enforces it yet; there is no admin-only endpoint until invite management in Phase 1.ROLE_ADMIN/ROLE_MEMBERbecome named constants andAuthenticatedSessioncarries the role so that future check has it in hand. It is deliberately absent fromUserOut, so no HTTP response and no OpenAPI contract changes.RegisterRequest's password and display-name constraints move to named aliases (Password,DisplayName) inschemas/auth.py, so the CLI applies exactly the same rules rather than a copy that drifts.How this was verified
Tests —
apps/api/tests/test_cli.py, 15 new tests, real SQLite via the existing conftest (real Alembic migrations, real Argon2id, no mocks). Full suite: 28 passed. The ones that carry weight:test_create_admin_creates_an_account_that_can_actually_log_in— deliberately not "a row appeared with a hash in it": it bootstraps an admin through the CLI and then logs in as them over HTTP. This command runs once, by a human with no way to debug it, and "printed success but the password doesn't work" is indistinguishable from a broken deployment.test_create_admin_refuses_an_existing_email_without_touching_the_account— asserts refusal three ways (original password still works, new one rejected, display name unchanged), because exit code 1 alone would also be satisfied by a command that failed after corrupting the row.test_create_admin_error_message_never_echoes_the_password— pydantic's defaultValidationErrorrendering embeds the offending value, i.e. it prints the password._validateshows onlyloc/msg; this pins that.test_success_output_never_contains_the_passwordcovers the other path.test_create_admin_records_the_admin_role— asserts against the stored column and againstROLE_MEMBER, so it can't pass vacuously if the default changes. Nothing else in the suite would notice a silentmember.test_password_stdin_preserves_a_trailing_space— proven through a real login, not by inspecting a hash.test_the_console_script_is_registered_under_the_name_the_docs_use— an editable install importsvelodrome.clifine whether or not the entry point exists, so nothing else would catch the README's command being wrong.Gates, all run from
apps/api/after the rebase:ruff check .clean ·ruff format --check .clean (29 files) ·mypy --strict velodromeclean (20 files) ·pytest -q28 passed ·alembic checkreports no model drift.Manual, against a scratch SQLite database (migrated with
alembic upgrade head, then driving the real entry point):error: an account already exists for you@example.com — refusing to modify it…, exit 1.--password-stdin: the message naming both working forms (docker exec -it …and the piped form), exit 1 — rather than a bareOSErrorfrom getpass.Not verified: this has not been run inside the actual built image on the Unraid host — only against a local venv and scratch database. The entry point's presence is asserted from
pyproject.toml, andPATHalready coversalembic/uvicornby the same mechanism (deploy/entrypoint.shinvokes both by installed script name), but the first realdocker execis unproven. Worth running once after the next image build.Invariants
#5 is the one this change is most exposed to, in two places, both tested: the pydantic error rendering above, and the absence of a
--passwordflag. The newrolefield onAuthenticatedSessionis deliberately not added toUserOut.Not applicable: no ingestion, no wear/odometer, no physical quantities, no new tables, no migration (
alembic checkconfirms no model drift —ROLE_MEMBERis the same"member"string the column already defaulted to, now named).Risks and follow-ups
create_admin()bypasses the invite requirement by design. It is not reachable over HTTP and nothing inapi/calls it; reaching it requires process execution inside the container, which already permits rewriting the SQLite file directly. Worth a reviewer's eye on that reasoning specifically — it's the load-bearing security argument, and it's the reason the "only when zero users" guard was rejected rather than forgotten.--forceoverwrite.main(through #9).docs/DECISIONS.mdconflicted — D17 was taken by the registry-TLS decision that merged while this was in flight, so this entry is D18, and all code comments reference D18.🤖 Generated with Claude Code
https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
velodrome create-adminto bootstrap the first user b7b4c31296A 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