Hosting: one-time exchange code for the signup session handoff - #1459
Conversation
…doff The Customize link used to carry the session bearer in its fragment, which left a captured URL a live credential until upstream expiry. The hosting API now mints a one-time code (nanoid, five-minute TTL, Redis GETDEL so a code can only ever be exchanged once) for the ensureValidToken-resolved token, the success screen re-mints on an interval while it stays open and the link carries #hc=<code>. The instance exchanges the code at the managed API and still applies its owner gate to whatever comes back; the bearer #hs path stays until every deployed ecency.com sends codes, since the web app promotes to production separately from the hosting stack. Identity for minting comes from the token via Hivesigner /me, shared with the login exchange, and neither codes nor tokens reach the audit trail.
PR Summary by QodoReplace signup handoff bearer with one-time exchange code (TTL + single-use)
AI Description
Diagram
High-Level Assessment
Files changed (9)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2793974c0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!handoffCode) return; | ||
| e.preventDefault(); | ||
| window.open( | ||
| `${safeBlogUrl}?setup=1#hs=${encodeURIComponent(handoffToken)}`, | ||
| `${safeBlogUrl}?setup=1#hc=${encodeURIComponent(handoffCode)}`, |
There was a problem hiding this comment.
Invalidate the handoff code after opening it
After the first Customize click, the instance consumes this one-time code, but handoffCode remains unchanged until the four-minute refresh. Any second click during that window prevents the credential-free navigation and opens the already-used #hc URL again; its exchange returns 404, and this generated URL also omits the fallback login=hivesigner parameter, leaving the owner logged out. Clear and re-mint the code immediately after using it, or let subsequent clicks take the fallback href until a fresh code is available.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in the follow-up commit: the click clears the code and bumps a mint nonce, so the instance-consumed code is never replayed (an immediate second click takes the credential-free default navigation while a fresh code mints), and the opened URL now carries the login=hivesigner fallback param so an unexchangeable code still lands a Hivesigner owner in a login flow. Spec pins the no-replay behavior.
The instance's exchange deletes the code, so the click clears it and forces a fresh mint: an immediate second click takes the credential-free default navigation instead of replaying a dead code into a logged-out landing. The opened URL also carries the login=hivesigner fallback param, so a code the instance cannot exchange still lands a Hivesigner owner in a login flow.
Code Review by Qodo
1.
|
| const resolved = await resolveHivesignerUsername(accessToken); | ||
| if (!resolved.ok) { | ||
| return c.json({ error: resolved.error }, resolved.status); | ||
| } | ||
| const { username } = resolved; | ||
|
|
||
| const code = nanoid(32); | ||
| await handoffStore.set(code, { accessToken, username }, HANDOFF_TTL_SECONDS); |
There was a problem hiding this comment.
2. No per-account handoff limit 📎 Requirement gap ⛨ Security
The new /v1/auth/handoff mint endpoint is only covered by existing per-IP rate limiting, with no additional rate limit keyed by the resolved account. This violates the requirement to rate limit minting by both account and IP, and enables high-volume minting spread across many accounts behind one IP (or vice versa).
Agent Prompt
## Issue description
`POST /v1/auth/handoff` lacks rate limiting by authenticated account; only per-IP rate limiting is applied at the app level.
## Issue Context
Compliance requires rate limiting by both account and IP. The app currently applies `rateLimit({ name: 'auth', ... })` which keys solely by trusted client IP; the new handoff mint route does not add any account-based budget.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/routes/auth.ts[207-244]
- apps/self-hosted/hosting/api/src/index.ts[59-77]
- apps/self-hosted/hosting/api/src/middleware/rate-limit.ts[28-82]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Confirmed and fixed in 948d5ac: minting now counts against the resolved account in a rolling minute (Redis INCR with expiry) beside the per-IP limits, capped well above legitimate use (one code per success screen plus a slow refresh). Route test covers the cap.
| if (!handoffCode) return; | ||
| e.preventDefault(); | ||
| window.open( | ||
| `${safeBlogUrl}?setup=1#hs=${encodeURIComponent(handoffToken)}`, | ||
| `${safeBlogUrl}?setup=1#hc=${encodeURIComponent(handoffCode)}`, |
There was a problem hiding this comment.
4. Consumed code remains clickable 🐞 Bug ≡ Correctness
After the first successful exchange, HostingSignup retains and reuses the same handoffCode on later Customize clicks even though Redis has atomically deleted it. Subsequent clicks before a newer code is successfully minted open a destination whose exchange returns 404, leaving the owner logged out instead of carrying the session.
Agent Prompt
## Issue description
The Customize click handler continues using a one-time handoff code after the destination consumes it. A retry, double-click, or second tab therefore exchanges an already-deleted code and loses the session handoff.
## Issue Context
Redis consumption uses atomic `GETDEL`, and the API returns 404 for a consumed code. Ensure each launch gets a fresh code or that the consumed code is immediately removed and safely reminted while preserving popup-blocker behavior and the credential-free fallback.
## Fix Focus Areas
- apps/web/src/features/hosting-signup/hosting-signup.tsx[414-425]
- apps/web/src/features/hosting-signup/hosting-signup.tsx[1072-1078]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
This ran against the pre-fix head: since bcec696 the click clears the code and forces a fresh mint, an immediate second click takes the credential-free default navigation and the opened URL carries the login=hivesigner fallback. Spec pins the no-replay behavior.
| const mint = async () => { | ||
| try { | ||
| const token = await ensureValidToken(activeUser.username); | ||
| if (!token || cancelled) return; | ||
| const minted = await hostingApi.mintHandoff(token); | ||
| if (!cancelled) setHandoffCode(minted.code); | ||
| } catch { | ||
| if (!cancelled) setHandoffCode(null); | ||
| } | ||
| }; | ||
| mint(); | ||
| const timer = setInterval(mint, 4 * 60 * 1000); |
There was a problem hiding this comment.
5. Refreshed code can expire before ui updates 🐞 Bug ☼ Reliability
The success screen re-mints a handoff code every 4 minutes, but the currently displayed code (with a 5-minute server TTL) is only replaced once ensureValidToken() and hostingApi.mintHandoff() both resolve; if that round trip takes longer than the ~1-minute margin, a click on Customize opens the URL with the now-expired code, causing the instance-side exchange to fail with a 404 instead of falling back to the credential-free href.
Agent Prompt
## Issue description
The handoff code minted on the signup success screen expires 5 minutes after mint (server-side `HANDOFF_TTL_SECONDS = 5 * 60`), but the client only re-mints a fresh code on a fixed 4-minute interval, and the currently-held `handoffCode` state is not replaced until the async `ensureValidToken()` + `hostingApi.mintHandoff()` round trip resolves. If that round trip is slow (network latency, retries, a busy hosting API), the previous code can pass its 5-minute TTL before the new one is stored, and a user click during that window will open the Customize link with an already-expired code, causing the instance-side `/v1/auth/handoff/exchange` call to fail (404) with no automatic fallback.
## Issue Context
- Server TTL: `apps/self-hosted/hosting/api/src/routes/auth.ts` `HANDOFF_TTL_SECONDS = 5 * 60`.
- Client re-mint interval: `apps/web/src/features/hosting-signup/hosting-signup.tsx` `setInterval(mint, 4 * 60 * 1000)`.
- The click handler only checks `if (!handoffCode) return;` with no expiry awareness before opening the URL.
## Fix Focus Areas
- apps/web/src/features/hosting-signup/hosting-signup.tsx[410-430]
- apps/web/src/features/hosting-signup/hosting-signup.tsx[1067-1079]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Code Review by Qodo
1. resolveHivesignerUsername uses any
|
|
Warning Review limit reached
Next review available in: 44 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughThe PR adds short-lived, single-use handoff codes for hosting signup. The API stores codes in Redis, the web signup flow passes codes instead of bearer tokens, and self-hosted setup exchanges codes into authenticated sessions. ChangesHosting handoff flow
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Signup as hosting signup
participant HostingAPI as hostingApi
participant AuthAPI as hosting auth API
participant Redis as handoffStore
participant Setup as self-hosted setup
Signup->>HostingAPI: mintHandoff with access token
HostingAPI->>AuthAPI: POST /v1/auth/handoff
AuthAPI->>Redis: Store five-minute handoff payload
AuthAPI-->>HostingAPI: Return one-time code
Signup-->>Setup: Navigate with `#hc` code
Setup->>AuthAPI: POST /handoff/exchange
AuthAPI->>Redis: Consume code once
Redis-->>AuthAPI: Return token and username
AuthAPI-->>Setup: Return session payload
Setup-->>Setup: Validate owner and create session
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/self-hosted/hosting/api/src/routes/auth.ts`:
- Around line 215-244: Update the /handoff handler around authRoutes.post and
add per-account and per-IP mint rate limiting: check and reject an exhausted IP
budget before resolveHivesignerUsername, then check the resolved username’s
budget before nanoid creation and handoffStore.set, returning 429 without
writing a handoff code. In apps/self-hosted/hosting/api/src/routes/auth.ts lines
215-244, implement the route changes; in
apps/self-hosted/hosting/api/src/routes/auth-handoff.test.ts lines 46-103, add
isolated tests covering exhausted IP and account limits plus successful minting
for distinct accounts and IPs.
In `@apps/web/src/features/hosting-signup/hosting-signup.tsx`:
- Around line 410-430: Update the handoff-code effect around ensureValidToken
and mintHandoff to depend on activeUser?.username, clearing handoffCode when
step is not "success", no active user exists, or before each mint attempt. Also
clear it when ensureValidToken returns no token, while preserving cancellation
handling so codes cannot persist across logout or account changes.
In `@apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx`:
- Around line 188-198: Update the hosting signup test around the mintHandoff
success flow to await the initial mintHandoff promise and state update before
interacting with the customize control. Move fireEvent.click(customize) outside
waitFor, click exactly once, then assert the expected open URL and window
features.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 587525d8-e6f8-4cd4-827b-0422986fd277
📒 Files selected for processing (9)
apps/self-hosted/hosting/api/src/routes/auth-handoff.test.tsapps/self-hosted/hosting/api/src/routes/auth.tsapps/self-hosted/hosting/api/src/utils/redis.tsapps/self-hosted/src/features/auth/setup-handoff.test.tsapps/self-hosted/src/features/auth/setup-handoff.tsapps/self-hosted/src/features/auth/utils/handoff-exchange.tsapps/web/src/features/hosting-signup/hosting-api.tsapps/web/src/features/hosting-signup/hosting-signup.tsxapps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
| // The minted code resolves asynchronously on success-screen mount, so | ||
| // retry the click until the state lands. Only the one-time code travels | ||
| // in the URL; the bearer stays out of it entirely. | ||
| await waitFor(() => { | ||
| fireEvent.click(customize); | ||
| expect(open).toHaveBeenCalledWith( | ||
| "https://alice.blogs.ecency.com/?setup=1#hs=tok-alice", | ||
| "https://alice.blogs.ecency.com/?setup=1#hc=hand-off-code-1234567890abcdef", | ||
| "_blank", | ||
| "noopener,noreferrer" | ||
| ); | ||
| }); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- target file outline ---'
ast-grep outline apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx || true
printf '%s\n' '--- target test context ---'
sed -n '1,240p' apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
printf '%s\n' '--- related symbols ---'
rg -n -C 4 'mintHandoff|customize|window\.open|waitFor' apps/web/src/specs/features/hosting-signup apps/web/src | head -300Repository: ecency/vision-web
Length of output: 43896
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- hosting signup candidates ---'
fd -i 'hosting-signup' apps/web/src
printf '%s\n' '--- component symbols ---'
rg -n -C 8 'mintHandoff|customize-your-blog|window\.open|onClick|handoff|hand-off' apps/web/src/features/hosting-signup
printf '%s\n' '--- component outline ---'
component=$(fd -i -t f 'hosting-signup.tsx' apps/web/src | head -1)
if [ -n "${component}" ]; then
ast-grep outline "$component" || true
printf '%s\n' '--- component size ---'
wc -l "$component"
fiRepository: ecency/vision-web
Length of output: 48190
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
test = Path("apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx").read_text()
component = Path("apps/web/src/features/hosting-signup/hosting-signup.tsx").read_text()
assert "await waitFor(() => {\n fireEvent.click(customize);" in test
assert "const minted = await hostingApi.mintHandoff(token);" in component
assert "if (!handoffCode) return;" in component
assert "setHandoffCode(minted.code)" in component
# Minimal model of Testing Library's retry behavior: the callback is retried
# until its assertion passes. A click is a side effect on every invocation.
handoff_ready = False
clicks = 0
opens = 0
for attempt in range(2):
clicks += 1
if handoff_ready:
opens += 1
break
# The initial mint resolves between retries and React applies the state.
handoff_ready = True
assert clicks == 2
assert opens == 1
print("source pattern: click is inside waitFor")
print("component pattern: window.open requires handoffCode set after mintHandoff resolves")
print(f"retry model: clicks={clicks}, opens={opens}")
print("call observation alone does not establish that the resolved code is in component state")
PYRepository: ecency/vision-web
Length of output: 396
Move the click outside waitFor
Wait for the initial mintHandoff promise to resolve and the code to reach component state. Then click once and assert the popup URL.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx` around
lines 188 - 198, Update the hosting signup test around the mintHandoff success
flow to await the initial mintHandoff promise and state update before
interacting with the customize control. Move fireEvent.click(customize) outside
waitFor, click exactly once, then assert the expected open URL and window
features.
Source: Coding guidelines
…doff codes Minting counts against the account inside a rolling minute beside the per-IP limits, so a leaked token cannot become a code mill (legitimate use is one code per success screen plus a slow refresh). The instance re-resolves the exchanged session against Hivesigner /me itself and requires the identity to match, so the account it signs in was never taken on the API's word alone; the owner gate still applies after both.
The mint effect clears any existing code before anything else, so a code minted for a previous account, a logout or an earlier screen can never ride the Customize button into the new context. The client stores the server's expiresAt and a click within thirty seconds of it takes the fallback navigation and mints a replacement instead of opening a code whose exchange would answer dead.
Closes #1449
What
The signup success screen's Customize link no longer carries the session bearer in its fragment. It carries a one-time short-TTL code minted by the hosting API, so a captured URL is worthless after a single exchange or five minutes, closing the browser-history and replay residuals accepted when #1448 shipped.
How
POST /v1/auth/handoffverifies the token against Hivesigner /me (the same shared resolver the login exchange uses), mints a nanoid code and stores { token, identity } in Redis with a five-minute TTL.POST /v1/auth/handoff/exchangetrades the code for the session through an atomic GETDEL, so a code can only ever be exchanged once. Neither codes nor tokens reach the audit trail. Both endpoints sit under the existing auth rate limits.#hc=<code>. A failed mint leaves the click on the credential-free fallback href, never a bearer substitute.#hc=at boot (scrubbed pre-render, crash-safe decode like the bearer path), exchanges it once at the managed API and still applies the owner gate to whatever comes back. The#hs=bearer path stays for now: the web app promotes to production separately from the hosting stack, so instances must keep accepting bearer links until every deployed ecency.com sends codes.Rollout order
Safe by construction: this merge deploys the hosting stack (API gains the endpoints, instances gain the code path) while ecency.com only starts minting after its own production promotion, against an API that already has the endpoint.
Tests
Summary by CodeRabbit
New Features
Bug Fixes
Tests