Make the admin a member with a flag, and drop the second listener

The admin was a set of env credentials on its own loopback listener. That
bought network isolation, and charged a second port to tunnel and proxy and
a second credential in the password manager. It also sat outside the
SameSite protection the member cookie already had, and left every ban and
password reset with no actor to log.

is_admin on users reuses what was already there: the session, the login
rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member
mux. A member without the flag gets 404 rather than 403 — the pages are
none of their business, and "forbidden" confirms there is something to be
forbidden from.

Registration needs an invite and invites come from /admin, so an empty
database cannot grow its first user. seedAdmin breaks that circle exactly
once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a
database that already has users.

An admin cannot ban themselves: banning drops the target's sessions, and
nothing would be left that could undo it.

This reverses decision 8, which is rewritten rather than deleted, along
with the admin entry in the CONTEXT.md vocabulary.
This commit is contained in:
Esa Kataja
2026-09-05 13:40:26 +03:00
parent 00ea7624ca
commit 2af29fe999
16 changed files with 321 additions and 186 deletions
+37 -35
View File
@@ -364,56 +364,58 @@ template renders a circle with the member's initials, in CSS. No default image o
## 6. Admin
**The admin is not a user.** They never submit, never review, and never see the member-facing site.
No `role` column, no admin row, no admin session, no admin login page, no first-launch seeding — and
no query anywhere has to exclude the admin from a list, a leaderboard, or an aggregate. The admin UI
is Finnish, like everything else.
**An admin is a member with `is_admin` set.** One boolean column on `users`, no role enum. They
submit and review like anyone else and appear in every list and leaderboard, so no query has to
exclude them. The admin UI is Finnish, like everything else.
```go
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
// (close the browser). Add a cookie session if a second admin ever needs one.
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(adminUser)) != 1 ||
subtle.ConstantTimeCompare([]byte(p), []byte(adminPass)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
// resolve reports without also being able to reset passwords.
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := memberFrom(r.Context())
if m == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
if !m.IsAdmin {
http.NotFound(w, r)
return
}
next(w, r)
}
}
```
No bcrypt here: hashing protects *stored* passwords against a database leak, and this one lives in
the env file already. The constant-time compare is the part that
matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is
worse than one that will not boot.
A signed-in member who is not an admin gets **404, not 403**: the admin pages are none of their
business, and "forbidden" confirms there is something to be forbidden from. Everything else — the
session cookie, `SameSite` CSRF protection, the login rate limiter, ban-drops-sessions — is reused
rather than reimplemented, which is the whole point of the flag.
```go
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
// or the reverse proxy. A separate binary would need its own deploy and would race the
// startup migrations; it buys nothing else.
go func() { log.Fatal(http.ListenAndServe("127.0.0.1:8081", requireAdmin(adminMux))) }()
log.Fatal(http.ListenAndServe(":8080", memberMux))
// One listener. /admin is a route on the member mux, gated per-route.
log.Fatal(http.ListenAndServe(":8080", a.withMember(a.memberMux())))
```
**Bootstrap:** the admin logs in with the env credentials and mints the first invite. That is the
entire first-launch story. Losing the password is an edit to `.env` and a restart.
**Bootstrap:** registration needs an invite and invites are minted from `/admin`, so an empty
database cannot grow a first user on its own. `seedAdmin` breaks the circle exactly once — on an
empty `users` table it creates account number one from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and sets
`is_admin`. Against a populated database it does nothing, which is what makes it safe to leave in
the boot sequence. **Fatal at startup if those are unset on an empty database** — a site nobody can
log into is worse than one that will not boot.
**Routes** (all on the loopback listener): `GET /admin` dashboard, `POST /admin/invites`,
`POST /admin/users/{id}/password`, `POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`,
`GET /admin/reports`, `POST /admin/reports/{id}/resolve`, and `GET /admin/audio/{id}` — moderating a
complaint means listening to the song, and a separate audio route avoids branching auth inside the
member handler.
**Routes:** `GET /admin` dashboard, `POST /admin/invites`, `POST /admin/users/{id}/password`,
`POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`, `GET /admin/reports`,
`POST /admin/reports/{id}/resolve`. There is no `/admin/audio/{id}`: an admin is a member, so
`GET /audio/{id}` already works for them.
**Ban** is a reversible toggle. It refuses login and deletes the member's sessions immediately.
Their songs and reviews stay, keep counting in the stats, and keep their name on them: a ban ends
participation, it does not rewrite history.
participation, it does not rewrite history. An admin cannot ban *themselves* — the sessions would go
with it and nothing would be left to undo it.
**The admin surface has no API.** Basic Auth on loopback with no client but a browser JSON would
be contract surface with no consumer.
**The admin surface has no API.** No client but a browser, so JSON would be contract surface with no
consumer.
---
@@ -731,7 +733,7 @@ panel, so the admin surface comes first — before a single member can exist.
1. **Skeleton** — `main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose,
the two listeners.
2. **Admin, invites, auth**Basic Auth listener, mint an invite, register, log in, sessions, ban.
2. **Admin, invites, auth**seed the first admin, mint an invite, register, log in, sessions, ban.
3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet,
so the hard parts (worker, publish transaction, restart recovery) are proven without a network
dependency.