User reported: "배포가 되면 인증이 풀린다" (auth resets after every
deployment).
Root cause: Program.cs had no explicit Data Protection configuration.
Without SetApplicationName, ASP.NET Core derives the key-ring
discriminator from the app's physical content root path. Every
deployment lands in a brand-new directory
(~/deployments/quantengine_{tag}_{hash}/), so the discriminator
changed on every single release. The cookie authentication ticket is
encrypted/signed via this key ring, so once the discriminator
changed, every previously-issued auth cookie became undecryptable --
forcing all logged-in users to authenticate again after each deploy,
even well inside their 12-hour ExpireTimeSpan.
Fix: explicit .SetApplicationName("QuantEngine") pins a stable
discriminator across deployments, and .PersistKeysToFileSystem points
at %LOCALAPPDATA%/quantengine-keys (Linux: ~/.local/share/quantengine-keys
via User=kjh2064 in the systemd unit) -- a location outside the
versioned deployment directories, so the actual key material also
survives every redeploy and service restart instead of only the
discriminator being stable.
Discovered while verifying the new Operations page against a local
instance (SSH-tunneled to prod Postgres): every startup logged
'Hangfire setup failed: Cannot resolve scoped service
QuantEngine.Web.Services.SchedulerService from root provider' and
silently skipped InitializeSchedules() entirely.
SchedulerService is registered AddScoped, but UseHangfireSetup()
resolved it directly from app.Services (the root/singleton-level
provider), which cannot construct scoped services without an active
scope. This has apparently been broken for a while -- the 4 recurring
jobs (daily-collection, hourly-price-update, weekly-report,
monthly-optimization) only kept showing up because Hangfire persists
recurring job definitions in PostgreSQL from whatever earlier
deployment last managed to register them; any code change to those
schedules would silently never take effect on redeploy.
Fixed by creating an explicit scope (serviceProvider.CreateScope())
before resolving SchedulerService. Verified locally: the warning is
gone and the log now shows "Hangfire schedules initialized
successfully" followed by the dispatchers starting.
Root cause: user asked why logout was missing. Playwright audit against
production found logout works fine, but surfaced two real defects and
led to a wider audit that found extensive fabricated data across the
admin pages -- none of it backed by the database despite CLAUDE.md's
policy that all data must come from DB records.
Layout (_AdminLayout.cshtml):
- Full rewrite using Tabler's actual navbar-vertical/page-wrapper/footer
component structure instead of ad-hoc inline CSS. The old layout had
no footer element at all, and its mobile breakpoint CSS hid the
sidebar off-screen (left: -260px) with no hamburger button to bring
it back -- verified via Playwright screenshot at 375px width that
the entire nav menu was inaccessible on mobile, leaving only Logout
reachable. Tabler's navbar-toggler + Bootstrap collapse (bundled in
tabler.min.js) now restores it; verified the toggle actually opens
the menu via Playwright.
- Active nav-link highlighting moved from client-side JS string
matching to a server-side Razor helper against Context.Request.Path.
Fake/hardcoded data removed or replaced with real DB/Hangfire state:
- Dashboard: deleted the "최근 시스템 이벤트" table (3 rows hardcoded
from DateTime.Now with fake descriptions like "시스템 초기화" /
"데이터베이스 백업" -- no backing table exists). Removed hardcoded
"정상"/"연결됨" status badges and "버전: v0.1.0"/"업타임: 정상";
replaced with a real IsDatabaseConnected flag (true only if the
page's actual DB queries succeeded) and the real
IWebHostEnvironment.EnvironmentName.
- Monitoring: removed hardcoded "API 서버: 운영 중" (no real signal
backs it) and wired "데이터베이스: 연결 정상/끊김" to the same
real success/failure state as the page's own DB calls.
- Operations: this page was entirely fabricated -- ScheduledJobs,
RecentExecutions, IsJobProcessorRunning, PendingJobsCount, and
StatusMessage were all static values with zero connection to
Hangfire, despite Hangfire actually running in production
(confirmed via journalctl: ServerWatchdog, RecurringJobScheduler
dispatchers active) with 4 real recurring jobs registered in
SchedulerService (daily-collection, hourly-price-update,
weekly-report, monthly-optimization). Rewrote to query
JobStorage.Current.GetConnection().GetRecurringJobs() and
GetMonitoringApi() directly: real scheduled jobs, real succeeded/
failed executions, real server count, real enqueued count. Verified
locally (SSH-tunneled to prod DB) that this now returns the actual
4 registered jobs with correct next-run times and one real
RunDailyCollectionAsync execution.
Also fixed the page-title duplication on Monitoring/Operations
(ViewData["Title"] included "- QuantEngine" AND the layout appended
it again -> "모니터링 - QuantEngine - QuantEngine" in the browser tab).
Separately discovered (not fixed in this commit, flagging for
follow-up): Hangfire's SchedulerService.InitializeSchedules() fails
every startup with "Cannot resolve scoped service 'SchedulerService'
from root provider" -- the 4 recurring jobs above still show up
because they persist from an earlier successful registration, but
re-registration is silently broken on every current boot.
Verified end-to-end with Playwright against a local instance (SSH
tunnel to production Postgres): login, all 5 admin pages render
without errors, mobile hamburger opens the sidebar, and Operations
shows genuine Hangfire data.
User caught this directly: it's already 2026-07-12 in Korea, but
Run #2008's release was tagged quant_20260711.3.7150737 -- the wrong
date.
Confirmed: UTC was still 2026-07-11 16:2x when KST was already
2026-07-12 01:2x (9-hour offset). prepare-release.yml computed
TODAY via `TZ=UTC date +%Y%m%d`, which is only "correct" if the team
operates on UTC -- but this project's production server logs,
deployment cadence, and team are all Korea Standard Time. Any release
cut between midnight and 9am KST would silently tag itself with
yesterday's date.
Fixed by using `TZ=Asia/Seoul date +%Y%m%d` instead.