Skip to content

feat(admin): optional admin dashboard (users, bans, roles, sessions, invites, audit log) - #26

Merged
jayleaton merged 17 commits into
mainfrom
feat/admin-app
Oct 22, 2026
Merged

jayleaton merged 17 commits into
mainfrom
feat/admin-app

Conversation

@jayleaton

Copy link
Copy Markdown
Owner

The template gets an optional admin dashboard (apps/admin): its own binary, Railway service, database and auth instance. Admins can list, search and inspect users (sessions, verified, created, role, ban state), ban (reason, expiry) and unban, revoke sessions, set roles, invite admins and read an audit log of every admin action. An analytics placeholder sits behind a port. The UI is in en and ja, and there is a matching JSON API under /api/admin/v1.

Summary

flowchart LR
  subgraph admin["apps/admin (own binary, ADMIN_* env)"]
    UI["ui (templ + Datastar)"] --> S["services (Policy + audit)"]
    API["api /api/admin/v1"] --> S
    S --> SRV["server adapters"]
  end
  SRV -->|"auth.UserAdmin (ban, role, sessions)"| APPDB[("app DB: DATABASE_URL")]
  SRV -->|"admin auth instance, cookie admin.*"| ADMDB[("admin DB: ADMIN_DATABASE_URL")]
  S -->|"adminrepo.AuditLog (append-only)"| ADMDB
  S -->|"adminrepo.Stats (read-only)"| APPDB
  WEB["apps/web"] -.x|"depguard + forbidigo"| admin
  WEB -.x ADMDB
Loading
  • Same layers as the web app. Ports live in the services, the handlers are about five lines inside the route wrapper and envelope, validators are separate, and mockgen mocks are used only at the boundary tests. Every service method checks services.Policy first: any admin may act, provided they have 2FA when ADMIN_REQUIRE_2FA is on (the default in production). Every mutation writes admin_audit_log after it succeeds. A failed audit write is a 500 and is never swallowed.
  • User administration goes through auth.UserAdmin, not a second sqlc copy. packages/auth owns those tables, the ban rules (including expired bans lifting themselves) and session revocation. packages/database/adminrepo (sqlc) holds what the admin app owns: the audit log, which a trigger keeps append-only, and read-only stats.
  • Isolation in both directions, enforced and probed.
    • depguard: apps/web/** cannot import apps/admin or adminrepo, and apps/admin/** cannot import apps/web.
    • Admin transports cannot import packages/auth; they declare UI-owned ports and server adapts auth to them.
    • forbidigo: auth.NewUserAdmin and (*auth.Auth).Users are only allowed in apps/admin and in auth itself.
    • make lint-layering plants a violation for every rule.
  • Invite only.
    • admin migrate seeds ADMIN_BOOTSTRAP_EMAIL without a password, and admin invite <email> (or the Admins page) emails a set-password link. With ADMIN_REQUIRE_2FA, setting the password continues into TOTP enrolment with backup codes before the first session.
    • The admin auth instance has DisableSignUp, and the server mounts only the auth endpoints it needs: sign-in, the 2FA steps, the set-password link, session and sign-out. Self-service account changes would bypass the audit log, so they are not reachable.
  • Local dev and deploy.
    • make db-up creates an admin database on the same per-checkout Postgres: no extra container, and worktree isolation is unchanged.
    • Commands: make admin-dev, admin-migrate, admin-invite EMAIL=, admin-build, admin-docker.
    • Deploy files: apps/admin/Dockerfile and apps/admin/railway.json (pre-deploy /admin migrate, plus /healthz, which pings both databases). Hosted, the admin database is its own small Postgres.
  • Fixes the browser pass found:
    • Web sign-in of a banned user showed "Something went wrong". It now maps auth.ErrBannedUser to a translated "account suspended" message.
    • In the admin UI, the GET search and filter forms were swallowed by Datastar's submit preventDefault.
  • Fixes a blocking security review found:
    • The emailed set-password and reset token was written to request logs, in both apps. Logs now record /api/auth/reset-password/{token}.
    • Every admin response is now no-store.
    • The locale redirect now rejects control characters.
    • The auth endpoints are an allowlist (see above).

Evidence

Before: there was no admin app (apps/admin did not exist).

After:

  • Real servers (web on :3200, admin on :3201, the per-checkout dev Postgres with app and admin databases, ADMIN_REQUIRE_2FA=true), driven by headless Chromium. Script output on the final commit:

    [e2e] admin signed in after 2FA enrolment
    [e2e] web: banned session redirected to http://localhost:3200/login
    [e2e] web: banned sign-in refused with: "Your account has been suspended. Contact support if you think this is a mistake."
    [e2e] web: sign-in works after unban
    [e2e] admin sign-in with TOTP works
    [e2e] ALL OK
    

    The flow: a user signs up in web; admin migrate creates the bootstrap admin; admin invite admin@example.com logs the link; the admin opens it, sets a password and enrols TOTP; they search for the user, open the detail page and ban them for 1 day. The user's web session dies and their sign-in is refused. After the unban, sign-in works again. Then the audit log, analytics, admins and the Japanese pages are checked, and finally a fresh browser signs in with the TOTP step. A replayed TOTP code is refused, as expected.

    There are 15 screenshots: users search, user detail, banned and unbanned states, the web refusal, the audit log, analytics, admins, the ja pages, and 2FA enrolment and sign-in. They are on the dev box at ~/Documents/spark-work/zig-dsv41-scratch/admin-shots/ and are not uploaded to GitHub.

  • make ci, run through heavy.sh with -j1 -p=1, passes:

    • Build, generated-code diff (sqlc, mocks, templ), and gofmt + golangci-lint with 0 issues.
    • Test-location check, go test -race, and coverage floors.
    • 40/40 layering probes (22 new: 20 admin depguard probes in both directions and 2 forbidigo) and the test-boundary and coverage probes, including the admin services coverage probe.
  • Per boundary (passing tests including subtests, then coverage against the floor):

    Boundary Passing tests Coverage (floor)
    apps/admin/internal/services 28 100% (90)
    apps/admin/internal/api 91 100% (85); validate 100%
    apps/admin/ui/** 106 89.9% (80, new floor)
    packages/database/adminrepo 4 82.9% (80)

    The allowlisted wiring tests are apps/admin/internal/server (6, incl. TestBanFlow and TestAdminIsolation over real packages/auth on two Postgres databases) and apps/admin/internal/config (4).

Merge Danger

Door: two-way.

Everything is additive and opt-in. The admin app runs only if you deploy it, and its migrations touch only its own database (goose_auth and goose_admin there). The app database gets no new migrations from this PR.

The shared-code changes are small:

  • dbtest.Fingerprint is now exported.
  • The web UI gains one error mapping and two message keys.
  • Web request logs redact reset tokens.

Reverting the PR removes them.

Blast Radius: platform.

  • Lint, CI and tooling: new depguard and forbidigo rules, extended test boundaries and floors, CI templ/paths, and Makefile targets. make db-up now also creates the admin dev database.
  • Shared code: the web-side changes listed above.
  • Deploy order: deploy the web service before the admin service. Web's pre-deploy migrates the app database the admin app reads.

Follow-ups, not in this PR:

  • Removing or disabling an admin.
  • Admin RESEND_API_KEY through packages/secrets.
  • A QR code for TOTP (manual key plus otpauth:// link today).
  • Auditing admin sign-ins.

🤖 Generated with Claude Code

jayleaton and others added 17 commits October 22, 2026 15:55
…pp stats

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cked ports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…with adapters over packages/auth

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…boundaries and floors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, railway.json, CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns, admins, audit, analytics, 2FA) in en and ja

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pps/admin, with probes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d of a server-side block

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…revents submit by default)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n, ja)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, redact tokens in logs, stricter locale redirect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton marked this pull request as ready for review October 22, 2026 08:59
@jayleaton
jayleaton merged commit db9d678 into main Oct 22, 2026
10 checks passed
@jayleaton
jayleaton deleted the feat/admin-app branch October 22, 2026 09:03
jayleaton added a commit that referenced this pull request Oct 23, 2026
Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
…s web + admin

Ported from auto-visuals #31 (why: #26, #29). CI never built the images
or booted the binaries, so an image that cannot start, or a production
config that refuses to boot without an optional integration, reached a
deploy first.

scripts/release-smoke.sh, in the single ci job (and make release-smoke):
1. builds every binary that has a Dockerfile with that Dockerfile's own
   build-stage ENV and RUN line (scripts/dockerfile-builds.sh reads them;
   nothing copies the flags), and fails if one is dynamically linked;
2. runs the binaries' own migrate on fresh databases;
3. boots web and admin with APP_ENV=production and only the config
   production requires, until /healthz answers 200.

It runs only when a build input changed: scripts/affected.sh outputs
smoke=true for a full run, a changed Dockerfile, or an affected package
that a Dockerfile builds (test files alone do not count).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
…s web + admin (#115)

* ci: release smoke builds every Dockerfile's binary, migrates and boots web + admin

Ported from auto-visuals #31 (why: #26, #29). CI never built the images
or booted the binaries, so an image that cannot start, or a production
config that refuses to boot without an optional integration, reached a
deploy first.

scripts/release-smoke.sh, in the single ci job (and make release-smoke):
1. builds every binary that has a Dockerfile with that Dockerfile's own
   build-stage ENV and RUN line (scripts/dockerfile-builds.sh reads them;
   nothing copies the flags), and fails if one is dynamically linked;
2. runs the binaries' own migrate on fresh databases;
3. boots web and admin with APP_ENV=production and only the config
   production requires, until /healthz answers 200.

It runs only when a build input changed: scripts/affected.sh outputs
smoke=true for a full run, a changed Dockerfile, or an affected package
that a Dockerfile builds (test files alone do not count).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: give the Check step a minute for the release smoke; document it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(scripts): tidy the dockerfile-builds header

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
)

* feat(storage): optional S3 object storage and safe image ingestion

Adds packages/storage (S3 client over net/http + SigV4, no SDK),
packages/storage/safefetch (user-URL fetch that checks every dialled IP,
redirect hop included, so DNS rebinding fails) and
packages/storage/imaging (pure-Go JPEG/PNG/GIF/WebP/AVIF decode, EXIF
orientation, fit to 2048 px, re-encode to WebP).

Off unless STORAGE_S3_ENDPOINT/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY are
set (live secrets, all or none, probed at boot). One example behind the
layers: ImageService with ObjectStore/ImageFetcher/ImageCodec ports and
/api/core/v1/images (upload, import by URL, get, delete), mounted only
when storage is on. The SSRF address and URL rules live in services
(PublicAddr, FetchURLAllowed) and are table-tested there.

New dependencies: gen2brain/webp and gen2brain/avif (libwebp transpiled
to Go, libavif under wazero; the only pure-Go WebP encoder and AVIF
decoder) and golang.org/x/image (resizing). Builds need -tags nodynamic
to stay static: make build and the Dockerfile pass it, and the image
build fails without it.

Source: jayleaton/auto-visuals#9 and #26 (generic parts only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(imaging): resize in bands of rows, 8 MiB of scratch instead of 198 MB

x/image/draw's CatmullRom.Scale keeps the whole horizontally scaled image
(target width x source height x 32 bytes): 198 MB for a 12 MP photo, 396 MB
for 48 MP, and the server held about 540 MB after a few uploads. resize
does the same separable Catmull-Rom (same weights, same 16-bit
premultiplied arithmetic, same rounding) one band of output rows at a
time, scaling only the source rows a band reads, support rows included,
so the output is identical byte for byte and bands leave no seams.

Peak RSS on the real server: 12 MP upload 567 -> 179 MB, 48 MP 591 -> 218
MB, two 12 MP at once 528 -> 266 MB; the stored WebP is byte-identical.
golang.org/x/image is no longer needed (image/draw from the standard
library covers the copy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant