Release v0.6.2 #33

Merged
Mike Bros merged 45 commits from release/0.6.2 into main 2026-02-28 16:33:40 +00:00
Contributor

Release v0.6.2 — Hardening & Kiosk Mode

Epics

  • OP#1763 feat: kiosk operation mode — settings/middleware, featured dashboard snapshot/restore, per-IP deletion tracking, daily reset, dashboard protection, setup wizard, test coverage
  • OP#1795 fix: access control & session security — token coverage, OIDC warnings, periodic revalidation, guest read-only, rate limiter caps
  • OP#1794 fix: hardening & review findings — auth validation, SSE resources, frontend lifecycle, accessibility, Chart.js SRI, migration cleanup
  • OP#1793 feat: theme system improvements — live preview, admin theme override
  • OP#1792 fix: setup & first-run flow bugs — cache invalidation, toolbar visibility
  • OP#1824 feat: user-facing dashboard import from Dashy config

Changes Since Original PR

  • OP#1835: Set dashboard as server default from admin panel
  • OP#1836: Auto-create server default dashboard when none exists
  • OP#1832: Footer component reorder and live preview in admin settings
  • OP#1839: Live composite footer preview
  • OP#1834: Live theme preview on admin settings page
  • OP#1833: Invalidate settings cache after UpdateSettings
  • OP#1831: Toolbar overflow extending page horizontally
  • OP#1840: Trigger immediate status checks for new/imported items
  • OP#1841: Enable status checks by default and log when disabled
  • OP#1838: Fix duplicate status element IDs from edit/view mode dual rendering
  • OP#1857: Fix SSE endpoint 500 due to missing http.Flusher on statusWriter
  • OP#1858: Fix polling timers perpetually reset by SSE reconnect errors

Deferred to v0.7.0

  • OP#1830: Make admin impersonation available in all auth modes
  • OP#1837: Fix imported dashboard not activated after user import

Checklist

  • All version tasks closed in Gravity PM (53 closed, 2 deferred)
  • Tests passing (go test ./...)
  • Lint passing (golangci-lint run ./...)
  • Formatting clean (templ generate && goimports && gofumpt)
  • manifest.json version matches 0.6.2
  • Draft release created

Stats

  • 42 commits, 53 tasks, 6 epics
  • ~95 SP total

References

Version: v0.6.2 — Hardening & Kiosk Mode (Gravity PM)

## Release v0.6.2 — Hardening & Kiosk Mode ### Epics - **OP#1763** feat: kiosk operation mode — settings/middleware, featured dashboard snapshot/restore, per-IP deletion tracking, daily reset, dashboard protection, setup wizard, test coverage - **OP#1795** fix: access control & session security — token coverage, OIDC warnings, periodic revalidation, guest read-only, rate limiter caps - **OP#1794** fix: hardening & review findings — auth validation, SSE resources, frontend lifecycle, accessibility, Chart.js SRI, migration cleanup - **OP#1793** feat: theme system improvements — live preview, admin theme override - **OP#1792** fix: setup & first-run flow bugs — cache invalidation, toolbar visibility - **OP#1824** feat: user-facing dashboard import from Dashy config ### Changes Since Original PR - OP#1835: Set dashboard as server default from admin panel - OP#1836: Auto-create server default dashboard when none exists - OP#1832: Footer component reorder and live preview in admin settings - OP#1839: Live composite footer preview - OP#1834: Live theme preview on admin settings page - OP#1833: Invalidate settings cache after UpdateSettings - OP#1831: Toolbar overflow extending page horizontally - OP#1840: Trigger immediate status checks for new/imported items - OP#1841: Enable status checks by default and log when disabled - OP#1838: Fix duplicate status element IDs from edit/view mode dual rendering - OP#1857: Fix SSE endpoint 500 due to missing http.Flusher on statusWriter - OP#1858: Fix polling timers perpetually reset by SSE reconnect errors ### Deferred to v0.7.0 - OP#1830: Make admin impersonation available in all auth modes - OP#1837: Fix imported dashboard not activated after user import ### Checklist - [x] All version tasks closed in Gravity PM (53 closed, 2 deferred) - [x] Tests passing (`go test ./...`) - [x] Lint passing (`golangci-lint run ./...`) - [x] Formatting clean (`templ generate && goimports && gofumpt`) - [x] `manifest.json` version matches `0.6.2` - [x] Draft release created ### Stats - 42 commits, 53 tasks, 6 epics - ~95 SP total ### References Version: v0.6.2 — Hardening & Kiosk Mode (Gravity PM)
Add InvalidateSettings() calls after the app_settings UPDATE in all
three setup paths (local, OIDC, skip) to prevent stale 30s cache
from causing redirect loops back to /setup.

Closes OP#1796

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wrap the toolbar row and swoop in a CanEdit || ShowCloneButton
conditional so guest/unauthenticated views don't render an empty
decorative strip.

Closes OP#1798

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 7 tests verifying InvalidateSettings is called on success paths
(local, OIDC, skip) and NOT called on failure paths (user creation
fails, settings update fails, provider upsert fails, exec fails).

Closes OP#1797

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Simplify theme resolution in AdminHandler.baseData() to skip user
theme and preference lookups, always using the server default theme
for consistent admin UX.

Closes OP#1800

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add data-css-vars and data-is-dark attributes to theme radio labels
with an inline Alpine @change handler that applies CSS variables to
:root on selection, giving instant visual feedback without saving.
Navigating away reverts naturally via page reload.

Closes OP#1799

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verify admin pages always use server default theme regardless of
user theme preferences, and fall back to first builtin when no
server default is configured.

Closes OP#1801

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Block POST/PUT/DELETE/PATCH and /admin paths for unauthenticated
users in guest mode. Authenticated users retain full access.
Prevents unintended dashboard mutations in guest mode.

Closes OP#1762

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 10k entry cap with oldest-entry eviction to all three rate limit
maps (RateLimiter.visitors, LocalAuthHandler.attempts,
ResetHandler.attempts) to prevent unbounded memory growth under
sustained attack between cleanup cycles.

Closes OP#1764

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a background goroutine that scans OIDC sessions every 10 minutes
and revalidates those older than 32 hours. Sessions whose IdP provider
has been disabled are destroyed immediately. When a valid access token
is available, user groups and admin status are refreshed from the
userinfo endpoint.

- Migration 00033 adds revalidated_at column to sessions table
- OIDC callback now stores access_token in session data
- FetchUserInfo method on OIDCProvider for userinfo endpoint calls
- Tests cover userinfo parsing, admin detection, and token validation

Closes OP#1761

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Cap password at 72 chars (bcrypt silently truncates beyond this)
- Replace weak email check (@ + .) with stricter regex pattern
- Log invite metadata JSON unmarshal errors instead of swallowing
- Confirm resend already returns identical responses (no change needed)

Closes OP#1669

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Close Events channel in Unsubscribe to prevent goroutine leaks
- Handle closed channel in SSE handler event loop
- Send retry: 5000 field to set 5-second reconnect delay
- Max client count was already enforced (DefaultMaxConns=1000)

Closes OP#1670

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Re-scan DOM in polling timer callbacks to avoid referencing removed elements
- Flatten animation promise chain with .catch() to reset animating flag on errors
- Resize listener already cleaned up via Alpine destroy() hook (no change needed)

Closes OP#1671

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Tab controls: role="tablist"/role="tab", aria-selected, aria-label on scroll buttons
- Mobile tab dropdown: role="navigation", aria-haspopup, aria-expanded, role="menu"/role="menuitem"
- Modal: role="dialog", aria-modal, aria-labelledby, focus trap via Tab key interception
- Confirm modal: role="alertdialog", aria-describedby, focus trap, auto-focus on open
- Close button: aria-label="Close dialog" instead of title-only

Closes OP#1672

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Pin Chart.js CDN to v4.5.1 with SHA-384 integrity attribute for supply
  chain protection
- Remove HandleImpersonation middleware from production middleware chain
- Gate impersonation routes (/admin/impersonate, /impersonate/stop)
  behind DevAuth flag so they are not registered in production builds

Closes OP#1673

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Migration 00028 added page-level transition columns that 00030
immediately moved to dashboard-level and dropped. On fresh installs
this was wasted work. Now 00028 is a no-op placeholder preserving the
goose version chain, and 00030 uses IF EXISTS for the column drops.

Closes OP#1674

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- ListTokensByType: returns matching tokens, returns empty for no matches
- InvalidateTokens: marks unused tokens as used, no error when none exist

Closes OP#1759

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When OIDC_ENCRYPT_KEY is set, check oidc_providers for client_secret
values missing the "enc:" prefix and log a warning with the count of
affected providers, guiding admins to re-save them in the admin UI.

Closes OP#1760

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add "kiosk" as a new OperationMode value. Kiosk mode provides public
read-only access to the featured dashboard, demo pages, SSE events,
and status endpoints. Admin routes are always blocked. Only GET/HEAD
plus POST /status/batch and DELETE (for item deletion tracking) are
allowed on permitted paths.

- EnforceMode middleware handles kiosk case with isKioskAllowedPath
- Admin settings dropdown includes kiosk option with description
- 9 kiosk-specific tests + isKioskAllowedPath table test

Closes OP#1784

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add KioskService with TakeSnapshot/RestoreFromSnapshot for daily kiosk
resets, plus DeleteGuestDashboards, HasSnapshot, and FeaturedDashboardID
helpers. Includes migration adding kiosk_snapshot JSONB column to
app_settings and comprehensive pgxmock test coverage.

Closes OP#1785

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add DeletionTracker with per-IP counting (max 20 deletions/day, 10k IP
cap) and KioskItemDelete handler for anonymous item deletion in kiosk
mode. Wire into routing so unauthenticated DELETE requests use the
kiosk handler with limit enforcement.

Closes OP#1786

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add StartDailyReset to KioskService that periodically restores the
featured dashboard from snapshot, deletes guest dashboards, and resets
per-IP deletion counters. Starts automatically when in kiosk mode with
a stored snapshot, stops cleanly on context cancellation.

Closes OP#1787

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Block DELETE on dashboards, pages, and sections in kiosk mode as
defense-in-depth — even authenticated users cannot delete structural
elements. Item deletion remains allowed within the per-IP limit.

Closes OP#1788

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verify /demo/ and /demo/api/* paths are accessible in kiosk mode with
explicit test cases. The /demo/ prefix was already whitelisted in the
kiosk allowed paths from OP#1784.

Closes OP#1789

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add kiosk as a selectable operation mode in the OIDC setup wizard with
descriptive text explaining public read-only access, deletion limits,
and daily reset. Selecting kiosk triggers an initial snapshot of the
featured dashboard.

Closes OP#1790

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add PUT/PATCH blocking tests for kiosk middleware, service error
handling for item deletion, concurrent access safety for DeletionTracker,
and verify all four acceptance scenarios: middleware enforcement, deletion
limits, dashboard structure protection, and daily reset cycle.

Closes OP#1791

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
chore(release): prepare v0.6.2 release
Some checks failed
CI / validate-branch (pull_request) Successful in 1s
CI / validate-release-pr (pull_request) Failing after 4s
CI / security (pull_request) Successful in 42s
CI / build (pull_request) Successful in 1m38s
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Failing after 7m56s
101029a170
Bump manifest.json to 0.6.2. Fix user groups parameter to pass
[]string directly to pgx instead of JSON-marshaling to []byte,
and update integration test expectations to match.

Closes OP#1803

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author
Contributor

PR Review

Verdict: Acceptable

This release covers a broad surface — kiosk mode, OIDC session revalidation, security hardening, accessibility, and several bug fixes across 26 commits. The code is well-structured, follows project conventions, and has solid test coverage for new features. No blocking issues found.

Findings

Severity Count
Blocking 0
Important 4
Minor 7
Nitpick 5

Test Results

  • Tests: All packages pass (21 packages, 0 failures)
  • Lint: Clean (golangci-lint run ./...)
  • Formatting: Clean (templ generate && goimports && gofumpt)

Human Review Items

2 items flagged for human review — see items marked with HUMAN REVIEW.

Key Findings

  1. [IMPORTANT] DeletionTracker bypassed at size cap — internal/handlers/kiosk.go
  2. [IMPORTANT] clientIP() ignores X-Forwarded-For behind reverse proxy — internal/handlers/kiosk.go
  3. [IMPORTANT] Silent DB error suppression in session revalidation — internal/auth/revalidate.go
  4. [IMPORTANT] Kiosk DELETE allowed on all kiosk paths, not just items — internal/middleware/mode.go

internal/handlers/kiosk.go

  • [IMPORTANT] Lines 31-48: DeletionTracker bypass at size cap. When the tracker map reaches 10,000 entries, Record() silently drops new IPs (line 44). However, Allow() returns true for unknown IPs (count 0 < 20). This means any IP arriving after the 10,000th gets unlimited deletions until the daily reset. Suggested fix: Allow() should return false when the map is full and the IP is not already tracked:

    func (dt *DeletionTracker) Allow(ip string) bool {
        dt.mu.Lock()
        defer dt.mu.Unlock()
        _, exists := dt.count[ip]
        if !exists && len(dt.count) >= kioskMaxTracked {
            return false
        }
        return dt.count[ip] < kioskMaxDeletions
    }
    
  • [IMPORTANT] Lines 119-125: clientIP() ignores X-Forwarded-For. Behind Caddy (per the Gravity Stack architecture), r.RemoteAddr will always be the proxy's IP. All kiosk visitors share a single 20-deletion pool instead of getting individual limits. The same issue exists in ratelimit.go and local.go — this is a pre-existing pattern, but kiosk mode makes it more impactful since per-IP tracking is the primary access control.

    HUMAN REVIEW: Decide whether to trust X-Forwarded-For from Caddy (requires knowing that no untrusted proxy sits in front). If Caddy is always the direct upstream, X-Forwarded-For is safe to trust. This would be a cross-cutting change affecting the rate limiter, login tracker, and deletion tracker.


internal/auth/revalidate.go

  • [IMPORTANT] Lines 97, 105, 114-115, 123: Silent DB error suppression. Five _, _ = m.pool.Exec(...) calls discard both the command tag and error. If a session DELETE or UPDATE fails (transient DB error, connection timeout), the failure is completely invisible. At minimum, log at WARN level:

    if _, err := m.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, ss.id); err != nil {
        slog.Warn("session.revalidation.delete_failed", "session_id", ss.id, "error", err)
    }
    
  • [MINOR] Lines 121-124: When the access token is empty or userinfo fails, revalidated_at is bumped and the return value is "refreshed". This means sessions with expired/missing tokens are counted as "refreshed" in the sweep stats (line 84), which is misleading. A separate status like "skipped" would give more accurate telemetry.

  • [MINOR] Line 70: _ = json.Unmarshal(rawData, &ss.data) silently ignores unmarshal errors. If session data is corrupted, the session proceeds with an empty map, eventually getting destroyed when provider_id fails UUID parse. The self-healing is fine, but the silent discard means corrupted session data is never logged.


internal/middleware/mode.go

  • [IMPORTANT] Line 109: Kiosk DELETE allowed too broadly. r.Method == http.MethodDelete passes through for any kiosk-allowed path — including /static/, /events/, /search, /demo/. DELETE was intended only for item deletion (/sections/{id}/items/{id}). Downstream handlers likely return 404/405, but for defense-in-depth, restrict DELETE to item paths:

    (r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/sections/"))
    
  • [MINOR] Lines 84-94: Guest mode now blocks mutations for unauthenticated users. This is a behavioral change — previously guest mode allowed all operations. Any guest-mode deployment where unauthenticated users were creating/modifying content will break. This should be called out in the release notes.

    HUMAN REVIEW: Confirm this breaking change is intentional and documented. Users upgrading from 0.6.1 in guest mode may be surprised by new 403 responses on POST/PUT/DELETE.


internal/services/kiosk.go

  • [MINOR] Lines 337-350: StartDailyReset passes the parent ctx to runReset. When ctx is cancelled, Go's random select may fire ticker.C before ctx.Done(), causing runReset to execute with a cancelled context. DB operations will fail and log errors during graceful shutdown. Adding if ctx.Err() != nil { return } at the top of runReset would silence the noise.

  • [NITPICK] Lines 73-168: TakeSnapshot uses N+1 queries (pages → sections per page → items per section). Acceptable for a one-time operation at setup, but a single join query would be more efficient for large dashboards.


internal/services/password.go

  • [MINOR] Line 18: Error message says "at most 72 characters" but len(password) checks bytes, not characters. Multi-byte UTF-8 characters (emoji, CJK) would hit the limit at fewer visible characters. The validation is correct (bcrypt operates on bytes), but the error message is misleading. Suggest: "at most 72 bytes" or utf8.RuneCountInString with "characters".

internal/handlers/sse.go / internal/sse/hub.go

  • [NITPICK] The close(c.Events) in Unsubscribe (hub.go:61) is safe because Broadcast holds RLock while Unsubscribe holds the exclusive Lock — they cannot execute concurrently. Good use of the RWMutex pattern. The corresponding ok check in sse.go is correct.

components/layouts/base.templ

  • [NITPICK] Lines ~162-163: The confirm modal focus trap only cycles through button elements. The generic modal in partials/modal.templ has a more comprehensive selector (a[href], button, textarea, input, select, [tabindex]). If the confirm modal ever adds inputs, the focus trap would miss them. Consider using the same comprehensive selector.

components/partials/user_settings.templ

  • [MINOR] Theme preview @change handler has try { ... } catch(e) {} that silently swallows JSON parse errors. Adding console.warn in the catch would aid debugging without changing behavior.

static/js/page-prefetch.js

  • [MINOR] The .catch() handler (end of animatedSwap) resets the animating flag but silently swallows all errors. Adding console.error would help diagnose animation failures in production.

internal/handlers/register.go

  • [NITPICK] Line 7: Email regex emailRe rejects some RFC 5321 valid addresses (IDN, quoted local parts). This is standard practice and fine for the project scope. Good improvement over the previous contains("@") check.

internal/services/password_test.go

  • [NITPICK] Lines 21-25 in TestRejectsTooLong: Convoluted string construction via byte replacement when strings.Repeat("a", 73) would be clearer. The AcceptsMaxLength test below uses a simpler pattern.

Review conducted from a fresh context against the current release/0.6.2 branch state (26 commits, ~40 changed files). All findings are new.

## PR Review **Verdict: Acceptable** This release covers a broad surface — kiosk mode, OIDC session revalidation, security hardening, accessibility, and several bug fixes across 26 commits. The code is well-structured, follows project conventions, and has solid test coverage for new features. No blocking issues found. ### Findings | Severity | Count | | -------- | ----- | | Blocking | 0 | | Important | 4 | | Minor | 7 | | Nitpick | 5 | ### Test Results - **Tests:** All packages pass (21 packages, 0 failures) - **Lint:** Clean (`golangci-lint run ./...`) - **Formatting:** Clean (`templ generate && goimports && gofumpt`) ### Human Review Items 2 items flagged for human review — see items marked with **HUMAN REVIEW**. ### Key Findings 1. **[IMPORTANT]** DeletionTracker bypassed at size cap — `internal/handlers/kiosk.go` 2. **[IMPORTANT]** `clientIP()` ignores `X-Forwarded-For` behind reverse proxy — `internal/handlers/kiosk.go` 3. **[IMPORTANT]** Silent DB error suppression in session revalidation — `internal/auth/revalidate.go` 4. **[IMPORTANT]** Kiosk DELETE allowed on all kiosk paths, not just items — `internal/middleware/mode.go` --- ### `internal/handlers/kiosk.go` - **[IMPORTANT]** Lines 31-48: **DeletionTracker bypass at size cap.** When the tracker map reaches 10,000 entries, `Record()` silently drops new IPs (line 44). However, `Allow()` returns `true` for unknown IPs (count `0 < 20`). This means any IP arriving after the 10,000th gets unlimited deletions until the daily reset. **Suggested fix:** `Allow()` should return `false` when the map is full and the IP is not already tracked: ```go func (dt *DeletionTracker) Allow(ip string) bool { dt.mu.Lock() defer dt.mu.Unlock() _, exists := dt.count[ip] if !exists && len(dt.count) >= kioskMaxTracked { return false } return dt.count[ip] < kioskMaxDeletions } ``` - **[IMPORTANT]** Lines 119-125: **`clientIP()` ignores `X-Forwarded-For`.** Behind Caddy (per the Gravity Stack architecture), `r.RemoteAddr` will always be the proxy's IP. All kiosk visitors share a single 20-deletion pool instead of getting individual limits. The same issue exists in `ratelimit.go` and `local.go` — this is a pre-existing pattern, but kiosk mode makes it more impactful since per-IP tracking is the primary access control. **HUMAN REVIEW:** Decide whether to trust `X-Forwarded-For` from Caddy (requires knowing that no untrusted proxy sits in front). If Caddy is always the direct upstream, `X-Forwarded-For` is safe to trust. This would be a cross-cutting change affecting the rate limiter, login tracker, and deletion tracker. --- ### `internal/auth/revalidate.go` - **[IMPORTANT]** Lines 97, 105, 114-115, 123: **Silent DB error suppression.** Five `_, _ = m.pool.Exec(...)` calls discard both the command tag and error. If a session DELETE or UPDATE fails (transient DB error, connection timeout), the failure is completely invisible. At minimum, log at WARN level: ```go if _, err := m.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, ss.id); err != nil { slog.Warn("session.revalidation.delete_failed", "session_id", ss.id, "error", err) } ``` - **[MINOR]** Lines 121-124: When the access token is empty or userinfo fails, `revalidated_at` is bumped and the return value is `"refreshed"`. This means sessions with expired/missing tokens are counted as "refreshed" in the sweep stats (line 84), which is misleading. A separate status like `"skipped"` would give more accurate telemetry. - **[MINOR]** Line 70: `_ = json.Unmarshal(rawData, &ss.data)` silently ignores unmarshal errors. If session data is corrupted, the session proceeds with an empty map, eventually getting destroyed when `provider_id` fails UUID parse. The self-healing is fine, but the silent discard means corrupted session data is never logged. --- ### `internal/middleware/mode.go` - **[IMPORTANT]** Line 109: **Kiosk DELETE allowed too broadly.** `r.Method == http.MethodDelete` passes through for any kiosk-allowed path — including `/static/`, `/events/`, `/search`, `/demo/`. DELETE was intended only for item deletion (`/sections/{id}/items/{id}`). Downstream handlers likely return 404/405, but for defense-in-depth, restrict DELETE to item paths: ```go (r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/sections/")) ``` - **[MINOR]** Lines 84-94: **Guest mode now blocks mutations for unauthenticated users.** This is a behavioral change — previously guest mode allowed all operations. Any guest-mode deployment where unauthenticated users were creating/modifying content will break. This should be called out in the release notes. **HUMAN REVIEW:** Confirm this breaking change is intentional and documented. Users upgrading from 0.6.1 in guest mode may be surprised by new 403 responses on POST/PUT/DELETE. --- ### `internal/services/kiosk.go` - **[MINOR]** Lines 337-350: `StartDailyReset` passes the parent `ctx` to `runReset`. When ctx is cancelled, Go's random select may fire `ticker.C` before `ctx.Done()`, causing `runReset` to execute with a cancelled context. DB operations will fail and log errors during graceful shutdown. Adding `if ctx.Err() != nil { return }` at the top of `runReset` would silence the noise. - **[NITPICK]** Lines 73-168: `TakeSnapshot` uses N+1 queries (pages → sections per page → items per section). Acceptable for a one-time operation at setup, but a single join query would be more efficient for large dashboards. --- ### `internal/services/password.go` - **[MINOR]** Line 18: Error message says "at most 72 characters" but `len(password)` checks bytes, not characters. Multi-byte UTF-8 characters (emoji, CJK) would hit the limit at fewer visible characters. The validation is correct (bcrypt operates on bytes), but the error message is misleading. Suggest: "at most 72 bytes" or `utf8.RuneCountInString` with "characters". --- ### `internal/handlers/sse.go` / `internal/sse/hub.go` - **[NITPICK]** The `close(c.Events)` in `Unsubscribe` (hub.go:61) is safe because `Broadcast` holds `RLock` while `Unsubscribe` holds the exclusive `Lock` — they cannot execute concurrently. Good use of the `RWMutex` pattern. The corresponding `ok` check in sse.go is correct. --- ### `components/layouts/base.templ` - **[NITPICK]** Lines ~162-163: The confirm modal focus trap only cycles through `button` elements. The generic modal in `partials/modal.templ` has a more comprehensive selector (`a[href], button, textarea, input, select, [tabindex]`). If the confirm modal ever adds inputs, the focus trap would miss them. Consider using the same comprehensive selector. --- ### `components/partials/user_settings.templ` - **[MINOR]** Theme preview `@change` handler has `try { ... } catch(e) {}` that silently swallows JSON parse errors. Adding `console.warn` in the catch would aid debugging without changing behavior. --- ### `static/js/page-prefetch.js` - **[MINOR]** The `.catch()` handler (end of `animatedSwap`) resets the `animating` flag but silently swallows all errors. Adding `console.error` would help diagnose animation failures in production. --- ### `internal/handlers/register.go` - **[NITPICK]** Line 7: Email regex `emailRe` rejects some RFC 5321 valid addresses (IDN, quoted local parts). This is standard practice and fine for the project scope. Good improvement over the previous `contains("@")` check. --- ### `internal/services/password_test.go` - **[NITPICK]** Lines 21-25 in `TestRejectsTooLong`: Convoluted string construction via byte replacement when `strings.Repeat("a", 73)` would be clearer. The `AcceptsMaxLength` test below uses a simpler pattern. --- *Review conducted from a fresh context against the current `release/0.6.2` branch state (26 commits, ~40 changed files). All findings are new.*
Gravity Bot changed title from Release v0.6.2 — Hardening & Kiosk Mode to Release v0.6.2 2026-02-23 23:06:57 +00:00
fix(test): resolve data race in TestStartDailyReset_RunsAndStops
All checks were successful
CI / validate-branch (pull_request) Successful in 2s
CI / build (pull_request) Successful in 1m30s
CI / security (pull_request) Successful in 2m9s
CI / validate-release-pr (pull_request) Successful in 1m54s
CI / lint (pull_request) Successful in 7m29s
CI / test (pull_request) Successful in 5m53s
092f656bd6
Use atomic.Bool for mockTracker.resetCalled to prevent data race
between the daily reset goroutine writing the flag and the test
goroutine reading it. Detected by CI race detector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allow() now returns false when the tracker is at capacity and the
requesting IP is not already tracked, preventing unlimited deletions
for IPs that arrive after the 10,000-entry cap.

Closes OP#1813

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace five _, _ = pool.Exec(...) calls with error-checked versions
that log at WARN level when session DELETE or UPDATE operations fail.

Closes OP#1814

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: restrict kiosk DELETE to /sections/ paths only
All checks were successful
CI / validate-release-pr (pull_request) Successful in 4s
CI / validate-branch (pull_request) Successful in 1s
CI / build (pull_request) Successful in 49s
CI / security (pull_request) Successful in 1m25s
CI / lint (pull_request) Successful in 4m4s
CI / test (pull_request) Successful in 6m52s
c6aa4e0544
Previously DELETE was allowed on any kiosk-allowed path. Now it is
scoped to /sections/ prefixed paths where item deletion is intended.

Closes OP#1815

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author
Contributor

PR Review Followup

Responding to review posted on 2026-02-23 (review comment).

Triage Summary

Severity Count Action
Blocking 0
Important 4 3 fixed in this branch, 1 flagged for human review
Minor 7 Deferred to v0.7.0
Nitpick 5 3 deferred to v0.7.0, 2 informational (no WP needed)
Human Review 2 Flagged — requires human judgment

Fixes Applied

The following findings have been resolved on this branch:

  1. [IMPORTANT] DeletionTracker bypass when map reaches size cap — fixed in 5edd417
    • OP#1813: fix: DeletionTracker bypass when map reaches size cap (kiosk.go)
  2. [IMPORTANT] Silent DB error suppression in session revalidation — fixed in 03f3be5
    • OP#1814: fix: silent DB error suppression in session revalidation (revalidate.go)
  3. [IMPORTANT] Kiosk DELETE allowed on all kiosk paths, not just items — fixed in c6aa4e0
    • OP#1815: fix: restrict kiosk DELETE to item paths only (mode.go)

Deferred to v0.7.0

The following items have been tracked for the next version:

  1. [MINOR] Improve session revalidation error handling and status reporting
  2. [MINOR] Guard StartDailyReset against cancelled context on shutdown
  3. [MINOR] Password length error message says characters but checks bytes
  4. [MINOR] Add error logging in frontend catch blocks (theme preview, prefetch)
  5. [NITPICK] Optimize TakeSnapshot to use join query instead of N+1
  6. [NITPICK] Use comprehensive focus trap selector in confirm modal
  7. [NITPICK] Simplify test string construction in password_test.go

Human Review Items (Unchanged)

These items require human judgment and were not auto-triaged:

  1. HUMAN REVIEW: clientIP() ignores X-Forwarded-For behind reverse proxy — decide whether to trust X-Forwarded-For from Caddy. This is a cross-cutting change affecting the rate limiter, login tracker, and deletion tracker.
  2. HUMAN REVIEW: Guest mode now blocks mutations for unauthenticated users — confirm this breaking change is intentional and documented. Users upgrading from 0.6.1 in guest mode may see new 403 responses on POST/PUT/DELETE.

Test Results

  • Tests: All 21 packages pass, 0 failures
  • Lint: Clean (golangci-lint run ./...)
  • All fixes verified: Yes

This followup was generated by the pr-followup skill.

## PR Review Followup Responding to review posted on 2026-02-23 ([review comment](https://git.bros.ninja/mike/gashy/pulls/33#issuecomment-788)). ### Triage Summary | Severity | Count | Action | | -------- | ----- | ------ | | Blocking | 0 | — | | Important | 4 | 3 fixed in this branch, 1 flagged for human review | | Minor | 7 | Deferred to v0.7.0 | | Nitpick | 5 | 3 deferred to v0.7.0, 2 informational (no WP needed) | | Human Review | 2 | Flagged — requires human judgment | ### Fixes Applied The following findings have been resolved on this branch: 1. **[IMPORTANT]** DeletionTracker bypass when map reaches size cap — fixed in `5edd417` - [OP#1813](https://project.bros.ninja/work_packages/1813): fix: DeletionTracker bypass when map reaches size cap (kiosk.go) 2. **[IMPORTANT]** Silent DB error suppression in session revalidation — fixed in `03f3be5` - [OP#1814](https://project.bros.ninja/work_packages/1814): fix: silent DB error suppression in session revalidation (revalidate.go) 3. **[IMPORTANT]** Kiosk DELETE allowed on all kiosk paths, not just items — fixed in `c6aa4e0` - [OP#1815](https://project.bros.ninja/work_packages/1815): fix: restrict kiosk DELETE to item paths only (mode.go) ### Deferred to v0.7.0 The following items have been tracked for the next version: 1. **[MINOR]** Improve session revalidation error handling and status reporting - [OP#1816](https://project.bros.ninja/work_packages/1816) 2. **[MINOR]** Guard StartDailyReset against cancelled context on shutdown - [OP#1817](https://project.bros.ninja/work_packages/1817) 3. **[MINOR]** Password length error message says characters but checks bytes - [OP#1818](https://project.bros.ninja/work_packages/1818) 4. **[MINOR]** Add error logging in frontend catch blocks (theme preview, prefetch) - [OP#1819](https://project.bros.ninja/work_packages/1819) 5. **[NITPICK]** Optimize TakeSnapshot to use join query instead of N+1 - [OP#1820](https://project.bros.ninja/work_packages/1820) 6. **[NITPICK]** Use comprehensive focus trap selector in confirm modal - [OP#1821](https://project.bros.ninja/work_packages/1821) 7. **[NITPICK]** Simplify test string construction in password_test.go - [OP#1822](https://project.bros.ninja/work_packages/1822) ### Human Review Items (Unchanged) These items require human judgment and were not auto-triaged: 1. **HUMAN REVIEW:** `clientIP()` ignores `X-Forwarded-For` behind reverse proxy — decide whether to trust `X-Forwarded-For` from Caddy. This is a cross-cutting change affecting the rate limiter, login tracker, and deletion tracker. 2. **HUMAN REVIEW:** Guest mode now blocks mutations for unauthenticated users — confirm this breaking change is intentional and documented. Users upgrading from 0.6.1 in guest mode may see new 403 responses on POST/PUT/DELETE. ### Test Results - **Tests:** All 21 packages pass, 0 failures - **Lint:** Clean (`golangci-lint run ./...`) - **All fixes verified:** Yes --- _This followup was generated by the pr-followup skill._
fix: use X-Forwarded-For for client IP behind reverse proxy
All checks were successful
CI / validate-branch (pull_request) Successful in -1s
CI / security (pull_request) Successful in 1m23s
CI / validate-release-pr (pull_request) Successful in 4s
CI / build (pull_request) Successful in 46s
CI / lint (pull_request) Successful in 3m46s
CI / test (pull_request) Successful in 2m54s
2d9191a554
All three clientIP() implementations (rate limiter, login tracker,
deletion tracker) now check X-Forwarded-For first, using the leftmost
IP. Falls back to RemoteAddr when no proxy header is present.

Closes OP#1823

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat(import): add user-facing dashboard import from Dashy config
Some checks failed
CI / validate-branch (pull_request) Successful in 47s
CI / security (pull_request) Failing after 1m2s
CI / build (pull_request) Failing after 1m6s
CI / validate-release-pr (pull_request) Successful in 1m10s
CI / lint (pull_request) Failing after 1m44s
CI / test (pull_request) Has been skipped
537d00ff08
Add authenticated (non-admin) import flow that reuses the existing
ImportService with hardcoded safe defaults: mode="new" and
updateSettings=false. Server-level settings (logo, footer, OIDC) are
never modified regardless of form input. Dashboard is owned by the
importing user.

New routes: GET/POST /dashboards/import, POST /dashboards/import/preview
Navigation links added to header dropdown, dashboard list, settings
page, and new-dashboard form.

Closes OP#1825, closes OP#1826, closes OP#1827, closes OP#1828

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
test(import): add user import access control and isolation tests
Some checks failed
CI / validate-branch (pull_request) Successful in 0s
CI / security (pull_request) Failing after 40s
CI / test (pull_request) Has been skipped
CI / validate-release-pr (pull_request) Successful in 3s
CI / build (pull_request) Failing after 1m5s
CI / lint (pull_request) Failing after 4m7s
3bb78a7017
10 tests covering: auth requirement (GET redirects, POST returns 401),
mode enforcement (always "new" regardless of form input), settings
isolation (updateSettings always false), owner assignment, dashboard
name passthrough, empty data error, file upload, and preview endpoint.

Closes OP#1829

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat(status): trigger immediate status checks for new/imported items
Some checks failed
CI / build (pull_request) Failing after 49s
CI / security (pull_request) Failing after 1m2s
CI / validate-branch (pull_request) Successful in 1m30s
CI / validate-release-pr (pull_request) Successful in 1m34s
CI / lint (pull_request) Failing after 1m41s
CI / test (pull_request) Has been skipped
24acaf1f0b
Fire CheckSingleItem in background goroutines after dashboard import
and item create/update with status_check enabled, so indicators update
within seconds instead of waiting for the next 5-minute checker cycle.

OP#1840

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ensures downstream middleware sees updated values immediately instead
of serving stale cached settings until the next TTL expiry.

OP#1833

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Split StatusIndicator into statusIndicatorVisual + tooltip so the
tooltip div renders as a sibling instead of nested inside the span.
Prevents DOMParser from restructuring the tree and breaking OOB swap
processing in status-batch.js.

OP#1838

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add overflow-x-hidden to the toolbar flex container and right padding
to prevent action buttons from causing horizontal scroll.

OP#1831

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat: set dashboard as server default and auto-create when missing
All checks were successful
CI / validate-branch (pull_request) Successful in 1s
CI / validate-release-pr (pull_request) Successful in 5s
CI / build (pull_request) Successful in 1m3s
CI / security (pull_request) Successful in 1m43s
CI / lint (pull_request) Successful in 5m17s
CI / test (pull_request) Successful in 5m13s
7cc2e60fa4
Add SetDashboardAsDefault admin handler with "Set as Default" button
on the admin dashboard overview. Add CreateServerDefaultDashboard and
SetServerDefaultDashboard service methods. When no server default
exists and an admin visits the index, auto-create one with a "Home"
page so the app is never stuck on an empty state.

OP#1835 OP#1836

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: enable status checks by default and log when disabled
Some checks failed
CI / validate-branch (pull_request) Successful in -1s
CI / validate-release-pr (pull_request) Successful in 2s
CI / security (pull_request) Successful in 1m53s
CI / build (pull_request) Successful in 2m11s
CI / lint (pull_request) Successful in 2m34s
CI / test (pull_request) Has been cancelled
07df0b96bd
status_check_enabled defaulted to false in the initial schema and the
setup wizard never flipped it. The background checker silently skipped
all cycles, so no status_cache entries were ever created — items showed
gray "pending" dots permanently.

- Migration 00035: set column default to true and enable on existing rows
- Add slog.Info when checker skips a cycle due to disabled setting

Closes OP#1841

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: add goose annotations to migration 00035
All checks were successful
CI / security (pull_request) Successful in 44s
CI / lint (pull_request) Successful in 5m37s
CI / validate-branch (pull_request) Successful in 0s
CI / validate-release-pr (pull_request) Successful in 4s
CI / build (pull_request) Successful in 53s
CI / test (pull_request) Successful in 3m8s
e4767998fe
Missing -- +goose Up/Down directives caused goose to reject the file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: status indicators stuck gray due to duplicate element IDs
All checks were successful
CI / validate-branch (pull_request) Successful in 1s
CI / validate-release-pr (pull_request) Successful in 6s
CI / build (pull_request) Successful in 1m36s
CI / lint (pull_request) Successful in 5m30s
CI / security (pull_request) Successful in 1m31s
CI / test (pull_request) Successful in 5m24s
b8a5a6a14d
The Item template rendered itemInner twice (edit-mode overlay + normal
view), both always present in the DOM via Alpine x-show. Each produced
a status span with the same id, so getElementById found the hidden
edit-mode copy and left the visible one unchanged.

Add withStatus bool param to itemInner — edit mode skips status
elements entirely. Also fix SSE 500 (statusWriter missing Flusher
interface) and polling timer reset on every SSE reconnect attempt.

Closes OP#1838 OP#1857 OP#1858

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: border mode OOB style tag nested inside innerHTML swap span
All checks were successful
CI / validate-release-pr (pull_request) Successful in 3s
CI / security (pull_request) Successful in 45s
CI / lint (pull_request) Successful in 3m55s
CI / validate-branch (pull_request) Successful in 0s
CI / build (pull_request) Successful in 53s
CI / test (pull_request) Successful in 3m16s
4e668a3c0b
The StatusBatchItem template rendered border mode's <style> OOB tag
inside the <span hx-swap-oob="innerHTML"> parent. When the JS processed
the outer span first, it moved the style tag into the document, causing
getElementById to find the moved copy (self-swap) instead of the
original empty <style> target. The CSS was destroyed on self-clear.

Move border style and info OOB tags to be siblings of the status span,
matching the pattern already used for the tooltip OOB tag.

Also make `seed` idempotent by running `unseed` first and clearing
the server default flag before re-seeding.

Closes OP#1838

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: border mode style OOB swap destroyed by redundant processing pass
All checks were successful
CI / validate-release-pr (pull_request) Successful in 49s
CI / security (pull_request) Successful in 3m30s
CI / test (pull_request) Successful in 3m7s
CI / build (pull_request) Successful in 1m32s
CI / lint (pull_request) Successful in 7m22s
CI / validate-branch (pull_request) Successful in 1s
a6d9bea7d8
Two issues prevented border mode CSS from being applied:

1. DOMParser moves <style> tags to <head>, so querySelectorAll on
   doc.body never found them. Fix: reclaim styles from head before
   returning.

2. The styleEls loop ran AFTER the oobEls loop had already correctly
   moved the CSS text node into the target <style>. Since the source
   element was now empty, styleEls set target.textContent = "" —
   destroying the CSS. Fix: remove the redundant styleEls pass.

Closes OP#1838

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: status indicators not loading after page tab switch
All checks were successful
CI / validate-branch (pull_request) Successful in 1s
CI / validate-release-pr (pull_request) Successful in 5s
CI / build (pull_request) Successful in 1m7s
CI / security (pull_request) Successful in 1m41s
CI / lint (pull_request) Successful in 4m5s
CI / test (pull_request) Successful in 7m5s
b9be52dcf1
page-switched event was dispatched before animatedSwap completed
the async DOM swap, so status-batch.js scanned old page content.
Move dispatch into afterSwap callback that runs after safeSetHTML.
Also reclaim DOMParser-relocated <style> tags for border mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mike Bros scheduled this pull request to auto merge when all checks succeed 2026-02-28 16:29:37 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
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
mike/gashy!33
No description provided.