Skip to content

fix(auth): stop the /api proxy shadowing the session BFF - #578

Open
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing
Open

fix(auth): stop the /api proxy shadowing the session BFF#578
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Real Google sign-in currently fails on main at the very last step.

The bug

The OAuth callback lands on /auth/callback with a valid handoff token, and the page exchanges it at POST /api/auth/session — the app's own route handler, and the only thing that can mint sapling_session. That request 404s. The popup broadcasts signin_failed, the modal says "Sign-in failed. Please try again.", and no session cookie is ever set.

Caught end to end in a local dev log, signing in with a real @bu.edu account:

backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404

Cause

next.config.ts returned its rewrites as a bare array, which means the afterFiles phase. Next 16 runs that phase before app-router route handlers, so /api/:path* swallowed the app's own /api routes and proxied them to a backend that has no such endpoint. Proved with a scratch route — GET /api/__probe came back with server: uvicorn, never from Next.

The { source: "/api/auth/session", destination: "/api/auth/session" } self-rewrite sitting above it was the attempted exemption. A rewrite whose destination is its own source doesn't re-enter route matching — it resolves to not-found — so it 404'd exactly like the proxy it was meant to dodge.

Fix

Move the proxy to fallback, the phase that runs only after filesystem and dynamic routes have all missed (per Next's bundled rewrites doc, step 8). Anything the app serves itself wins; everything else proxies. That holds for any future BFF route without a per-path exemption, and the self-rewrite goes away.

Verification

On both the dev server and a production build:

  • POST /api/auth/session200 with set-cookie: sapling_session=… (was 404)
  • /api/health → 200 and /api/auth/me → 401, i.e. still proxied to the backend
  • a browser driven through the real popup flow — modal → Google consent → callback — closes the modal, lands on /dashboard with the cookie set, and every authed API call returns 200
  • /dashboard with the cookie is admitted by middleware; without it, redirected to sign-in

Guards

This failed silently in a place no test looked, so it gets two:

  • next.config.test.ts (new) fails if the proxy returns to array/afterFiles form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears. vitest.config.ts's include gains root-level *.test.ts so it runs.
  • e2e/auth-session.spec.ts gains a journey that mints a handoff token via /api/auth/test-login and asserts the BFF answers 200 with a sapling_session cookie. The existing journey minted its cookie through the backend directly — which is exactly why nothing caught this.

Note this also affects the deployed build: the same config ships to Cloudflare, so production would break the same way once it is built from a Next 16 tree.

🤖 Generated with Claude Code

Real Google sign-in got all the way home and died on the last request.
The OAuth callback lands on /auth/callback with a valid handoff token, and
the page exchanges it at POST /api/auth/session — the app's own route
handler, and the only thing that can mint `sapling_session`. That request
404'd, so the popup broadcast `signin_failed`, the modal showed "Sign-in
failed. Please try again.", and no session cookie was ever set. Observed
end to end in the dev server log:
backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404
next.config.ts returned its rewrites as a bare ARRAY, which means the
`afterFiles` phase. Next 16 runs that phase before app-router route
handlers, so `/api/:path*` swallowed the app's own /api routes and proxied
them to a backend that has no such endpoint. Proved with a scratch route:
GET /api/__probe came back from uvicorn, never from Next.
The `{ source: "/api/auth/session", destination: "/api/auth/session" }`
self-rewrite above it was the attempted exemption. A rewrite whose
destination is its own source does not re-enter route matching — it
resolves to not-found — so it 404'd exactly like the proxy it was meant to
dodge.
Fix: move the proxy to `fallback`, the phase that runs only after
filesystem and dynamic routes have all missed (rewrites doc, step 8).
Anything the app serves itself wins; everything else proxies. That holds
for any future BFF route without a per-path exemption, and the self-rewrite
goes away.
Verified on both dev and a production build: POST /api/auth/session returns
200 with `set-cookie: sapling_session=...`, /api/health and /api/auth/me
still proxy to the backend, and a browser driven through the real popup
flow — modal, Google consent, callback — lands on /dashboard with the
cookie set and every authed call 200.
Guards, because this failed silently in a place no test looked:
- next.config.test.ts fails if the proxy returns to array/afterFiles
form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears.
vitest.config.ts's include gains root-level *.test.ts to run it.
- e2e/auth-session.spec.ts gains a journey that mints a handoff token and
asserts the BFF answers 200 with a cookie. The existing journey minted
its cookie through the backend directly, which is why nothing caught
this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingde950e7Commit Preview URL

Branch Preview URL
Aug 23 2026, 05:19 AM

@coderabbitai

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 861f9aca-1c9a-4ccc-add8-7e78ec1db9be


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto 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

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(auth): stop the /api proxy shadowing the session BFF by Darkest-Teddy · Pull Request #578 · SaplingLearn/Sapling · GitHub
Skip to content

fix(auth): stop the /api proxy shadowing the session BFF - #578

Open
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing
Open

fix(auth): stop the /api proxy shadowing the session BFF#578
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Real Google sign-in currently fails on main at the very last step.

The bug

The OAuth callback lands on /auth/callback with a valid handoff token, and the page exchanges it at POST /api/auth/session — the app's own route handler, and the only thing that can mint sapling_session. That request 404s. The popup broadcasts signin_failed, the modal says "Sign-in failed. Please try again.", and no session cookie is ever set.

Caught end to end in a local dev log, signing in with a real @bu.edu account:

backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404

Cause

next.config.ts returned its rewrites as a bare array, which means the afterFiles phase. Next 16 runs that phase before app-router route handlers, so /api/:path* swallowed the app's own /api routes and proxied them to a backend that has no such endpoint. Proved with a scratch route — GET /api/__probe came back with server: uvicorn, never from Next.

The { source: "/api/auth/session", destination: "/api/auth/session" } self-rewrite sitting above it was the attempted exemption. A rewrite whose destination is its own source doesn't re-enter route matching — it resolves to not-found — so it 404'd exactly like the proxy it was meant to dodge.

Fix

Move the proxy to fallback, the phase that runs only after filesystem and dynamic routes have all missed (per Next's bundled rewrites doc, step 8). Anything the app serves itself wins; everything else proxies. That holds for any future BFF route without a per-path exemption, and the self-rewrite goes away.

Verification

On both the dev server and a production build:

  • POST /api/auth/session200 with set-cookie: sapling_session=… (was 404)
  • /api/health → 200 and /api/auth/me → 401, i.e. still proxied to the backend
  • a browser driven through the real popup flow — modal → Google consent → callback — closes the modal, lands on /dashboard with the cookie set, and every authed API call returns 200
  • /dashboard with the cookie is admitted by middleware; without it, redirected to sign-in

Guards

This failed silently in a place no test looked, so it gets two:

  • next.config.test.ts (new) fails if the proxy returns to array/afterFiles form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears. vitest.config.ts's include gains root-level *.test.ts so it runs.
  • e2e/auth-session.spec.ts gains a journey that mints a handoff token via /api/auth/test-login and asserts the BFF answers 200 with a sapling_session cookie. The existing journey minted its cookie through the backend directly — which is exactly why nothing caught this.

Note this also affects the deployed build: the same config ships to Cloudflare, so production would break the same way once it is built from a Next 16 tree.

🤖 Generated with Claude Code

Real Google sign-in got all the way home and died on the last request.
The OAuth callback lands on /auth/callback with a valid handoff token, and
the page exchanges it at POST /api/auth/session — the app's own route
handler, and the only thing that can mint `sapling_session`. That request
404'd, so the popup broadcast `signin_failed`, the modal showed "Sign-in
failed. Please try again.", and no session cookie was ever set. Observed
end to end in the dev server log:
backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404
next.config.ts returned its rewrites as a bare ARRAY, which means the
`afterFiles` phase. Next 16 runs that phase before app-router route
handlers, so `/api/:path*` swallowed the app's own /api routes and proxied
them to a backend that has no such endpoint. Proved with a scratch route:
GET /api/__probe came back from uvicorn, never from Next.
The `{ source: "/api/auth/session", destination: "/api/auth/session" }`
self-rewrite above it was the attempted exemption. A rewrite whose
destination is its own source does not re-enter route matching — it
resolves to not-found — so it 404'd exactly like the proxy it was meant to
dodge.
Fix: move the proxy to `fallback`, the phase that runs only after
filesystem and dynamic routes have all missed (rewrites doc, step 8).
Anything the app serves itself wins; everything else proxies. That holds
for any future BFF route without a per-path exemption, and the self-rewrite
goes away.
Verified on both dev and a production build: POST /api/auth/session returns
200 with `set-cookie: sapling_session=...`, /api/health and /api/auth/me
still proxy to the backend, and a browser driven through the real popup
flow — modal, Google consent, callback — lands on /dashboard with the
cookie set and every authed call 200.
Guards, because this failed silently in a place no test looked:
- next.config.test.ts fails if the proxy returns to array/afterFiles
form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears.
vitest.config.ts's include gains root-level *.test.ts to run it.
- e2e/auth-session.spec.ts gains a journey that mints a handoff token and
asserts the BFF answers 200 with a cookie. The existing journey minted
its cookie through the backend directly, which is why nothing caught
this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingde950e7Commit Preview URL

Branch Preview URL
Aug 23 2026, 05:19 AM

@coderabbitai

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 861f9aca-1c9a-4ccc-add8-7e78ec1db9be


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto 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

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(auth): stop the /api proxy shadowing the session BFF by Darkest-Teddy · Pull Request #578 · SaplingLearn/Sapling · GitHub
Skip to content

fix(auth): stop the /api proxy shadowing the session BFF - #578

Open
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing
Open

fix(auth): stop the /api proxy shadowing the session BFF#578
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Real Google sign-in currently fails on main at the very last step.

The bug

The OAuth callback lands on /auth/callback with a valid handoff token, and the page exchanges it at POST /api/auth/session — the app's own route handler, and the only thing that can mint sapling_session. That request 404s. The popup broadcasts signin_failed, the modal says "Sign-in failed. Please try again.", and no session cookie is ever set.

Caught end to end in a local dev log, signing in with a real @bu.edu account:

backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404

Cause

next.config.ts returned its rewrites as a bare array, which means the afterFiles phase. Next 16 runs that phase before app-router route handlers, so /api/:path* swallowed the app's own /api routes and proxied them to a backend that has no such endpoint. Proved with a scratch route — GET /api/__probe came back with server: uvicorn, never from Next.

The { source: "/api/auth/session", destination: "/api/auth/session" } self-rewrite sitting above it was the attempted exemption. A rewrite whose destination is its own source doesn't re-enter route matching — it resolves to not-found — so it 404'd exactly like the proxy it was meant to dodge.

Fix

Move the proxy to fallback, the phase that runs only after filesystem and dynamic routes have all missed (per Next's bundled rewrites doc, step 8). Anything the app serves itself wins; everything else proxies. That holds for any future BFF route without a per-path exemption, and the self-rewrite goes away.

Verification

On both the dev server and a production build:

  • POST /api/auth/session200 with set-cookie: sapling_session=… (was 404)
  • /api/health → 200 and /api/auth/me → 401, i.e. still proxied to the backend
  • a browser driven through the real popup flow — modal → Google consent → callback — closes the modal, lands on /dashboard with the cookie set, and every authed API call returns 200
  • /dashboard with the cookie is admitted by middleware; without it, redirected to sign-in

Guards

This failed silently in a place no test looked, so it gets two:

  • next.config.test.ts (new) fails if the proxy returns to array/afterFiles form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears. vitest.config.ts's include gains root-level *.test.ts so it runs.
  • e2e/auth-session.spec.ts gains a journey that mints a handoff token via /api/auth/test-login and asserts the BFF answers 200 with a sapling_session cookie. The existing journey minted its cookie through the backend directly — which is exactly why nothing caught this.

Note this also affects the deployed build: the same config ships to Cloudflare, so production would break the same way once it is built from a Next 16 tree.

🤖 Generated with Claude Code

Real Google sign-in got all the way home and died on the last request.
The OAuth callback lands on /auth/callback with a valid handoff token, and
the page exchanges it at POST /api/auth/session — the app's own route
handler, and the only thing that can mint `sapling_session`. That request
404'd, so the popup broadcast `signin_failed`, the modal showed "Sign-in
failed. Please try again.", and no session cookie was ever set. Observed
end to end in the dev server log:
backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404
next.config.ts returned its rewrites as a bare ARRAY, which means the
`afterFiles` phase. Next 16 runs that phase before app-router route
handlers, so `/api/:path*` swallowed the app's own /api routes and proxied
them to a backend that has no such endpoint. Proved with a scratch route:
GET /api/__probe came back from uvicorn, never from Next.
The `{ source: "/api/auth/session", destination: "/api/auth/session" }`
self-rewrite above it was the attempted exemption. A rewrite whose
destination is its own source does not re-enter route matching — it
resolves to not-found — so it 404'd exactly like the proxy it was meant to
dodge.
Fix: move the proxy to `fallback`, the phase that runs only after
filesystem and dynamic routes have all missed (rewrites doc, step 8).
Anything the app serves itself wins; everything else proxies. That holds
for any future BFF route without a per-path exemption, and the self-rewrite
goes away.
Verified on both dev and a production build: POST /api/auth/session returns
200 with `set-cookie: sapling_session=...`, /api/health and /api/auth/me
still proxy to the backend, and a browser driven through the real popup
flow — modal, Google consent, callback — lands on /dashboard with the
cookie set and every authed call 200.
Guards, because this failed silently in a place no test looked:
- next.config.test.ts fails if the proxy returns to array/afterFiles
form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears.
vitest.config.ts's include gains root-level *.test.ts to run it.
- e2e/auth-session.spec.ts gains a journey that mints a handoff token and
asserts the BFF answers 200 with a cookie. The existing journey minted
its cookie through the backend directly, which is why nothing caught
this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingde950e7Commit Preview URL

Branch Preview URL
Aug 23 2026, 05:19 AM

@coderabbitai

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 861f9aca-1c9a-4ccc-add8-7e78ec1db9be


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto 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

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(auth): stop the /api proxy shadowing the session BFF by Darkest-Teddy · Pull Request #578 · SaplingLearn/Sapling · GitHub
Skip to content

fix(auth): stop the /api proxy shadowing the session BFF - #578

Open
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing
Open

fix(auth): stop the /api proxy shadowing the session BFF#578
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Real Google sign-in currently fails on main at the very last step.

The bug

The OAuth callback lands on /auth/callback with a valid handoff token, and the page exchanges it at POST /api/auth/session — the app's own route handler, and the only thing that can mint sapling_session. That request 404s. The popup broadcasts signin_failed, the modal says "Sign-in failed. Please try again.", and no session cookie is ever set.

Caught end to end in a local dev log, signing in with a real @bu.edu account:

backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404

Cause

next.config.ts returned its rewrites as a bare array, which means the afterFiles phase. Next 16 runs that phase before app-router route handlers, so /api/:path* swallowed the app's own /api routes and proxied them to a backend that has no such endpoint. Proved with a scratch route — GET /api/__probe came back with server: uvicorn, never from Next.

The { source: "/api/auth/session", destination: "/api/auth/session" } self-rewrite sitting above it was the attempted exemption. A rewrite whose destination is its own source doesn't re-enter route matching — it resolves to not-found — so it 404'd exactly like the proxy it was meant to dodge.

Fix

Move the proxy to fallback, the phase that runs only after filesystem and dynamic routes have all missed (per Next's bundled rewrites doc, step 8). Anything the app serves itself wins; everything else proxies. That holds for any future BFF route without a per-path exemption, and the self-rewrite goes away.

Verification

On both the dev server and a production build:

  • POST /api/auth/session200 with set-cookie: sapling_session=… (was 404)
  • /api/health → 200 and /api/auth/me → 401, i.e. still proxied to the backend
  • a browser driven through the real popup flow — modal → Google consent → callback — closes the modal, lands on /dashboard with the cookie set, and every authed API call returns 200
  • /dashboard with the cookie is admitted by middleware; without it, redirected to sign-in

Guards

This failed silently in a place no test looked, so it gets two:

  • next.config.test.ts (new) fails if the proxy returns to array/afterFiles form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears. vitest.config.ts's include gains root-level *.test.ts so it runs.
  • e2e/auth-session.spec.ts gains a journey that mints a handoff token via /api/auth/test-login and asserts the BFF answers 200 with a sapling_session cookie. The existing journey minted its cookie through the backend directly — which is exactly why nothing caught this.

Note this also affects the deployed build: the same config ships to Cloudflare, so production would break the same way once it is built from a Next 16 tree.

🤖 Generated with Claude Code

Real Google sign-in got all the way home and died on the last request.
The OAuth callback lands on /auth/callback with a valid handoff token, and
the page exchanges it at POST /api/auth/session — the app's own route
handler, and the only thing that can mint `sapling_session`. That request
404'd, so the popup broadcast `signin_failed`, the modal showed "Sign-in
failed. Please try again.", and no session cookie was ever set. Observed
end to end in the dev server log:
backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404
next.config.ts returned its rewrites as a bare ARRAY, which means the
`afterFiles` phase. Next 16 runs that phase before app-router route
handlers, so `/api/:path*` swallowed the app's own /api routes and proxied
them to a backend that has no such endpoint. Proved with a scratch route:
GET /api/__probe came back from uvicorn, never from Next.
The `{ source: "/api/auth/session", destination: "/api/auth/session" }`
self-rewrite above it was the attempted exemption. A rewrite whose
destination is its own source does not re-enter route matching — it
resolves to not-found — so it 404'd exactly like the proxy it was meant to
dodge.
Fix: move the proxy to `fallback`, the phase that runs only after
filesystem and dynamic routes have all missed (rewrites doc, step 8).
Anything the app serves itself wins; everything else proxies. That holds
for any future BFF route without a per-path exemption, and the self-rewrite
goes away.
Verified on both dev and a production build: POST /api/auth/session returns
200 with `set-cookie: sapling_session=...`, /api/health and /api/auth/me
still proxy to the backend, and a browser driven through the real popup
flow — modal, Google consent, callback — lands on /dashboard with the
cookie set and every authed call 200.
Guards, because this failed silently in a place no test looked:
- next.config.test.ts fails if the proxy returns to array/afterFiles
form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears.
vitest.config.ts's include gains root-level *.test.ts to run it.
- e2e/auth-session.spec.ts gains a journey that mints a handoff token and
asserts the BFF answers 200 with a cookie. The existing journey minted
its cookie through the backend directly, which is why nothing caught
this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingde950e7Commit Preview URL

Branch Preview URL
Aug 23 2026, 05:19 AM

@coderabbitai

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 861f9aca-1c9a-4ccc-add8-7e78ec1db9be


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto 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

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(auth): stop the /api proxy shadowing the session BFF by Darkest-Teddy · Pull Request #578 · SaplingLearn/Sapling · GitHub
Skip to content

fix(auth): stop the /api proxy shadowing the session BFF - #578

Open
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing
Open

fix(auth): stop the /api proxy shadowing the session BFF#578
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Real Google sign-in currently fails on main at the very last step.

The bug

The OAuth callback lands on /auth/callback with a valid handoff token, and the page exchanges it at POST /api/auth/session — the app's own route handler, and the only thing that can mint sapling_session. That request 404s. The popup broadcasts signin_failed, the modal says "Sign-in failed. Please try again.", and no session cookie is ever set.

Caught end to end in a local dev log, signing in with a real @bu.edu account:

backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404

Cause

next.config.ts returned its rewrites as a bare array, which means the afterFiles phase. Next 16 runs that phase before app-router route handlers, so /api/:path* swallowed the app's own /api routes and proxied them to a backend that has no such endpoint. Proved with a scratch route — GET /api/__probe came back with server: uvicorn, never from Next.

The { source: "/api/auth/session", destination: "/api/auth/session" } self-rewrite sitting above it was the attempted exemption. A rewrite whose destination is its own source doesn't re-enter route matching — it resolves to not-found — so it 404'd exactly like the proxy it was meant to dodge.

Fix

Move the proxy to fallback, the phase that runs only after filesystem and dynamic routes have all missed (per Next's bundled rewrites doc, step 8). Anything the app serves itself wins; everything else proxies. That holds for any future BFF route without a per-path exemption, and the self-rewrite goes away.

Verification

On both the dev server and a production build:

  • POST /api/auth/session200 with set-cookie: sapling_session=… (was 404)
  • /api/health → 200 and /api/auth/me → 401, i.e. still proxied to the backend
  • a browser driven through the real popup flow — modal → Google consent → callback — closes the modal, lands on /dashboard with the cookie set, and every authed API call returns 200
  • /dashboard with the cookie is admitted by middleware; without it, redirected to sign-in

Guards

This failed silently in a place no test looked, so it gets two:

  • next.config.test.ts (new) fails if the proxy returns to array/afterFiles form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears. vitest.config.ts's include gains root-level *.test.ts so it runs.
  • e2e/auth-session.spec.ts gains a journey that mints a handoff token via /api/auth/test-login and asserts the BFF answers 200 with a sapling_session cookie. The existing journey minted its cookie through the backend directly — which is exactly why nothing caught this.

Note this also affects the deployed build: the same config ships to Cloudflare, so production would break the same way once it is built from a Next 16 tree.

🤖 Generated with Claude Code

Real Google sign-in got all the way home and died on the last request.
The OAuth callback lands on /auth/callback with a valid handoff token, and
the page exchanges it at POST /api/auth/session — the app's own route
handler, and the only thing that can mint `sapling_session`. That request
404'd, so the popup broadcast `signin_failed`, the modal showed "Sign-in
failed. Please try again.", and no session cookie was ever set. Observed
end to end in the dev server log:
backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404
next.config.ts returned its rewrites as a bare ARRAY, which means the
`afterFiles` phase. Next 16 runs that phase before app-router route
handlers, so `/api/:path*` swallowed the app's own /api routes and proxied
them to a backend that has no such endpoint. Proved with a scratch route:
GET /api/__probe came back from uvicorn, never from Next.
The `{ source: "/api/auth/session", destination: "/api/auth/session" }`
self-rewrite above it was the attempted exemption. A rewrite whose
destination is its own source does not re-enter route matching — it
resolves to not-found — so it 404'd exactly like the proxy it was meant to
dodge.
Fix: move the proxy to `fallback`, the phase that runs only after
filesystem and dynamic routes have all missed (rewrites doc, step 8).
Anything the app serves itself wins; everything else proxies. That holds
for any future BFF route without a per-path exemption, and the self-rewrite
goes away.
Verified on both dev and a production build: POST /api/auth/session returns
200 with `set-cookie: sapling_session=...`, /api/health and /api/auth/me
still proxy to the backend, and a browser driven through the real popup
flow — modal, Google consent, callback — lands on /dashboard with the
cookie set and every authed call 200.
Guards, because this failed silently in a place no test looked:
- next.config.test.ts fails if the proxy returns to array/afterFiles
form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears.
vitest.config.ts's include gains root-level *.test.ts to run it.
- e2e/auth-session.spec.ts gains a journey that mints a handoff token and
asserts the BFF answers 200 with a cookie. The existing journey minted
its cookie through the backend directly, which is why nothing caught
this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingde950e7Commit Preview URL

Branch Preview URL
Aug 23 2026, 05:19 AM

@coderabbitai

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 861f9aca-1c9a-4ccc-add8-7e78ec1db9be


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto 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

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(auth): stop the /api proxy shadowing the session BFF by Darkest-Teddy · Pull Request #578 · SaplingLearn/Sapling · GitHub
Skip to content

fix(auth): stop the /api proxy shadowing the session BFF - #578

Open
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing
Open

fix(auth): stop the /api proxy shadowing the session BFF#578
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Real Google sign-in currently fails on main at the very last step.

The bug

The OAuth callback lands on /auth/callback with a valid handoff token, and the page exchanges it at POST /api/auth/session — the app's own route handler, and the only thing that can mint sapling_session. That request 404s. The popup broadcasts signin_failed, the modal says "Sign-in failed. Please try again.", and no session cookie is ever set.

Caught end to end in a local dev log, signing in with a real @bu.edu account:

backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404

Cause

next.config.ts returned its rewrites as a bare array, which means the afterFiles phase. Next 16 runs that phase before app-router route handlers, so /api/:path* swallowed the app's own /api routes and proxied them to a backend that has no such endpoint. Proved with a scratch route — GET /api/__probe came back with server: uvicorn, never from Next.

The { source: "/api/auth/session", destination: "/api/auth/session" } self-rewrite sitting above it was the attempted exemption. A rewrite whose destination is its own source doesn't re-enter route matching — it resolves to not-found — so it 404'd exactly like the proxy it was meant to dodge.

Fix

Move the proxy to fallback, the phase that runs only after filesystem and dynamic routes have all missed (per Next's bundled rewrites doc, step 8). Anything the app serves itself wins; everything else proxies. That holds for any future BFF route without a per-path exemption, and the self-rewrite goes away.

Verification

On both the dev server and a production build:

  • POST /api/auth/session200 with set-cookie: sapling_session=… (was 404)
  • /api/health → 200 and /api/auth/me → 401, i.e. still proxied to the backend
  • a browser driven through the real popup flow — modal → Google consent → callback — closes the modal, lands on /dashboard with the cookie set, and every authed API call returns 200
  • /dashboard with the cookie is admitted by middleware; without it, redirected to sign-in

Guards

This failed silently in a place no test looked, so it gets two:

  • next.config.test.ts (new) fails if the proxy returns to array/afterFiles form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears. vitest.config.ts's include gains root-level *.test.ts so it runs.
  • e2e/auth-session.spec.ts gains a journey that mints a handoff token via /api/auth/test-login and asserts the BFF answers 200 with a sapling_session cookie. The existing journey minted its cookie through the backend directly — which is exactly why nothing caught this.

Note this also affects the deployed build: the same config ships to Cloudflare, so production would break the same way once it is built from a Next 16 tree.

🤖 Generated with Claude Code

Real Google sign-in got all the way home and died on the last request.
The OAuth callback lands on /auth/callback with a valid handoff token, and
the page exchanges it at POST /api/auth/session — the app's own route
handler, and the only thing that can mint `sapling_session`. That request
404'd, so the popup broadcast `signin_failed`, the modal showed "Sign-in
failed. Please try again.", and no session cookie was ever set. Observed
end to end in the dev server log:
backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404
next.config.ts returned its rewrites as a bare ARRAY, which means the
`afterFiles` phase. Next 16 runs that phase before app-router route
handlers, so `/api/:path*` swallowed the app's own /api routes and proxied
them to a backend that has no such endpoint. Proved with a scratch route:
GET /api/__probe came back from uvicorn, never from Next.
The `{ source: "/api/auth/session", destination: "/api/auth/session" }`
self-rewrite above it was the attempted exemption. A rewrite whose
destination is its own source does not re-enter route matching — it
resolves to not-found — so it 404'd exactly like the proxy it was meant to
dodge.
Fix: move the proxy to `fallback`, the phase that runs only after
filesystem and dynamic routes have all missed (rewrites doc, step 8).
Anything the app serves itself wins; everything else proxies. That holds
for any future BFF route without a per-path exemption, and the self-rewrite
goes away.
Verified on both dev and a production build: POST /api/auth/session returns
200 with `set-cookie: sapling_session=...`, /api/health and /api/auth/me
still proxy to the backend, and a browser driven through the real popup
flow — modal, Google consent, callback — lands on /dashboard with the
cookie set and every authed call 200.
Guards, because this failed silently in a place no test looked:
- next.config.test.ts fails if the proxy returns to array/afterFiles
form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears.
vitest.config.ts's include gains root-level *.test.ts to run it.
- e2e/auth-session.spec.ts gains a journey that mints a handoff token and
asserts the BFF answers 200 with a cookie. The existing journey minted
its cookie through the backend directly, which is why nothing caught
this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingde950e7Commit Preview URL

Branch Preview URL
Aug 23 2026, 05:19 AM

@coderabbitai

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 861f9aca-1c9a-4ccc-add8-7e78ec1db9be


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto 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

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(auth): stop the /api proxy shadowing the session BFF by Darkest-Teddy · Pull Request #578 · SaplingLearn/Sapling · GitHub
Skip to content

fix(auth): stop the /api proxy shadowing the session BFF - #578

Open
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing
Open

fix(auth): stop the /api proxy shadowing the session BFF#578
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Real Google sign-in currently fails on main at the very last step.

The bug

The OAuth callback lands on /auth/callback with a valid handoff token, and the page exchanges it at POST /api/auth/session — the app's own route handler, and the only thing that can mint sapling_session. That request 404s. The popup broadcasts signin_failed, the modal says "Sign-in failed. Please try again.", and no session cookie is ever set.

Caught end to end in a local dev log, signing in with a real @bu.edu account:

backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404

Cause

next.config.ts returned its rewrites as a bare array, which means the afterFiles phase. Next 16 runs that phase before app-router route handlers, so /api/:path* swallowed the app's own /api routes and proxied them to a backend that has no such endpoint. Proved with a scratch route — GET /api/__probe came back with server: uvicorn, never from Next.

The { source: "/api/auth/session", destination: "/api/auth/session" } self-rewrite sitting above it was the attempted exemption. A rewrite whose destination is its own source doesn't re-enter route matching — it resolves to not-found — so it 404'd exactly like the proxy it was meant to dodge.

Fix

Move the proxy to fallback, the phase that runs only after filesystem and dynamic routes have all missed (per Next's bundled rewrites doc, step 8). Anything the app serves itself wins; everything else proxies. That holds for any future BFF route without a per-path exemption, and the self-rewrite goes away.

Verification

On both the dev server and a production build:

  • POST /api/auth/session200 with set-cookie: sapling_session=… (was 404)
  • /api/health → 200 and /api/auth/me → 401, i.e. still proxied to the backend
  • a browser driven through the real popup flow — modal → Google consent → callback — closes the modal, lands on /dashboard with the cookie set, and every authed API call returns 200
  • /dashboard with the cookie is admitted by middleware; without it, redirected to sign-in

Guards

This failed silently in a place no test looked, so it gets two:

  • next.config.test.ts (new) fails if the proxy returns to array/afterFiles form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears. vitest.config.ts's include gains root-level *.test.ts so it runs.
  • e2e/auth-session.spec.ts gains a journey that mints a handoff token via /api/auth/test-login and asserts the BFF answers 200 with a sapling_session cookie. The existing journey minted its cookie through the backend directly — which is exactly why nothing caught this.

Note this also affects the deployed build: the same config ships to Cloudflare, so production would break the same way once it is built from a Next 16 tree.

🤖 Generated with Claude Code

Real Google sign-in got all the way home and died on the last request.
The OAuth callback lands on /auth/callback with a valid handoff token, and
the page exchanges it at POST /api/auth/session — the app's own route
handler, and the only thing that can mint `sapling_session`. That request
404'd, so the popup broadcast `signin_failed`, the modal showed "Sign-in
failed. Please try again.", and no session cookie was ever set. Observed
end to end in the dev server log:
backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404
next.config.ts returned its rewrites as a bare ARRAY, which means the
`afterFiles` phase. Next 16 runs that phase before app-router route
handlers, so `/api/:path*` swallowed the app's own /api routes and proxied
them to a backend that has no such endpoint. Proved with a scratch route:
GET /api/__probe came back from uvicorn, never from Next.
The `{ source: "/api/auth/session", destination: "/api/auth/session" }`
self-rewrite above it was the attempted exemption. A rewrite whose
destination is its own source does not re-enter route matching — it
resolves to not-found — so it 404'd exactly like the proxy it was meant to
dodge.
Fix: move the proxy to `fallback`, the phase that runs only after
filesystem and dynamic routes have all missed (rewrites doc, step 8).
Anything the app serves itself wins; everything else proxies. That holds
for any future BFF route without a per-path exemption, and the self-rewrite
goes away.
Verified on both dev and a production build: POST /api/auth/session returns
200 with `set-cookie: sapling_session=...`, /api/health and /api/auth/me
still proxy to the backend, and a browser driven through the real popup
flow — modal, Google consent, callback — lands on /dashboard with the
cookie set and every authed call 200.
Guards, because this failed silently in a place no test looked:
- next.config.test.ts fails if the proxy returns to array/afterFiles
form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears.
vitest.config.ts's include gains root-level *.test.ts to run it.
- e2e/auth-session.spec.ts gains a journey that mints a handoff token and
asserts the BFF answers 200 with a cookie. The existing journey minted
its cookie through the backend directly, which is why nothing caught
this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingde950e7Commit Preview URL

Branch Preview URL
Aug 23 2026, 05:19 AM

@coderabbitai

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 861f9aca-1c9a-4ccc-add8-7e78ec1db9be


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto 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

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(auth): stop the /api proxy shadowing the session BFF by Darkest-Teddy · Pull Request #578 · SaplingLearn/Sapling · GitHub
Skip to content

fix(auth): stop the /api proxy shadowing the session BFF - #578

Open
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing
Open

fix(auth): stop the /api proxy shadowing the session BFF#578
Darkest-Teddy wants to merge 1 commit into
mainfrom
fix/session-bff-rewrite-shadowing

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Real Google sign-in currently fails on main at the very last step.

The bug

The OAuth callback lands on /auth/callback with a valid handoff token, and the page exchanges it at POST /api/auth/session — the app's own route handler, and the only thing that can mint sapling_session. That request 404s. The popup broadcasts signin_failed, the modal says "Sign-in failed. Please try again.", and no session cookie is ever set.

Caught end to end in a local dev log, signing in with a real @bu.edu account:

backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404

Cause

next.config.ts returned its rewrites as a bare array, which means the afterFiles phase. Next 16 runs that phase before app-router route handlers, so /api/:path* swallowed the app's own /api routes and proxied them to a backend that has no such endpoint. Proved with a scratch route — GET /api/__probe came back with server: uvicorn, never from Next.

The { source: "/api/auth/session", destination: "/api/auth/session" } self-rewrite sitting above it was the attempted exemption. A rewrite whose destination is its own source doesn't re-enter route matching — it resolves to not-found — so it 404'd exactly like the proxy it was meant to dodge.

Fix

Move the proxy to fallback, the phase that runs only after filesystem and dynamic routes have all missed (per Next's bundled rewrites doc, step 8). Anything the app serves itself wins; everything else proxies. That holds for any future BFF route without a per-path exemption, and the self-rewrite goes away.

Verification

On both the dev server and a production build:

  • POST /api/auth/session200 with set-cookie: sapling_session=… (was 404)
  • /api/health → 200 and /api/auth/me → 401, i.e. still proxied to the backend
  • a browser driven through the real popup flow — modal → Google consent → callback — closes the modal, lands on /dashboard with the cookie set, and every authed API call returns 200
  • /dashboard with the cookie is admitted by middleware; without it, redirected to sign-in

Guards

This failed silently in a place no test looked, so it gets two:

  • next.config.test.ts (new) fails if the proxy returns to array/afterFiles form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears. vitest.config.ts's include gains root-level *.test.ts so it runs.
  • e2e/auth-session.spec.ts gains a journey that mints a handoff token via /api/auth/test-login and asserts the BFF answers 200 with a sapling_session cookie. The existing journey minted its cookie through the backend directly — which is exactly why nothing caught this.

Note this also affects the deployed build: the same config ships to Cloudflare, so production would break the same way once it is built from a Next 16 tree.

🤖 Generated with Claude Code

Real Google sign-in got all the way home and died on the last request.
The OAuth callback lands on /auth/callback with a valid handoff token, and
the page exchanges it at POST /api/auth/session — the app's own route
handler, and the only thing that can mint `sapling_session`. That request
404'd, so the popup broadcast `signin_failed`, the modal showed "Sign-in
failed. Please try again.", and no session cookie was ever set. Observed
end to end in the dev server log:
backend GET /api/auth/google/callback -> 307 (consent OK, hd=bu.edu)
frontend GET /auth/callback?...&is_approved=true&auth_token=... 200
frontend POST /api/auth/session 404
next.config.ts returned its rewrites as a bare ARRAY, which means the
`afterFiles` phase. Next 16 runs that phase before app-router route
handlers, so `/api/:path*` swallowed the app's own /api routes and proxied
them to a backend that has no such endpoint. Proved with a scratch route:
GET /api/__probe came back from uvicorn, never from Next.
The `{ source: "/api/auth/session", destination: "/api/auth/session" }`
self-rewrite above it was the attempted exemption. A rewrite whose
destination is its own source does not re-enter route matching — it
resolves to not-found — so it 404'd exactly like the proxy it was meant to
dodge.
Fix: move the proxy to `fallback`, the phase that runs only after
filesystem and dynamic routes have all missed (rewrites doc, step 8).
Anything the app serves itself wins; everything else proxies. That holds
for any future BFF route without a per-path exemption, and the self-rewrite
goes away.
Verified on both dev and a production build: POST /api/auth/session returns
200 with `set-cookie: sapling_session=...`, /api/health and /api/auth/me
still proxy to the backend, and a browser driven through the real popup
flow — modal, Google consent, callback — lands on /dashboard with the
cookie set and every authed call 200.
Guards, because this failed silently in a place no test looked:
- next.config.test.ts fails if the proxy returns to array/afterFiles
form, lands in beforeFiles/afterFiles, or if a self-rewrite reappears.
vitest.config.ts's include gains root-level *.test.ts to run it.
- e2e/auth-session.spec.ts gains a journey that mints a handoff token and
asserts the BFF answers 200 with a cookie. The existing journey minted
its cookie through the backend directly, which is why nothing caught
this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingde950e7Commit Preview URL

Branch Preview URL
Aug 23 2026, 05:19 AM

@coderabbitai

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 861f9aca-1c9a-4ccc-add8-7e78ec1db9be


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto 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

@Darkest-Teddy