Skip to content

feat: redirect retired usernames and Space slugs to current addresses - #463

Open
markbang wants to merge 4 commits into
mainfrom
router/cohub-slug-history-redirect
Open

markbang wants to merge 4 commits into
mainfrom
router/cohub-slug-history-redirect

Conversation

@markbang

@markbang markbang commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Routed task cohub-slug-history-redirect. The brief named no GitHub issue.

When a user or Space slug is renamed, the old public address redirects to the new one. Old names are not reserved, the same as on GitHub. Anyone can claim one at any time, and from then on the address belongs to the new holder.

Summary

v2.slug_history records every old username and Space slug. Public lookups resolve in two steps, and the current holder always wins:

resolvePublicSpace(username, slug)                         apps/api/src/slug-history.ts
  owner = user_profiles.username = username                  # current holder wins; never redirects
        ?? latest slug_history(username, old_slug) ⋈ user_profiles   ORDER BY created_at DESC LIMIT 1
  space = spaces(owner.uuid, slug)                           # current Space wins
        ?? latest slug_history(space, owner_uuid, old_slug) ⟕ spaces  ORDER BY created_at DESC LIMIT 1
           (latest holder deleted → 404, never an older holder)
  redirected = either step used history → redirectTo = /current-user/current-slug + request query (one hop)
 GET /api/spaces/by-slug/:u/:s          -> full Space payload (+ redirectTo when history was used)
 GET /api/users/by-username/:u          -> full profile payload (+ redirectTo; Cache-Control: no-store when redirected)
 GET /api/apps/by-slug/:u/:s/:a[/versions]
   current slugs: existing single join (hot path unchanged)
+  miss: resolvePublicSpace -> app by space id (+ redirectTo)

 PATCH /api/me/profile (username)
+  precheck: not held by someone else; last rename ≥ 7 days ago (first rename free) → 429 username_change_too_soon {nextAllowedAt}
   Logto update
+  db.transaction: lock profile row → recheck 7-day limit → insert history(old) → upsert profile
 PATCH /api/spaces/:id (slug)
+  db.transaction: lock Space row → insert history(slug being replaced) → update

 web
  hooks.server.ts handle(), before resolve()
+   document GET on route /(app)/[username]/[spaceSlug] (not __data.json; sec-fetch-dest=document when sent)
+     -> /api/spaces/by-slug; redirectTo -> 308, Location = current path + original query
  (app)/[username]/[spaceSlug]/+page.ts            client navigation: getBySlug().redirectTo -> redirect() (no extra request)
  (public)/…/w/[appSlug]/+page.server.ts           redirectTo -> 308 with the page query
  every 308: cache-control: no-store
  Space settings / profile settings                 hints + "next allowed time" error (en / zh-CN)

Indexes on slug_history are plain, not unique: one old_slug can belong to several former holders in turn. They are (entity_type, old_slug, created_at desc), (entity_type, owner_uuid, old_slug, created_at desc) and (entity_type, entity_id, created_at desc), the last one for the rate limit.

Old names are claimable

Situation Result
alice renames to alice3 /alice/... → 308 to /alice3/...
bob then claims alice /alice/... is bob's. No redirect, even though history still has alice
bob renames alice → bobby /alice/... → 308 to /bobby/.... The most recent former holder wins (created_at DESC)
a Space slug is reused by its owner Same rules, per owner

Redirects are never cached. A 308 is normally cached by browsers indefinitely, and the target here changes when someone claims the old name. Every 308 is sent with cache-control: no-store, the same as GitHub, so the first request after a claim already resolves to the new holder.

Home

ensureHomeSpace re-creates Home on demand under the fixed slug home. That needs no special case anymore: the live Home always wins, and after a rename /owner/home follows the most recent former Home until a new Home exists.

Evidence

  • apps/api/tests/slug-history.integration.ts runs on PGlite with the real migrations and a mocked Logto. It covers:
    • Username renames: each rename records history. A second rename within 7 days returns UsernameChangeTooSoonError and adds no row; the first rename is free.
    • Old usernames: every old name resolves to the current one. bob claims alice and holds it with no redirect. After bob renames, alice redirects to bob, the latest former holder.
    • A failed Logto write leaves no history row.
    • Old username plus old slug resolves in one step. Another owner with the same slug is unaffected.
    • Space slug reuse: the reused slug wins as the live Space. After it is renamed, the latest holder wins. If that latest holder is deleted, the address returns 404 instead of falling back to an older holder.
    • Home: renaming it records history, a re-created Home wins, and a second rename makes the address follow the latest former Home.
  • Manual check against a mock API on vite dev:
    • GET /olduser/oldslug?session=abc (document) → 308, location: /newuser/newslug?session=abc, cache-control: no-store
    • GET /olduser/oldslug/w/myapp?cohub_v=2&x=1 → 308, location: /newuser/newslug/w/myapp?cohub_v=2&x=1, cache-control: no-store
    • In a browser, an in-app link to /olduser/oldslug?session=1 lands on /newuser/newslug?session=1. history.length goes up by one and no __data.json is requested.
  • Checks run:
    • apps/api: tsc, biome, pnpm test, db:check-drift
    • packages/sdk: tsc, biome, tests
    • apps/web: typecheck, biome, pnpm build, pnpm test
    • The pre-commit typecheck covers all dependents.
  • review-loop (Claude + GPT): PASS on each pass — the initial change, the hook-based 308, the switch to claimable old names, and no-store redirects.

Cost

  • Space page documents: a page load of /:username/:spaceSlug costs one lightweight resolve request (worker → /api/spaces/by-slug) before the shell is returned. The (app) group renders client-side (ssr = false), so the 308 has to come from hooks.server.ts.
  • Client navigation: no added request. There is no server load, so there is no __data.json. +page.ts reads redirectTo from the getBySlug call it already makes, inside untrack, so query-only navigations don't rerun it.
  • redirect() instead of goto(…, { replaceState: true }): during a navigation, redirect() continues the same navigation and the old URL never enters history. A goto with replaceState inside a load would replace the previous page's history entry.

Known gaps

  • Apps visible only to Space members: an anonymous SSR load gets a 403 before redirectTo is attached. These open in client mode at the old URL, without a 308.
  • The SDK slug resolver needed no change. The API returns the full resolved payload alongside redirectTo, so createSlugAppIdResolver already reads the target app's id.
  • AppPublishDialog still shows raw English API errors, the same as before this change.

Defaults I added (L007)

What I added Why Cost to users What happens if removed
Lock the profile row, then recheck the 7-day limit inside the transaction Two renames by the same user at once would otherwise both pass the precheck One extra SELECT … FOR UPDATE per profile sync Double-submitting could bypass the weekly limit
Lock the Space row and record the slug that is actually being replaced With no unique index left, two concurrent renames of one Space could both record the same old slug and lose the intermediate one One SELECT … FOR UPDATE per slug change An intermediate slug could miss its history row and 404
On a failed local write, roll Logto back to the committed local username After a concurrent rename wins, rolling back to the pre-request name would undo it none Logto and local can diverge
Logto-driven profile syncs also record history when the username changes A rename made directly in Logto should redirect too none Those renames leave no redirect
Keep the existing single-join fast path for /api/apps/by-slug Public hot path none One extra query per app open
/api/users/by-username sends Cache-Control: no-store on redirected responses Its normal max-age=60, stale-while-revalidate=300 could keep serving a redirect after the old name has been claimed, unlike the web 308s Redirected profile lookups are never cached API clients could follow a stale redirect for up to 6 minutes
id PK and an entity_type CHECK Table hygiene none —

No retries, TTLs, grace periods or migration guards.

Merge Danger

Door: two-way. Migration 0084_slug_history is new in this branch and was rewritten in place, from unique to plain indexes. It has not run in any shared environment (deploy-backend only runs on main). A local database that applied an earlier version of 0084 needs DROP TABLE v2.slug_history before migrating.

Blast Radius: username and Space slug writes (/api/me/profile sync now runs in a transaction), public slug lookups, and the document load of the Space pretty-URL page (one extra API request in the web worker).

Record old usernames and Space slugs in slug_history so old public URLs
resolve to the current owner and Space in one step, and retired slugs are
never reassigned. Username renames are limited to once per 7 days.

Cohub Session: https://cohub.live/spaces/5089f055-9dbb-4fcd-b98c-fb232353281a/sessions/7e36f245-32c7-40bd-b8fa-15e1b67e55c2
The Space page renders client-side, so its server load never ran for the
document. Resolve retired usernames and Space slugs in handle() for
document requests instead, and follow redirectTo in the client load so
in-app navigation adds no __data.json request.

Cohub Session: https://cohub.live/spaces/5089f055-9dbb-4fcd-b98c-fb232353281a/sessions/7e36f245-32c7-40bd-b8fa-15e1b67e55c2
Old slugs are no longer reserved: the live holder always wins, and only an
unclaimed old slug redirects, following whoever gave it up last. History
indexes become non-unique, and redirects are cached for at most 60 seconds.

Cohub Session: https://cohub.live/spaces/5089f055-9dbb-4fcd-b98c-fb232353281a/sessions/7e36f245-32c7-40bd-b8fa-15e1b67e55c2
Old names can be claimed at any time, so 308s to the current address are
sent with Cache-Control: no-store and the next request after a claim
resolves to the new holder.

Cohub Session: https://cohub.live/spaces/5089f055-9dbb-4fcd-b98c-fb232353281a/sessions/7e36f245-32c7-40bd-b8fa-15e1b67e55c2

This branch has not been deployed

No deployments
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.

1 participant