Repository navigation
feat(admin): optional admin dashboard (users, bans, roles, sessions, invites, audit log) - #26
Merged
Merged
Conversation
…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
marked this pull request as ready for review
October 22, 2026 08:59
This was referenced Oct 22, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ADMDBservices.Policyfirst: any admin may act, provided they have 2FA whenADMIN_REQUIRE_2FAis on (the default in production). Every mutation writesadmin_audit_logafter it succeeds. A failed audit write is a 500 and is never swallowed.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.apps/web/**cannot importapps/adminoradminrepo, andapps/admin/**cannot importapps/web.packages/auth; they declare UI-owned ports andserveradapts auth to them.auth.NewUserAdminand(*auth.Auth).Usersare only allowed inapps/adminand in auth itself.make lint-layeringplants a violation for every rule.admin migrateseedsADMIN_BOOTSTRAP_EMAILwithout a password, andadmin invite <email>(or the Admins page) emails a set-password link. WithADMIN_REQUIRE_2FA, setting the password continues into TOTP enrolment with backup codes before the first session.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.make db-upcreates anadmindatabase on the same per-checkout Postgres: no extra container, and worktree isolation is unchanged.make admin-dev,admin-migrate,admin-invite EMAIL=,admin-build,admin-docker.apps/admin/Dockerfileandapps/admin/railway.json(pre-deploy/admin migrate, plus/healthz, which pings both databases). Hosted, the admin database is its own small Postgres.auth.ErrBannedUserto a translated "account suspended" message.preventDefault./api/auth/reset-password/{token}.no-store.Evidence
Before: there was no admin app (
apps/admindid not exist).After:
Real servers (web on :3200, admin on :3201, the per-checkout dev Postgres with
appandadmindatabases,ADMIN_REQUIRE_2FA=true), driven by headless Chromium. Script output on the final commit:The flow: a user signs up in web;
admin migratecreates the bootstrap admin;admin invite admin@example.comlogs 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 throughheavy.shwith-j1 -p=1, passes:go test -race, and coverage floors.Per boundary (passing tests including subtests, then coverage against the floor):
apps/admin/internal/servicesapps/admin/internal/apiapps/admin/ui/**packages/database/adminrepoThe allowlisted wiring tests are
apps/admin/internal/server(6, incl.TestBanFlowandTestAdminIsolationover real packages/auth on two Postgres databases) andapps/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_authandgoose_adminthere). The app database gets no new migrations from this PR.The shared-code changes are small:
dbtest.Fingerprintis now exported.Reverting the PR removes them.
Blast Radius: platform.
make db-upnow also creates theadmindev database.Follow-ups, not in this PR:
RESEND_API_KEYthroughpackages/secrets.otpauth://link today).🤖 Generated with Claude Code