Auto-generated user avatars: dicebear-go (constellation style, electric preset, seed = email) generated in background on login #376

Closed
opened 2026-09-12 13:48:50 +00:00 by crueber · 3 comments
Owner

What's requested

Auto-generate an avatar for every user who logs in without one:

  • Use the DiceBear library — dicebear-go — with the "constellation" style and the "electric" preset.
  • Seed = the user's email.
  • When a user logs in and has no avatar, dispatch a background worker to generate one and install it as their avatar as soon as it's available (never blocking the login response).
  • This adds a third-party module — a law-1 exception is explicitly authorized by the user. The amendment must still be written properly (see below).

Current state (code evidence)

  • No avatar concept exists anywhere — verified: no avatar in internal/ Go code or web/src (the #371 navbar identity control is specified to take an optional avatar URL with username fallback, so the display side is already planned for it).
  • User storage exists: users/<principal>/profile.json (CAS'd user profile, internal/identity/identity.go:10,148) with a GET/PUT API (/api/v1/users/{principal}, internal/identity/http.go:29,236). The avatar's natural home is a field on this profile or a sibling object (users/<principal>/avatar.svg) — implementer's call, note which.
  • Background work precedent: the mirror sync runs as a narrated task via the (repo, kind) task single-flight (internal/wal/tasks.go); the maintainer's follow loop (internal/maintain/follow.go) shows the separate-cadence goroutine pattern. Avatar generation is a small, one-shot, per-login job — the task table may be overkill (it's repo-keyed); a simple goroutine with a dedup set (one in-flight generation per principal) is likely the right shape. Decide and state it.
  • The library: github.com/dicebear/dicebear-go/v10 (+ github.com/dicebear/styles/v10 for the style definitions) — pure-Go, deterministic SVG generation from style + seed, no network calls, requires Go 1.23+ (repo is on Go 1.27, fine). The "constellation" style and "electric" preset are DiceBear catalog entries; verify both names exist in the styles/v10 package (constellation is a v9+ style; the preset maps to the style's options — check the exact option key, likely "preset": "electric").

Proposed design

  1. Law-1 amendment (required first step of the PR): add the exception to AGENTS.md §1 and the relevant doc's "Decisions & deviations" section (user-authorized 2026-09-12): github.com/dicebear/dicebear-go/v10 + github.com/dicebear/styles/v10, purpose: deterministic user-avatar generation. Two modules, both zero-sub-dependencies per the library README — verify with go mod graph before claiming it.
  2. Generation: on login (the OIDC session-mint path in internal/server/auth.go — MintSession/principalFromEmail callers), after authentication succeeds, check whether the user has an avatar; if not, enqueue generation with seed = the user's email (exactly as specified — including when #370's usernames land, the seed stays the email), style constellation, preset electric. Generate the SVG in-process (the library is local and fast — a goroutine, not a task-table job), write it to the user's avatar object, and set the profile's avatar reference. If a concurrent login for the same user is already generating, skip (single-flight by principal).
  3. Serving: the avatar object serves at a stable URL (e.g. GET /api/v1/users/{principal}/avatar or a static-ish route — pick per the existing users routes in identity/http.go), content-type image/svg+xml, with a long-lived cache class (generated output is deterministic per seed — effectively immutable; use the immutable cache class with the profile-version in the ETag or an avatar_version counter so regeneration busts caches).
  4. Consumption: the #371 navbar identity control renders the avatar from this URL (its optional-avatar prop); profile pages (users/{principal} view, owner profile #234) use it too. renderBody/markdown does NOT inline avatars — display surfaces only.
  5. Edge cases: regeneration on explicit user action (re-generate button on the profile — optional, note it); user deletes avatar → the login check must not regenerate unless the user opts back in (a avatar_disabled flag on the profile, or treat deletion as opting out until they request one — pick and document); SVG safety (DiceBear output is generated locally from the style definition, not user input beyond the seed — the seed is the email, which is text substituted into the SVG; confirm the library escapes the seed and sanitize/escape it anyway before writing, per the repo's sanitize-first posture).
  6. Determinism note: same email → same avatar every time (library guarantee), so regeneration after deletion reproduces the identical image unless the seed changes — document that deleting and re-enabling yields the same avatar.

Acceptance criteria

  • A user logging in with no avatar gets one generated (constellation/electric, seed = email) without any action; it appears in the navbar identity control and profile.
  • Generation never blocks the login response (background, single-flight per principal).
  • Avatar serves at a stable URL with an appropriate immutable-ish cache class and a cache-busting mechanism on regeneration.
  • Law-1 amendment present in AGENTS.md §1 + the doc's deviations section, naming both modules; go.mod shows exactly those additions (no transitive deps).
  • Seed is the user's email; the seed is escaped before SVG generation (no injection into the SVG).
  • Deletion/opt-out honored (login does not regenerate for a user who removed their avatar, per the documented policy).
  • Tests: generation determinism (same seed → same SVG), single-flight (two concurrent logins → one generation), serving route content-type/cache headers, and the escape behavior.
## What's requested Auto-generate an avatar for every user who logs in without one: - Use the **DiceBear** library — `dicebear-go` — with the **"constellation"** style and the **"electric"** preset. - **Seed = the user's email.** - When a user logs in and has no avatar, dispatch a **background worker** to generate one and install it as their avatar as soon as it's available (never blocking the login response). - **This adds a third-party module — a law-1 exception is explicitly authorized by the user.** The amendment must still be written properly (see below). ## Current state (code evidence) - **No avatar concept exists anywhere** — verified: no `avatar` in `internal/` Go code or `web/src` (the #371 navbar identity control is specified to take an optional avatar URL with username fallback, so the display side is already planned for it). - **User storage exists**: `users/<principal>/profile.json` (CAS'd user profile, `internal/identity/identity.go:10,148`) with a GET/PUT API (`/api/v1/users/{principal}`, `internal/identity/http.go:29,236`). The avatar's natural home is a field on this profile or a sibling object (`users/<principal>/avatar.svg`) — implementer's call, note which. - **Background work precedent**: the mirror sync runs as a narrated task via the `(repo, kind)` task single-flight (`internal/wal/tasks.go`); the maintainer's follow loop (`internal/maintain/follow.go`) shows the separate-cadence goroutine pattern. Avatar generation is a small, one-shot, per-login job — the task table may be overkill (it's repo-keyed); a simple goroutine with a dedup set (one in-flight generation per principal) is likely the right shape. Decide and state it. - **The library**: `github.com/dicebear/dicebear-go/v10` (+ `github.com/dicebear/styles/v10` for the style definitions) — pure-Go, deterministic SVG generation from style + seed, no network calls, requires Go 1.23+ (repo is on Go 1.27, fine). The "constellation" style and "electric" preset are DiceBear catalog entries; verify both names exist in the `styles/v10` package (constellation is a v9+ style; the preset maps to the style's options — check the exact option key, likely `"preset": "electric"`). ## Proposed design 1. **Law-1 amendment (required first step of the PR):** add the exception to `AGENTS.md` §1 and the relevant doc's "Decisions & deviations" section (user-authorized 2026-09-12): `github.com/dicebear/dicebear-go/v10` + `github.com/dicebear/styles/v10`, purpose: deterministic user-avatar generation. Two modules, both zero-sub-dependencies per the library README — verify with `go mod graph` before claiming it. 2. **Generation:** on login (the OIDC session-mint path in `internal/server/auth.go` — `MintSession`/`principalFromEmail` callers), after authentication succeeds, check whether the user has an avatar; if not, enqueue generation with **seed = the user's email** (exactly as specified — including when #370's usernames land, the seed stays the email), style `constellation`, preset `electric`. Generate the SVG in-process (the library is local and fast — a goroutine, not a task-table job), write it to the user's avatar object, and set the profile's avatar reference. If a concurrent login for the same user is already generating, skip (single-flight by principal). 3. **Serving:** the avatar object serves at a stable URL (e.g. `GET /api/v1/users/{principal}/avatar` or a static-ish route — pick per the existing users routes in `identity/http.go`), content-type `image/svg+xml`, with a long-lived cache class (generated output is deterministic per seed — effectively immutable; use the immutable cache class with the profile-version in the ETag or an `avatar_version` counter so regeneration busts caches). 4. **Consumption:** the #371 navbar identity control renders the avatar from this URL (its optional-avatar prop); profile pages (`users/{principal}` view, owner profile #234) use it too. `renderBody`/markdown does NOT inline avatars — display surfaces only. 5. **Edge cases:** regeneration on explicit user action (re-generate button on the profile — optional, note it); user deletes avatar → the login check must not regenerate unless the user opts back in (a `avatar_disabled` flag on the profile, or treat deletion as opting out until they request one — pick and document); SVG safety (DiceBear output is generated locally from the style definition, not user input beyond the seed — the seed is the email, which is text substituted into the SVG; **confirm the library escapes the seed** and sanitize/escape it anyway before writing, per the repo's sanitize-first posture). 6. **Determinism note:** same email → same avatar every time (library guarantee), so regeneration after deletion reproduces the identical image unless the seed changes — document that deleting and re-enabling yields the same avatar. ## Acceptance criteria - [ ] A user logging in with no avatar gets one generated (constellation/electric, seed = email) without any action; it appears in the navbar identity control and profile. - [ ] Generation never blocks the login response (background, single-flight per principal). - [ ] Avatar serves at a stable URL with an appropriate immutable-ish cache class and a cache-busting mechanism on regeneration. - [ ] Law-1 amendment present in `AGENTS.md` §1 + the doc's deviations section, naming both modules; `go.mod` shows exactly those additions (no transitive deps). - [ ] Seed is the user's email; the seed is escaped before SVG generation (no injection into the SVG). - [ ] Deletion/opt-out honored (login does not regenerate for a user who removed their avatar, per the documented policy). - [ ] Tests: generation determinism (same seed → same SVG), single-flight (two concurrent logins → one generation), serving route content-type/cache headers, and the escape behavior.
crueber added this to the v1 milestone 2026-09-12 13:48:50 +00:00
Author
Owner

Fix ready for review: #379 (branch fix/issue-376, no conflicts, NOT merged). Notes for the reviewer: (1) two issue-text assumptions did not survive verification — constellation has no options so there is no 'electric' preset key (defaults used), and the library pulls two build-required transitives (schema + jsonschema/v6, go mod graph proof in the PR); both are documented in AGENTS.md §1 + 01 Decisions. (2) Opt-out policy: DELETE records avatar_disabled and login never regenerates until POST regenerates. (3) Not merged per instructions.

Fix ready for review: https://git.packden.us/crueber/walhub/pulls/379 (branch fix/issue-376, no conflicts, NOT merged). Notes for the reviewer: (1) two issue-text assumptions did not survive verification — constellation has no options so there is no 'electric' preset key (defaults used), and the library pulls two build-required transitives (schema + jsonschema/v6, go mod graph proof in the PR); both are documented in AGENTS.md §1 + 01 Decisions. (2) Opt-out policy: DELETE records avatar_disabled and login never regenerates until POST regenerates. (3) Not merged per instructions.
Author
Owner

REVIEW: PR #379 (fix/issue-376) — adversarial pass, new third-party dep. Verified in scratch worktree /tmp/walhub-376 (944e2f5). No browser drive (per task rules; tests + reasoning). Main worktree untouched (tracked tree clean before/after).

  1. LAW-1: PASS. AGENTS.md §1 amendment names both modules (dicebear-go/v10 + styles/v10), purpose, user-authorized 2026-09-12, Forgejo #376, Decisions pointer, and both build-required transitives with go-mod-graph rationale. Deviations mirrored in docs/features/01 Decisions + docs/go/06 D1/§8.6. go.mod adds EXACTLY 2 direct (v10.7.0/v10.6.0) + 2 indirect (schema v1.5.1, jsonschema/v6 v6.0.2); x/text pre-existing. go mod graph + go mod why confirm both transitives come from dicebear-go internal/validate+render. Pinned. No net imports in dicebear-go or schema (grep clean). dlclark/regexp2 appears in the module graph but is imported ONLY by jsonschema's own example_regexp_test.go — go list -deps ./internal/identity proves it is NOT linked into the binary. Exception text covers build-required transitives; accurate.

  2. PRESET DEVIATION: VERIFIED TRUE. styles/v10 constellation.json has no options key and no 'electric' string; 'electric' exists only in notionists.json. Generation with defaults IS the constellation style requested. avatar.go:69-78 + 01 Decisions document this honestly. Claim (b) likewise true (graph proof above).

  3. GENERATION: PASS. AvatarHook seam (server.Options/avatarHook, fired post-mint in authCallback, wired in cmd/walhub/serve.go:avatarHookOf, nil in setup-only) only enqueues; EnsureAvatarAsync (avatar.go:288) spawns a goroutine behind a per-principal mutex dedup set, mutex never held across store calls. Fired ONLY on OIDC session-mint, never per-request. Failures are best-effort drops (TestEnsureAvatarAsyncFailureRetries); next login retries. Single-flight covered by TestEnsureAvatarSingleFlight; whole identity suite green under -race.

  4. SEED/ESCAPING: PASS (stronger than asked). Seed=emailOf(p) (verified email, auth.go:312) at login; regen resolves via EmailForUsername with legacy-email fallback, 404s without mappable email (never invents a seed). Library deliberately excludes the raw seed from output (avatar.go upstream comment); TestGenerateSeedAbsent covers hostile seeds. I independently rendered hostile seeds ('">

REVIEW: PR #379 (fix/issue-376) — adversarial pass, new third-party dep. Verified in scratch worktree /tmp/walhub-376 (944e2f5). No browser drive (per task rules; tests + reasoning). Main worktree untouched (tracked tree clean before/after). 1. LAW-1: PASS. AGENTS.md §1 amendment names both modules (dicebear-go/v10 + styles/v10), purpose, user-authorized 2026-09-12, Forgejo #376, Decisions pointer, and both build-required transitives with go-mod-graph rationale. Deviations mirrored in docs/features/01 Decisions + docs/go/06 D1/§8.6. go.mod adds EXACTLY 2 direct (v10.7.0/v10.6.0) + 2 indirect (schema v1.5.1, jsonschema/v6 v6.0.2); x/text pre-existing. go mod graph + go mod why confirm both transitives come from dicebear-go internal/validate+render. Pinned. No net imports in dicebear-go or schema (grep clean). dlclark/regexp2 appears in the module graph but is imported ONLY by jsonschema's own example_regexp_test.go — go list -deps ./internal/identity proves it is NOT linked into the binary. Exception text covers build-required transitives; accurate. 2. PRESET DEVIATION: VERIFIED TRUE. styles/v10 constellation.json has no options key and no 'electric' string; 'electric' exists only in notionists.json. Generation with defaults IS the constellation style requested. avatar.go:69-78 + 01 Decisions document this honestly. Claim (b) likewise true (graph proof above). 3. GENERATION: PASS. AvatarHook seam (server.Options/avatarHook, fired post-mint in authCallback, wired in cmd/walhub/serve.go:avatarHookOf, nil in setup-only) only enqueues; EnsureAvatarAsync (avatar.go:288) spawns a goroutine behind a per-principal mutex dedup set, mutex never held across store calls. Fired ONLY on OIDC session-mint, never per-request. Failures are best-effort drops (TestEnsureAvatarAsyncFailureRetries); next login retries. Single-flight covered by TestEnsureAvatarSingleFlight; whole identity suite green under -race. 4. SEED/ESCAPING: PASS (stronger than asked). Seed=emailOf(p) (verified email, auth.go:312) at login; regen resolves via EmailForUsername with legacy-email fallback, 404s without mappable email (never invents a seed). Library deliberately excludes the raw seed from output (avatar.go upstream comment); TestGenerateSeedAbsent covers hostile seeds. I independently rendered hostile seeds ('"><script>...', 'a<b@...', 'x&y@...'): 3631-byte SVG, no verbatim leak, no <script, determinism holds, distinct seeds differ. checkAvatarSVG fail-closed gate (shape/size/script/seed-leak) on both generate and write paths. 5. SERVING: PASS. GET /api/v1/users/{principal}/avatar: image/svg+xml from stored pointer, public max-age=86400 immutable + quoted ETag (user-avatar-<updated_at>) + 304 path (TestUserAvatarServeHeaders), ?v= busting via UserAvatarURL. GET is public under the same anonymousRead gate as the profile (per #370 rules); URL carries the username so no email leaks, and avatar bytes never contain the seed. POST/DELETE self-or-admin (same gate as profile PUT, TestUserAvatarAuth). 6. OPT-OUT: PASS. DELETE clears pointer + sets avatar_disabled (idempotent; unknown principal 404s per userExists rule); both EnsureAvatarAsync probes honor it; POST regenerates deterministically (same email → identical bytes) and clears the flag (TestDeleteRegenerateOptOut). Documented in 01 Decisions + avatar.go:204-209. 7. BUCKET TRUTH: PASS. Bytes at users/<username>/avatar.svg (PutOverwrite) + CAS'd pointer on profile.json, bytes-first/pointer-second (#359 discipline); wipe-safe. PutProfile preserves the pointer (test-pinned). No user-deletion path exists in the codebase, so no avatar-cleanup gap (only DeleteUserAvatar). #370 interplay sound: key=username, seed=email via registry, Email never stored on profile. 8. CONSUMPTION: PASS. me().avatar_url (omitted when unwired/absent, TestMeAvatarURL) → navModel.avatarUrl → IdentityMenu; /:owner header pointer-gated with 404-hide; renderBody/markdown untouched (no avatar refs in render paths). Minor note: Repos.jsx adds one cached users.get per /:owner page (404→null for orgs) — fine for UI, not a hot path. 9. QUALITY GATES: identity 95.5%, api 95.2% (-race green); gofmt/vet clean; go build clean; node 29/29 on touched files (identity-nav + sdk-identity). internal/server/auth + cmd/walhub green. internal/server shows 10 failures in the scratch worktree BUT all are empty-web/dist artifacts (fresh worktree has no built dist; shell=500/repos.js=404) — main (dist built) fails only the known pre-existing TestUIAssetConcepts (stale dist, as the PR body states). No PR regression. NON-BLOCKING NOTES (no push; not worth churning the branch): (a) UserAvatarKey (avatar.go:117) uses normPrincipal without @→%40 encoding while ProfileKey encodes — inert today (all production callers pass @-free usernames; S3/fs/GCS all accept @; email-spelling corners fail closed to 404/403), but consider encodePrincipal for one-prefix-per-user consistency. (b) No server-layer test pins that authCallback fires AvatarHook — consider a small harness test. (c) POST/DELETE gate uses direct name comparison rather than matchPrincipal, so legacy-email URL spellings 403 even for self — fail-closed, acceptable post-#370. MERGE RECOMMENDATION: ready to merge.
Author
Owner

Fixed by PR #379 (review clean — law-1 amendment + transitives verified, preset deviation confirmed honest, seed-escaping live-checked, opt-out honored), merged. Closing.

Fixed by PR #379 (review clean — law-1 amendment + transitives verified, preset deviation confirmed honest, seed-escaping live-checked, opt-out honored), merged. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#376
No description provided.