From b3a5eb147c6999a2e00744eb3392b779d548ca35 Mon Sep 17 00:00:00 2001 From: xrendan Date: Wed, 17 Jun 2026 08:52:50 -0600 Subject: [PATCH 01/11] feat: add Doorkeeper OAuth provider integration for draft preview mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Next.js Draft Mode support gated behind York Factory OAuth with admin scope, enabling admins to preview unpublished memos directly in TradingPost. - src/app/api/auth/login/ — initiates OAuth flow with CSRF state cookie - src/app/api/auth/callback/ — exchanges code for token, enables Draft Mode, stores Doorkeeper access token as httpOnly cookie - src/app/api/auth/logout/ — disables Draft Mode and revokes Doorkeeper token - src/lib/api/client.ts — apiFetch accepts previewToken; passes as Bearer header and bypasses ISR caching for preview requests - src/lib/api/memos.ts — fetchMemo threads previewToken through to apiFetch - src/app/memos/[slug]/page.tsx — reads draftMode() + yf_preview_token cookie; fetches draft content when active; shows "Draft Preview Mode" banner with Exit link; generateMetadata also respects draft mode - src/middleware.ts — wires up proxy.ts as actual Next.js middleware so the PostHog dashboard gate runs correctly --- src/app/api/auth/callback/route.ts | 93 ++++++++++++++++++++++++++++++ src/app/api/auth/login/route.ts | 48 +++++++++++++++ src/app/api/auth/logout/route.ts | 32 ++++++++++ src/app/memos/[slug]/page.tsx | 27 ++++++++- src/lib/api/client.ts | 16 ++++- src/lib/api/memos.ts | 3 +- src/middleware.ts | 10 ++++ 7 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/app/api/auth/callback/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/middleware.ts diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..7c037b5 --- /dev/null +++ b/src/app/api/auth/callback/route.ts @@ -0,0 +1,93 @@ +import { draftMode } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +const YF_OAUTH_URL = process.env.YF_OAUTH_URL || "http://localhost:3000"; +const CLIENT_ID = process.env.YF_OAUTH_CLIENT_ID || ""; +const CLIENT_SECRET = process.env.YF_OAUTH_CLIENT_SECRET || ""; +const CALLBACK_URL = + process.env.YF_OAUTH_CALLBACK_URL || + "http://localhost:5050/api/auth/callback"; + +interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + scope: string; + refresh_token?: string; +} + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + const siteUrl = new URL(request.url).origin; + + if (error) { + return NextResponse.redirect( + `${siteUrl}/?preview_error=oauth_denied`, + ); + } + + if (!code || !state) { + return NextResponse.redirect( + `${siteUrl}/?preview_error=invalid_callback`, + ); + } + + const storedState = request.cookies.get("oauth_state")?.value; + const redirectTo = request.cookies.get("oauth_redirect")?.value || "/memos"; + + if (!storedState || state !== storedState) { + return NextResponse.redirect( + `${siteUrl}/?preview_error=state_mismatch`, + ); + } + + const tokenRes = await fetch(`${YF_OAUTH_URL}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: CALLBACK_URL, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }).toString(), + cache: "no-store", + }); + + if (!tokenRes.ok) { + return NextResponse.redirect( + `${siteUrl}/?preview_error=token_exchange_failed`, + ); + } + + const tokenData = (await tokenRes.json()) as TokenResponse; + + if (!tokenData.scope?.split(" ").includes("admin")) { + return NextResponse.redirect( + `${siteUrl}/?preview_error=insufficient_scope`, + ); + } + + // Enable Next.js Draft Mode (sets __prerender_bypass cookie) + (await draftMode()).enable(); + + const isSecure = process.env.NODE_ENV === "production"; + const response = NextResponse.redirect(`${siteUrl}${redirectTo}`); + + response.cookies.delete("oauth_state"); + response.cookies.delete("oauth_redirect"); + + response.cookies.set("yf_preview_token", tokenData.access_token, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + maxAge: tokenData.expires_in, + path: "/", + }); + + return response; +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..3006803 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,48 @@ +import { randomBytes } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; + +const YF_OAUTH_URL = process.env.YF_OAUTH_URL || "http://localhost:3000"; +const CLIENT_ID = process.env.YF_OAUTH_CLIENT_ID || ""; +const CALLBACK_URL = + process.env.YF_OAUTH_CALLBACK_URL || + "http://localhost:5050/api/auth/callback"; + +export async function GET(request: NextRequest) { + if (!CLIENT_ID) { + return NextResponse.json( + { error: "OAuth not configured" }, + { status: 503 }, + ); + } + + const state = randomBytes(16).toString("hex"); + const redirectTo = + request.nextUrl.searchParams.get("redirect") || "/memos"; + + const authorizeUrl = new URL(`${YF_OAUTH_URL}/oauth/authorize`); + authorizeUrl.searchParams.set("client_id", CLIENT_ID); + authorizeUrl.searchParams.set("redirect_uri", CALLBACK_URL); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("scope", "admin"); + authorizeUrl.searchParams.set("state", state); + + const isSecure = process.env.NODE_ENV === "production"; + const response = NextResponse.redirect(authorizeUrl.toString()); + + response.cookies.set("oauth_state", state, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + maxAge: 60 * 10, + path: "/", + }); + response.cookies.set("oauth_redirect", redirectTo, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + maxAge: 60 * 10, + path: "/", + }); + + return response; +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..ce06556 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,32 @@ +import { draftMode } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +const YF_OAUTH_URL = process.env.YF_OAUTH_URL || "http://localhost:3000"; +const CLIENT_ID = process.env.YF_OAUTH_CLIENT_ID || ""; +const CLIENT_SECRET = process.env.YF_OAUTH_CLIENT_SECRET || ""; + +export async function GET(request: NextRequest) { + const siteUrl = new URL(request.url).origin; + + // Revoke Doorkeeper token if present + const previewToken = request.cookies.get("yf_preview_token")?.value; + if (previewToken && CLIENT_ID && CLIENT_SECRET) { + await fetch(`${YF_OAUTH_URL}/oauth/revoke`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + token: previewToken, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }).toString(), + cache: "no-store", + }).catch(() => {}); + } + + (await draftMode()).disable(); + + const response = NextResponse.redirect(`${siteUrl}/memos`); + response.cookies.delete("yf_preview_token"); + + return response; +} diff --git a/src/app/memos/[slug]/page.tsx b/src/app/memos/[slug]/page.tsx index 25f96c5..932e802 100644 --- a/src/app/memos/[slug]/page.tsx +++ b/src/app/memos/[slug]/page.tsx @@ -1,3 +1,4 @@ +import { cookies, draftMode } from "next/headers"; import { notFound, permanentRedirect } from "next/navigation"; import type { Metadata } from "next"; import { fetchMemo, fetchMemos, getSiteConfig } from "@/lib/api"; @@ -26,9 +27,13 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; + const { isEnabled: isDraft } = await draftMode(); + const previewToken = isDraft + ? (await cookies()).get("yf_preview_token")?.value + : undefined; let memo; try { - memo = await fetchMemo(slug); + memo = await fetchMemo(slug, { previewToken }); } catch { return { title: "Memo Not Found | Build Canada" }; } @@ -64,9 +69,16 @@ export default async function MemoDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; + + const { isEnabled: isDraft } = await draftMode(); + const cookieStore = await cookies(); + const previewToken = isDraft + ? cookieStore.get("yf_preview_token")?.value + : undefined; + let memo; try { - memo = await fetchMemo(slug); + memo = await fetchMemo(slug, { previewToken }); } catch { notFound(); } @@ -141,6 +153,17 @@ export default async function MemoDetailPage({ return (
+ {isDraft && ( +
+ Draft Preview Mode — content may be unpublished + + Exit Preview + +
+ )}