Skip to content

fix(frontend): make profile URLs case-insensitive - #485

Merged
junhoyeo merged 8 commits into
junhoyeo:mainfrom
IvGolovach:codex/fix-profile-username-case-477
May 1, 2026
Merged

fix(frontend): make profile URLs case-insensitive#485
junhoyeo merged 8 commits into
junhoyeo:mainfrom
IvGolovach:codex/fix-profile-username-case-477

Conversation

@IvGolovach

@IvGolovach IvGolovach commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

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/imlunahey could return not found while /u/ImLunaHey worked, which made profile links unreliable and inconsistent with GitHub username expectations.

Diff scope

  • Added packages/frontend/src/lib/db/usernameLookup.ts with shared case-insensitive username matching and cache-key normalization helpers.
  • Updated packages/frontend/src/app/api/users/[username]/route.ts to resolve profile usernames case-insensitively while returning the stored canonical username.
  • Updated packages/frontend/src/lib/embed/getUserEmbedStats.ts so embed stats and contribution lookups resolve usernames case-insensitively.
  • Updated packages/frontend/src/lib/leaderboard/getLeaderboard.ts so all-time and period user-rank lookups match usernames case-insensitively.
  • Updated packages/frontend/src/app/api/submit/route.ts and packages/frontend/src/app/api/settings/submitted-data/route.ts so revalidation tags match the normalized cache keys.
  • Added and updated regression coverage in:
    • packages/frontend/__tests__/api/usersProfile.test.ts
    • packages/frontend/__tests__/api/settingsSubmittedDataDelete.test.ts
    • packages/frontend/__tests__/api/submitAuth.test.ts
    • packages/frontend/__tests__/lib/getLeaderboard.test.ts
    • packages/frontend/__tests__/lib/getLeaderboardAllTime.test.ts
    • packages/frontend/__tests__/lib/getUserEmbedStats.test.ts
    • packages/frontend/__tests__/lib/usernameLookup.test.ts

Branch integrity

  • Base branch: main
  • git rev-parse origin/main: edc8ed1fca49ec4a6936e4d4e15ca45dfd442b8e
  • git rev-list --left-right --count origin/main...HEAD: 0 1
  • Behind count: 0
  • Ahead count: 1
  • git merge-base origin/main HEAD: edc8ed1fca49ec4a6936e4d4e15ca45dfd442b8e
  • git merge-base --is-ancestor origin/main HEAD: PASS, exit 0
  • Fast-forward safety: confirmed. This branch is one commit ahead of origin/main with no divergence.

Commit integrity

  • a6962fce4bff10798490d16c79a1aba2686382b2 fix(frontend): make profile username lookups case-insensitive
  • One logical change per commit: confirmed.
  • Ledger-Id trailer validation: Not applicable — scripts/ledger/ does not exist in this repository.
  • Unique Ledger-Id confirmation for squash-merge repositories: Not applicable — 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:

M	packages/frontend/__tests__/api/settingsSubmittedDataDelete.test.ts
M	packages/frontend/__tests__/api/submitAuth.test.ts
M	packages/frontend/__tests__/api/usersProfile.test.ts
M	packages/frontend/__tests__/lib/getLeaderboard.test.ts
M	packages/frontend/__tests__/lib/getLeaderboardAllTime.test.ts
A	packages/frontend/__tests__/lib/getUserEmbedStats.test.ts
A	packages/frontend/__tests__/lib/usernameLookup.test.ts
M	packages/frontend/src/app/api/settings/submitted-data/route.ts
M	packages/frontend/src/app/api/submit/route.ts
M	packages/frontend/src/app/api/users/[username]/route.ts
A	packages/frontend/src/lib/db/usernameLookup.ts
M	packages/frontend/src/lib/embed/getUserEmbedStats.ts
M	packages/frontend/src/lib/leaderboard/getLeaderboard.ts
  • git diff --check origin/main...HEAD: PASS, no output
  • Forbidden-file confirmation: no .env files, 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 files
  • bun run lint: PASS, 0 errors, 7 warnings in pre-existing unrelated files
  • bun run build: PASS
  • Backend pytest: Not applicable — no backend/runtime/migration/backend dependency files changed.
  • Coverage percentage: Not applicable — backend coverage gate is not required for this frontend-only change.

Verification-pack proof

Not applicable — no infra/governance/replay/invariant/verification files changed.

Migration notes

Not applicable — no DB migration changed.

CI context confirmation

  • Required contexts: backend, frontend, simulated-correctness-core
  • CI context names unchanged.
  • Not applicable — no CI/workflow context changes.

Runtime safety

Reviewed runtime files/modules:

  • packages/frontend/src/app/api/users/[username]/route.ts
  • packages/frontend/src/lib/embed/getUserEmbedStats.ts
  • packages/frontend/src/lib/leaderboard/getLeaderboard.ts
  • packages/frontend/src/app/api/submit/route.ts
  • packages/frontend/src/app/api/settings/submitted-data/route.ts
  • packages/frontend/src/lib/db/usernameLookup.ts

Runtime reasoning:

  • No new blocking locks: the change only replaces exact username predicates with an exact case-insensitive SQL predicate and normalizes cache tag strings.
  • No new unbounded queues: no queueing, background workers, polling loops, or async accumulation paths were added.
  • No invariant removal: canonical stored usernames are still returned in API responses; cache revalidation remains scoped to the same public profile and leaderboard surfaces.
  • No invariant regression introduced.

Documentation integrity

Not applicable — no docs, runbooks, public commands, or operational behavior changed.

Rollback plan

  • Intended post-merge rollback command for squash merge: git revert <post_merge_commit_sha>
  • Replace <post_merge_commit_sha> with the final squash/merge commit SHA after merge.
  • DB downgrade required: no.
  • Data repair required: no.
  • Operational caveats: none expected.

Known residual risks

  • Low: 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.

Open in Devin Review

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
@vercel

vercel Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tokscale Ready Ready Preview, Comment May 1, 2026 3:15am

Request Review

cubic-dev-ai[bot]

This comment was marked as resolved.

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/frontend/src/app/api/settings/submitted-data/route.ts
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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 junhoyeo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@junhoyeo
junhoyeo merged commit e516d39 into junhoyeo:main May 1, 2026
5 checks passed
junhoyeo added a commit that referenced this pull request Jul 6, 2026
… 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)
junhoyeo added a commit that referenced this pull request Jul 6, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Profile URLs should be case-insensitive

2 participants