fix(frontend): make profile URLs case-insensitive - #485
Conversation
Validation - scripts/ledger/check: Not applicable - scripts/ledger/ does not exist in this repository. - git fetch --no-tags origin main: PASS - packages/frontend/node_modules/.bin/vitest run: PASS, 154 tests - bun run lint: PASS, 0 errors, 7 warnings in pre-existing unrelated files - bun run build: PASS - git diff --check: PASS - git diff --cached --check: PASS Rollback - git revert HEAD
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Validation - scripts/ledger/check: Not applicable - scripts/ledger/ does not exist in this repository. - git fetch --no-tags origin main: PASS - packages/frontend/node_modules/.bin/vitest run __tests__/api/usersProfile.test.ts __tests__/lib/getLeaderboard.test.ts __tests__/lib/getLeaderboardAllTime.test.ts __tests__/lib/getUserEmbedStats.test.ts __tests__/lib/usernameLookup.test.ts: PASS, 23 tests - packages/frontend/node_modules/.bin/vitest run: PASS, 162 tests - bun run lint: PASS, 0 errors, 7 warnings in pre-existing unrelated files - bun run build: PASS - git diff --check: PASS - git diff --cached --check: PASS Rollback - git revert HEAD
Validation - scripts/ledger/check: Not applicable - scripts/ledger/ does not exist in this repository. - git fetch --no-tags origin main: PASS - packages/frontend/node_modules/.bin/vitest run __tests__/api/usersProfile.test.ts __tests__/lib/getLeaderboard.test.ts __tests__/lib/getLeaderboardAllTime.test.ts __tests__/lib/getUserEmbedStats.test.ts __tests__/lib/usernameLookup.test.ts: PASS, 24 tests - packages/frontend/node_modules/.bin/vitest run: PASS, 163 tests - bun run lint: PASS, 0 errors, 7 warnings in pre-existing unrelated files - bun run build: PASS - git diff --check: PASS - git diff --cached --check: PASS Rollback - git revert HEAD
Drizzle wraps every migration in a transaction, so this file uses CREATE UNIQUE INDEX rather than CREATE INDEX CONCURRENTLY. Document the trade-off and the manual escape hatch (run CONCURRENTLY outside Drizzle, then let the IF NOT EXISTS guard make this a no-op) so a future operator on a large users table has the runbook in-tree. Confidence: high Scope-risk: narrow
Case-insensitive username lookups can populate ISR-cached routes under multiple URL casings. revalidatePath only invalidates an exact path, so a 60-second window of stale data was possible whenever the canonical-case write didn't match the URL the requester originally typed. Centralize the per-username path list in revalidateUsernamePaths and call it for both canonical and lowercased forms from submit and settings/submitted-data. Confidence: high Scope-risk: narrow
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/frontend/src/lib/db/usernameLookup.ts">
<violation number="1" location="packages/frontend/src/lib/db/usernameLookup.ts:43">
P2: Revalidating only the raw and lowercased username leaves other mixed-case profile/API/embed URLs stale, because `revalidatePath` is case-sensitive and these routes accept raw casing.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| export function revalidateUsernamePaths(username: string): void { | ||
| const canonical = username; | ||
| const lower = normalizeUsernameCacheKey(username); | ||
| const variants = canonical === lower ? [canonical] : [canonical, lower]; |
There was a problem hiding this comment.
P2: Revalidating only the raw and lowercased username leaves other mixed-case profile/API/embed URLs stale, because revalidatePath is case-sensitive and these routes accept raw casing.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/frontend/src/lib/db/usernameLookup.ts, line 43:
<comment>Revalidating only the raw and lowercased username leaves other mixed-case profile/API/embed URLs stale, because `revalidatePath` is case-sensitive and these routes accept raw casing.</comment>
<file context>
@@ -29,3 +30,21 @@ export function getSingleUsernameMatch<T>(
+export function revalidateUsernamePaths(username: string): void {
+ const canonical = username;
+ const lower = normalizeUsernameCacheKey(username);
+ const variants = canonical === lower ? [canonical] : [canonical, lower];
+
+ for (const variant of variants) {
</file context>
junhoyeo
left a comment
There was a problem hiding this comment.
Thanks @IvGolovach! Case-insensitive username lookup combined with 'limit(2)' + 'AmbiguousUsernameError' returning HTTP 409 is a strictly safer design than the typical 'unique index alone' answer — failing closed on case-collision is the right call, and it stays correct even on environments where the migration hasn't run yet. Migration 0005's pre-flight DO-block guard against existing case-variant duplicates is a nice safety net. The unstable_cache + lower-cased tag pattern in getUserRank is consistent with the rest of the codebase.
Note for the open cubic thread on revalidatePath case-variants beyond canonical+lowercased: the 60s ISR self-heal bounds the staleness window, and a proper tag-based-caching refactor of '/api/users/[username]', '/u/[username]', and '/api/embed/[username]/svg' is a clean follow-up PR.
Approved.
The route now delegates ISR invalidation to revalidateUsernamePaths, which fans out to canonical and lowercased profile, API, and embed paths. Update the delete-route test mock to expose that helper and assert the expanded path set so the regression is caught at the API boundary. Confidence: high Scope-risk: narrow
Successful submissions update leaderboard and user-rank tags, but the public profile, API, and embed routes are ISR-cached by path. Call revalidateUsernamePaths after submit and cover it in the auth-path test so fresh submission data reaches both canonical and lowercased username URLs immediately. Confidence: high Scope-risk: narrow
Case-insensitive lookup plus exact-path ISR still allowed stale mixed-case profile and embed URLs to survive after writes. Redirect the page, profile API, and embed API to the stored canonical username so only one cacheable path remains, and cover the redirects in route tests. Confidence: high Scope-risk: narrow
… modernize cache invalidation Review follow-up after merging main into this branch. Main moved on while this PR was open, so DELETE /api/settings/account is brought up to the current baseline: - Authenticate via getSessionFromRequest(request) instead of bare getSession(), inheriting the CSRF Origin-allowlist gate that #615 (f2bf654) retrofitted onto every other cookie-mutating settings route (submitted-data, devices, tokens). - Use normalizeUsernameCacheKey() for all user:/user-rank:/embed-user: revalidateTag calls and revalidateUsernamePaths() for path revalidation, matching the post-#485 baseline. - Call revalidateUserGroupLeaderboards() (#524) before the users-row delete, since the helper needs the group membership rows that the delete cascades away. Best-effort like all other revalidation. - Update the route tests for the request-based signature: requests now carry an Origin header exercised against the real getSessionFromRequest, plus new cases asserting missing/disallowed Origin yields 401 with no DB delete, mirroring the sibling routes' CSRF tests. Constraint: Cookie-auth mutations must pass the Origin allowlist convention adopted in #615 Constraint: revalidateUserGroupLeaderboards reads group membership rows that ON DELETE CASCADE removes Rejected: Bearer personal-token path for account deletion | web-Settings-UI action; session-only is the conservative default and answers the parity question raised in review Rejected: Invalidate group leaderboards after the delete | membership rows are already cascaded away by then Confidence: high Scope-risk: narrow Not-tested: CSRF_ALLOWED_ORIGINS env override branch (covered indirectly by requestSession's own tests)
…letion (#379) * feat(settings): add Danger Zone with self-service data and account deletion Add a Danger Zone section to the Settings page with two destructive actions: - Delete submitted data: removes leaderboard entries, profile stats, and usage history while keeping the account active - Delete account: permanently deletes the user and all associated data (sessions, tokens, submissions) via ON DELETE CASCADE Both flows use a 3-step confirmation modal (intent → warning → typed confirmation) matching the CLI's triple-confirmation pattern. New API route: DELETE /api/settings/account New tests: settingsAccountDelete.test.ts (5 cases) * Update packages/frontend/src/app/settings/SettingsClient.tsx Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(settings): gate account deletion behind CSRF Origin allowlist and modernize cache invalidation Review follow-up after merging main into this branch. Main moved on while this PR was open, so DELETE /api/settings/account is brought up to the current baseline: - Authenticate via getSessionFromRequest(request) instead of bare getSession(), inheriting the CSRF Origin-allowlist gate that #615 (f2bf654) retrofitted onto every other cookie-mutating settings route (submitted-data, devices, tokens). - Use normalizeUsernameCacheKey() for all user:/user-rank:/embed-user: revalidateTag calls and revalidateUsernamePaths() for path revalidation, matching the post-#485 baseline. - Call revalidateUserGroupLeaderboards() (#524) before the users-row delete, since the helper needs the group membership rows that the delete cascades away. Best-effort like all other revalidation. - Update the route tests for the request-based signature: requests now carry an Origin header exercised against the real getSessionFromRequest, plus new cases asserting missing/disallowed Origin yields 401 with no DB delete, mirroring the sibling routes' CSRF tests. Constraint: Cookie-auth mutations must pass the Origin allowlist convention adopted in #615 Constraint: revalidateUserGroupLeaderboards reads group membership rows that ON DELETE CASCADE removes Rejected: Bearer personal-token path for account deletion | web-Settings-UI action; session-only is the conservative default and answers the parity question raised in review Rejected: Invalidate group leaderboards after the delete | membership rows are already cascaded away by then Confidence: high Scope-risk: narrow Not-tested: CSRF_ALLOWED_ORIGINS env override branch (covered indirectly by requestSession's own tests) --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Summary
Make profile username lookups case-insensitive across the frontend public profile surfaces.
Fixes #477.
Why
Shared profile URLs should resolve regardless of username casing. Before this change,
/u/imlunaheycould return not found while/u/ImLunaHeyworked, which made profile links unreliable and inconsistent with GitHub username expectations.Diff scope
packages/frontend/src/lib/db/usernameLookup.tswith shared case-insensitive username matching and cache-key normalization helpers.packages/frontend/src/app/api/users/[username]/route.tsto resolve profile usernames case-insensitively while returning the stored canonical username.packages/frontend/src/lib/embed/getUserEmbedStats.tsso embed stats and contribution lookups resolve usernames case-insensitively.packages/frontend/src/lib/leaderboard/getLeaderboard.tsso all-time and period user-rank lookups match usernames case-insensitively.packages/frontend/src/app/api/submit/route.tsandpackages/frontend/src/app/api/settings/submitted-data/route.tsso revalidation tags match the normalized cache keys.packages/frontend/__tests__/api/usersProfile.test.tspackages/frontend/__tests__/api/settingsSubmittedDataDelete.test.tspackages/frontend/__tests__/api/submitAuth.test.tspackages/frontend/__tests__/lib/getLeaderboard.test.tspackages/frontend/__tests__/lib/getLeaderboardAllTime.test.tspackages/frontend/__tests__/lib/getUserEmbedStats.test.tspackages/frontend/__tests__/lib/usernameLookup.test.tsBranch integrity
maingit rev-parse origin/main:edc8ed1fca49ec4a6936e4d4e15ca45dfd442b8egit rev-list --left-right --count origin/main...HEAD:0 101git merge-base origin/main HEAD:edc8ed1fca49ec4a6936e4d4e15ca45dfd442b8egit merge-base --is-ancestor origin/main HEAD: PASS, exit0origin/mainwith no divergence.Commit integrity
a6962fce4bff10798490d16c79a1aba2686382b2 fix(frontend): make profile username lookups case-insensitivescripts/ledger/does not exist in this repository.scripts/ledger/does not exist in this repository.Ledger proof
Not applicable —
scripts/ledger/does not exist in this repository.Diff hygiene
git diff --name-status origin/main...HEAD:git diff --check origin/main...HEAD: PASS, no output.envfiles, local environment files, secrets, tokens, credentials,ops/reports/**artifacts,__pycache__/,*.pyc,.DS_Store,.coverage,htmlcov/,coverage.xml, build artifacts, cache files, unrelated generated files, or unrelated source changes.Test proof
packages/frontend/node_modules/.bin/vitest run: PASS, 154 tests passed across 19 test filesbun run lint: PASS, 0 errors, 7 warnings in pre-existing unrelated filesbun run build: PASSVerification-pack proof
Not applicable — no infra/governance/replay/invariant/verification files changed.
Migration notes
Not applicable — no DB migration changed.
CI context confirmation
backend,frontend,simulated-correctness-coreRuntime safety
Reviewed runtime files/modules:
packages/frontend/src/app/api/users/[username]/route.tspackages/frontend/src/lib/embed/getUserEmbedStats.tspackages/frontend/src/lib/leaderboard/getLeaderboard.tspackages/frontend/src/app/api/submit/route.tspackages/frontend/src/app/api/settings/submitted-data/route.tspackages/frontend/src/lib/db/usernameLookup.tsRuntime reasoning:
Documentation integrity
Not applicable — no docs, runbooks, public commands, or operational behavior changed.
Rollback plan
git revert <post_merge_commit_sha><post_merge_commit_sha>with the final squash/merge commit SHA after merge.Known residual risks
LOWER(users.username)may not use the existing username unique index on every Postgres plan. No migration was added because this is a narrow single-user lookup; a functional index can be added later if profile lookup volume requires it.