From e26891a2d564cc66b2b4c09c37635153b8bbfbb7 Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:07 +0100 Subject: [PATCH 1/9] feat(auth): add safeNextPath for post-sign-in redirects Sign-in is about to accept a `next` destination so people return to where they started (e.g. a plan's checkout) instead of always landing on /app. Because that value comes from the URL, it must never be able to send users to another site. safeNextPath() accepts only root-relative, same-site paths and returns /app (DEFAULT_NEXT) for anything else: - non-strings, empty values and values over 512 characters - paths that don't start with "/" - protocol-relative forms ("//host" and "/\host"), which browsers treat as another origin - anything that resolves to a different origin when parsed The path, query and hash are kept, so /pricing?checkout=team survives. --- src/lib/safe-next.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/lib/safe-next.ts diff --git a/src/lib/safe-next.ts b/src/lib/safe-next.ts new file mode 100644 index 0000000..14a61e8 --- /dev/null +++ b/src/lib/safe-next.ts @@ -0,0 +1,29 @@ +// Where to send someone after sign-in. Only same-site paths are allowed, so a +// crafted `?next=` can never bounce users to another origin (open redirect). +// Anything else falls back to the dashboard. + +export const DEFAULT_NEXT = '/app' + +export function safeNextPath(value: unknown): string { + if (typeof value !== 'string' || value.length > 512) return DEFAULT_NEXT + // Must be a root-relative path. `//host` and `/\host` are protocol-relative + // in browsers, so reject them before parsing. + if ( + !value.startsWith('/') || + value.startsWith('//') || + value.startsWith('/\\') + ) { + return DEFAULT_NEXT + } + + const base = 'https://jargons.invalid' + let url: URL + try { + url = new URL(value, base) + } catch { + return DEFAULT_NEXT + } + if (url.origin !== base) return DEFAULT_NEXT + + return `${url.pathname}${url.search}${url.hash}` +} From 091b07abc2a31c69e8e03b0e94df053e91e7a2a4 Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:10 +0100 Subject: [PATCH 2/9] test(auth): cover safeNextPath open-redirect cases Checks that same-site paths keep their query string, and that absolute URLs, protocol-relative "//" and "/\" forms, javascript: URLs, relative paths, non-strings and over-long values all fall back to /app. --- src/lib/safe-next.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/lib/safe-next.test.ts diff --git a/src/lib/safe-next.test.ts b/src/lib/safe-next.test.ts new file mode 100644 index 0000000..9f8120f --- /dev/null +++ b/src/lib/safe-next.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_NEXT, safeNextPath } from './safe-next' + +describe('safeNextPath', () => { + it('keeps same-site paths with their query', () => { + expect(safeNextPath('/pricing?checkout=team')).toBe( + '/pricing?checkout=team', + ) + expect(safeNextPath('/app')).toBe('/app') + }) + + it.each([ + undefined, + null, + 42, + '', + 'pricing', + 'https://evil.example/phish', + '//evil.example', + '/\\evil.example', + 'javascript:alert(1)', + `/${'a'.repeat(600)}`, + ])('falls back to the dashboard for %j', (value) => { + expect(safeNextPath(value)).toBe(DEFAULT_NEXT) + }) +}) From d2cd0ac460f877d4e0e827d97ef7c4000ccc71ee Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:11 +0100 Subject: [PATCH 3/9] feat(auth): remember where to return after GitHub sign-in GitHub sign-in is a round trip through github.com, so the destination has to survive it. Store it in a short-lived cookie: - setSignInNext(next) validates the path with safeNextPath() and stores it in jargons_sign_in_next (HttpOnly, SameSite=Lax, 10 minute max-age, Secure in production). Lax is enough because the cookie only needs to come back on the top-level redirect from GitHub. When the destination is just the default /app, any stale cookie is cleared instead. - consumeSignInNext() reads the destination, validates it again, and deletes the cookie so it is used only once. It returns /app when nothing was stored. --- src/server/github-auth.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/server/github-auth.ts b/src/server/github-auth.ts index b6301c1..3e0fcee 100644 --- a/src/server/github-auth.ts +++ b/src/server/github-auth.ts @@ -1,6 +1,7 @@ import { createServerFn } from '@tanstack/react-start' import { loadDb } from '../db/load' +import { DEFAULT_NEXT, safeNextPath } from '../lib/safe-next' import { getEnv, getOptionalEnv } from './env' type GitHubTokenResponse = { @@ -23,6 +24,8 @@ const githubAuthorizeUrl = 'https://github.com/login/oauth/authorize' const githubTokenUrl = 'https://github.com/login/oauth/access_token' const githubUserUrl = 'https://api.github.com/user' const sessionCookieName = 'jargons_session' +// Where to land after the GitHub round trip, set when sign-in starts. +const signInNextCookieName = 'jargons_sign_in_next' export type CurrentUser = { id: string @@ -270,6 +273,38 @@ export async function deleteSessionCookie() { deleteCookie(sessionCookieName, { path: '/' }) } +// Remember where to send the user once GitHub sign-in completes. Lax is enough: +// the cookie only has to survive the top-level redirect back from GitHub. +export async function setSignInNext(next: unknown): Promise { + const path = safeNextPath(next) + const { setCookie, deleteCookie } = + await import('@tanstack/react-start/server') + + if (path === DEFAULT_NEXT) { + deleteCookie(signInNextCookieName, { path: '/' }) + return + } + + setCookie(signInNextCookieName, path, { + maxAge: 60 * 10, + httpOnly: true, + path: '/', + sameSite: 'lax', + secure: getOptionalEnv('NODE_ENV', 'development') === 'production', + }) +} + +// Read and clear the post-sign-in destination (the dashboard when unset). +export async function consumeSignInNext(): Promise { + const { getCookie, deleteCookie } = + await import('@tanstack/react-start/server') + const path = safeNextPath(getCookie(signInNextCookieName)) + + deleteCookie(signInNextCookieName, { path: '/' }) + + return path +} + export async function exchangeGitHubCode(code: string) { const response = await fetch(githubTokenUrl, { method: 'POST', From f56340d46b3f8725aca6ec07bf4d4bb648722ce2 Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:12 +0100 Subject: [PATCH 4/9] feat(auth): accept ?next= when starting GitHub sign-in /auth/github/start now reads the optional `next` query parameter and hands it to setSignInNext() before redirecting to GitHub, so the destination is remembered across the OAuth round trip. Invalid values are ignored and sign-in falls back to /app. --- src/routes/auth.github.start.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/routes/auth.github.start.tsx b/src/routes/auth.github.start.tsx index 8ad3985..7c27840 100644 --- a/src/routes/auth.github.start.tsx +++ b/src/routes/auth.github.start.tsx @@ -1,17 +1,20 @@ import { createFileRoute } from '@tanstack/react-router' -import { getGitHubAuthorizeUrl } from '../server/github-auth' +import { getGitHubAuthorizeUrl, setSignInNext } from '../server/github-auth' export const Route = createFileRoute('/auth/github/start')({ server: { handlers: { - GET: async () => + GET: async ({ request }) => { + await setSignInNext(new URL(request.url).searchParams.get('next')) + // Not Response.redirect() — its immutable headers break if the // framework needs to append headers (e.g. Set-Cookie) afterwards. - new Response(null, { + return new Response(null, { status: 302, headers: { location: getGitHubAuthorizeUrl() }, - }), + }) + }, }, }, }) From 8856543d10eb0ecc919cd95f2828e99f7c5c160f Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:13 +0100 Subject: [PATCH 5/9] feat(auth): return to the remembered page after sign-in After a successful GitHub sign-in the callback now redirects to the destination from consumeSignInNext() instead of always going to /app. With no stored destination the behaviour is unchanged. Error handling still sends users back to the sign-in page. --- src/routes/auth.github.callback.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/routes/auth.github.callback.tsx b/src/routes/auth.github.callback.tsx index b186544..e0e2902 100644 --- a/src/routes/auth.github.callback.tsx +++ b/src/routes/auth.github.callback.tsx @@ -1,6 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' -import { completeGitHubAuthWithCode } from '../server/github-auth' +import { + completeGitHubAuthWithCode, + consumeSignInNext, +} from '../server/github-auth' export const Route = createFileRoute('/auth/github/callback')({ server: { @@ -19,7 +22,7 @@ export const Route = createFileRoute('/auth/github/callback')({ try { await completeGitHubAuthWithCode(code) - return redirectTo(new URL('/app', url.origin)) + return redirectTo(new URL(await consumeSignInNext(), url.origin)) } catch (error) { console.error('GitHub auth callback failed', error) From bb1c0ba6e32f0eec5d416628c5237d6a3c380f6b Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:14 +0100 Subject: [PATCH 6/9] feat(auth): let the GitHub sign-in button carry a return path GitHubAuthButton takes an optional `next` prop and appends it (URL-encoded) to /auth/github/start. Without `next` the link is unchanged. The value is validated on the server, not here. --- src/components/github-auth-button.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/github-auth-button.tsx b/src/components/github-auth-button.tsx index f2d6866..80ee37e 100644 --- a/src/components/github-auth-button.tsx +++ b/src/components/github-auth-button.tsx @@ -1,12 +1,20 @@ import { MarkGithubIcon } from '@primer/octicons-react' import { ArrowRight } from 'lucide-react' -export function GitHubAuthButton({ label }: { label: string }) { +export function GitHubAuthButton({ + label, + next, +}: { + label: string + // Same-site path to return to after sign-in; validated on the server. + next?: string +}) { + const href = next + ? `/auth/github/start?next=${encodeURIComponent(next)}` + : '/auth/github/start' + return ( - + {label} From 8c8500fc72c6ccfe49d6bf0b1debac8053ab65e1 Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:15 +0100 Subject: [PATCH 7/9] feat(auth): keep the return path through the sign-up page New buyers who click "Create an account" from sign-in should still come back to the plan they picked. The sign-up route now accepts an optional `next` search param, passes it to the GitHub button, and keeps it on the "Sign in" link. --- src/routes/auth.sign-up.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/routes/auth.sign-up.tsx b/src/routes/auth.sign-up.tsx index 66a01b0..4ed6f15 100644 --- a/src/routes/auth.sign-up.tsx +++ b/src/routes/auth.sign-up.tsx @@ -6,10 +6,15 @@ import { AuthShell } from '../components/auth-shell' import { GitHubAuthButton } from '../components/github-auth-button' export const Route = createFileRoute('/auth/sign-up')({ + validateSearch: (search): { next?: string } => ({ + next: typeof search.next === 'string' ? search.next : undefined, + }), component: SignUpPage, }) function SignUpPage() { + const { next } = Route.useSearch() + return ( - +
Sign in From de910380548909eed7f743eacd39053c582025bc Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:16 +0100 Subject: [PATCH 8/9] feat(auth): pass ?next= from the sign-in page to GitHub The sign-in route now accepts an optional `next` search param alongside `error`, forwards it to the GitHub button, and keeps it on the "Create an account" link so switching to sign-up doesn't lose it. --- src/routes/auth.sign-in.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/routes/auth.sign-in.tsx b/src/routes/auth.sign-in.tsx index fe26146..4e277cd 100644 --- a/src/routes/auth.sign-in.tsx +++ b/src/routes/auth.sign-in.tsx @@ -6,14 +6,15 @@ import { AuthShell } from '../components/auth-shell' import { GitHubAuthButton } from '../components/github-auth-button' export const Route = createFileRoute('/auth/sign-in')({ - validateSearch: (search): { error?: string } => ({ + validateSearch: (search): { error?: string; next?: string } => ({ error: typeof search.error === 'string' ? search.error : undefined, + next: typeof search.next === 'string' ? search.next : undefined, }), component: SignInPage, }) function SignInPage() { - const { error } = Route.useSearch() + const { error, next } = Route.useSearch() return ( - + {error ? (

@@ -45,6 +46,7 @@ function SignInPage() { Create an account From b245cf75fa5401af4a2218db2e2f52793e57503f Mon Sep 17 00:00:00 2001 From: devtofunmi Date: Sun, 27 Sep 2026 12:41:16 +0100 Subject: [PATCH 9/9] feat(pricing): resume the chosen plan's checkout after sign-in Previously a signed-out visitor who clicked "Sign in to subscribe" was sent to /auth/sign-in and, after signing in, landed on /app with no sign of the plan they had picked, so the purchase was easy to abandon. - Signed-out clicks (and a 401 from the checkout API) now go to /auth/sign-in?next=/pricing?checkout=. - The pricing route accepts `?checkout=`, validated with isPaidPlan(). When it is present and the user is signed in on the free plan, the page starts that plan's checkout straight away. - The param is removed from the URL (history replace) before the checkout starts, so pressing Back from the Bachs checkout does not start it again. A ref guards against running it twice. - Signed-out visitors and workspaces already on a paid plan just see the pricing page; nothing is started automatically. --- src/routes/pricing.tsx | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/routes/pricing.tsx b/src/routes/pricing.tsx index 552cf82..ccb9a7c 100644 --- a/src/routes/pricing.tsx +++ b/src/routes/pricing.tsx @@ -1,13 +1,22 @@ import { Link, createFileRoute } from '@tanstack/react-router' import { ArrowRight, Check, LoaderCircle } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { OwlMark } from '../components/owl-mark' -import { FREE_RUN_LIMIT, PAID_PLAN_IDS, PAID_PLANS } from '../lib/plans' +import { + FREE_RUN_LIMIT, + isPaidPlan, + PAID_PLAN_IDS, + PAID_PLANS, +} from '../lib/plans' import type { PaidPlan, Plan } from '../lib/plans' import { getBilling } from '../server/billing' export const Route = createFileRoute('/pricing')({ + // `?checkout=` resumes a subscribe click that had to sign in first. + validateSearch: (search): { checkout?: PaidPlan } => ({ + checkout: isPaidPlan(search.checkout) ? search.checkout : undefined, + }), loader: async () => { const billing = await getBilling() return { @@ -18,14 +27,33 @@ export const Route = createFileRoute('/pricing')({ component: PricingPage, }) +// Sign in, then come back here and go straight into `target`'s checkout. +function signInThenCheckout(target: PaidPlan) { + const next = `/pricing?checkout=${target}` + window.location.assign(`/auth/sign-in?next=${encodeURIComponent(next)}`) +} + function PricingPage() { const { signedIn, plan } = Route.useLoaderData() + const { checkout } = Route.useSearch() + const navigate = Route.useNavigate() const [busy, setBusy] = useState(null) const [error, setError] = useState(null) + const resumed = useRef(false) + + useEffect(() => { + if (!checkout || resumed.current) return + resumed.current = true + // Drop the param first so Back from the checkout doesn't restart it. + void navigate({ search: {}, replace: true }) + if (signedIn && plan === 'free') { + void subscribe(checkout) + } + }, [checkout, signedIn, plan]) async function subscribe(target: PaidPlan) { if (!signedIn) { - window.location.assign('/auth/sign-in') + signInThenCheckout(target) return } setBusy(target) @@ -37,7 +65,7 @@ function PricingPage() { body: JSON.stringify({ plan: target }), }) if (res.status === 401) { - window.location.assign('/auth/sign-in') + signInThenCheckout(target) return } const data = (await res.json().catch(() => ({}))) as { url?: string }