Compass mock mode - runs locally without WorkOS auth
Some checks failed
Tests / E2E Web (chromium) (push) Has been cancelled
Tests / Coverage (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / E2E Web (firefox) (push) Has been cancelled
Tests / E2E Web (webkit) (push) Has been cancelled
Tests / E2E Desktop (macos-latest) (push) Has been cancelled
Tests / E2E Desktop (ubuntu-latest) (push) Has been cancelled
Tests / E2E Desktop (windows-latest) (push) Has been cancelled

- Middleware bypasses WorkOS when API keys not configured
- AuthWrapper conditionally loads AuthKitProvider
- getCurrentUser() returns mock dev user when no auth
- Demo mode (/demo) sets cookie for dashboard access
- Memory DB provider fallback when D1 unavailable
- CF dev proxy gated behind ENABLE_CF_DEV=1
- All changes conditional - real auth works if keys provided
This commit is contained in:
Jake Shore 2026-02-18 21:52:50 -05:00
parent 0198898979
commit b80ffee3f7
7 changed files with 114 additions and 7 deletions

View File

@ -27,8 +27,19 @@ export default nextConfig;
// Enable calling `getCloudflareContext()` in `next dev`. // Enable calling `getCloudflareContext()` in `next dev`.
// See https://opennext.js.org/cloudflare/bindings#local-access-to-bindings. // See https://opennext.js.org/cloudflare/bindings#local-access-to-bindings.
// Only init in dev -- build and lint don't need the wrangler proxy. // Only init in dev -- build and lint don't need the wrangler proxy.
if (process.env.NODE_ENV === "development") { // Disabled for local dev without Cloudflare account access:
if (process.env.NODE_ENV === "development" && process.env.ENABLE_CF_DEV === "1") {
import("@opennextjs/cloudflare").then((mod) => import("@opennextjs/cloudflare").then((mod) =>
mod.initOpenNextCloudflareForDev() mod.initOpenNextCloudflareForDev()
); );
} else if (process.env.NODE_ENV === "development") {
// When Cloudflare dev proxy is not enabled, set a mock context so
// getCloudflareContext() doesn't throw. Actions check `env?.DB` and
// gracefully return empty data when it's missing.
const sym = Symbol.for("__cloudflare-context__");
(globalThis as Record<symbol, unknown>)[sym] = {
env: {}, // no DB binding — actions will short-circuit
cf: {},
ctx: { waitUntil: () => {}, passThroughOnException: () => {} },
};
} }

View File

@ -37,6 +37,7 @@ export async function getCustomDashboards(): Promise<
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) return { success: true, data: [] }
const db = getDb(env.DB) const db = getDb(env.DB)
const dashboards = await db.query.customDashboards.findMany({ const dashboards = await db.query.customDashboards.findMany({

View File

@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Sora, IBM_Plex_Mono, Playfair_Display } from "next/font/google"; import { Sora, IBM_Plex_Mono, Playfair_Display } from "next/font/google";
import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { AuthWrapper } from "@/components/auth-wrapper";
import "./globals.css"; import "./globals.css";
const sora = Sora({ const sora = Sora({
@ -44,11 +44,11 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${sora.variable} ${ibmPlexMono.variable} ${playfair.variable} font-sans antialiased`}> <body className={`${sora.variable} ${ibmPlexMono.variable} ${playfair.variable} font-sans antialiased`}>
<AuthKitProvider> <AuthWrapper>
<ThemeProvider> <ThemeProvider>
{children} {children}
</ThemeProvider> </ThemeProvider>
</AuthKitProvider> </AuthWrapper>
</body> </body>
</html> </html>
); );

View File

@ -0,0 +1,26 @@
import type { ReactNode } from "react"
/**
* Server component that conditionally renders the real AuthKitProvider
* (when WorkOS is configured) or a simple passthrough (demo/local mode).
*
* This avoids importing @workos-inc/authkit-nextjs/components when
* WORKOS_API_KEY is empty, which would throw NoApiKeyProvidedException.
*/
const isWorkOSConfigured =
!!process.env.WORKOS_API_KEY &&
!!process.env.WORKOS_CLIENT_ID &&
!process.env.WORKOS_API_KEY.includes("placeholder")
export async function AuthWrapper({ children }: { children: ReactNode }) {
if (isWorkOSConfigured) {
// Dynamic import so the module is never loaded when WorkOS is absent
const { AuthKitProvider } = await import(
"@workos-inc/authkit-nextjs/components"
)
return <AuthKitProvider>{children}</AuthKitProvider>
}
return <>{children}</>
}

View File

@ -23,9 +23,36 @@ const allSchemas = {
...conversationsSchema, ...conversationsSchema,
} }
/**
* Null-safe stub returned when no D1 binding is available (local dev without CF).
* Every property access returns a chainable proxy that resolves to empty results,
* so server actions that forget to check `env?.DB` won't crash.
*/
function createNullDb(): ReturnType<typeof drizzle> {
const handler: ProxyHandler<object> = {
get(_target, prop) {
// .then — make the proxy non-thenable so `await proxy` returns the proxy itself
if (prop === "then") return undefined
// Common drizzle terminal methods — resolve to empty/noop
if (prop === "all" || prop === "values") return async () => []
if (prop === "get") return async () => undefined
if (prop === "run") return async () => ({ changes: 0 })
if (prop === "execute") return async () => []
// findMany / findFirst on the relational query builder
if (prop === "findMany") return async () => []
if (prop === "findFirst") return async () => undefined
// Everything else returns another proxy so chaining works:
// db.select().from(t).where(...) etc.
return new Proxy((..._args: unknown[]) => new Proxy({}, handler), handler)
},
}
return new Proxy({}, handler) as ReturnType<typeof drizzle>
}
// Legacy function - kept for backwards compatibility // Legacy function - kept for backwards compatibility
// Prefer using the provider interface from ./provider for new code // Prefer using the provider interface from ./provider for new code
export function getDb(d1: D1Database) { export function getDb(d1: D1Database) {
if (!d1) return createNullDb()
return drizzle(d1, { schema: allSchemas }) return drizzle(d1, { schema: allSchemas })
} }

View File

@ -1,4 +1,3 @@
import { withAuth, signOut } from "@workos-inc/authkit-nextjs"
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" import { getDb } from "@/db"
import { users, organizations, organizationMembers } from "@/db/schema" import { users, organizations, organizationMembers } from "@/db/schema"
@ -91,6 +90,7 @@ export async function getCurrentUser(): Promise<AuthUser | null> {
} }
// WorkOS is configured -- try real auth first // WorkOS is configured -- try real auth first
const { withAuth } = await import("@workos-inc/authkit-nextjs")
const session = await withAuth() const session = await withAuth()
if (!session || !session.user) { if (!session || !session.user) {
@ -281,6 +281,7 @@ export async function ensureUserExists(workosUser: {
} }
export async function handleSignOut() { export async function handleSignOut() {
const { signOut } = await import("@workos-inc/authkit-nextjs")
await signOut() await signOut()
} }
@ -302,6 +303,7 @@ export async function requireEmailVerified(): Promise<AuthUser> {
!process.env.WORKOS_API_KEY.includes("placeholder") !process.env.WORKOS_API_KEY.includes("placeholder")
if (isWorkOSConfigured) { if (isWorkOSConfigured) {
const { withAuth } = await import("@workos-inc/authkit-nextjs")
const session = await withAuth() const session = await withAuth()
if (session?.user && !session.user.emailVerified) { if (session?.user && !session.user.emailVerified) {
throw new Error("Email not verified") throw new Error("Email not verified")

View File

@ -1,5 +1,4 @@
import { NextRequest } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { authkit, handleAuthkitHeaders } from "@workos-inc/authkit-nextjs"
// public routes that don't require authentication // public routes that don't require authentication
const publicPaths = [ const publicPaths = [
@ -31,7 +30,48 @@ function isPublicPath(pathname: string): boolean {
) )
} }
/**
* When WorkOS is not configured, skip authkit entirely and passthrough.
* Protected routes require the demo cookie; otherwise redirect to login.
*/
function handleNoWorkOS(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl
if (isPublicPath(pathname)) {
return NextResponse.next()
}
const hasDemoCookie =
request.cookies.get("compass-demo")?.value === "true"
if (hasDemoCookie) {
return NextResponse.next()
}
// no session & not demo — redirect to login
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("from", pathname)
return NextResponse.redirect(loginUrl)
}
export default async function middleware(request: NextRequest) { export default async function middleware(request: NextRequest) {
// When WorkOS is not configured, skip authkit entirely to avoid
// NoApiKeyProvidedException. The app falls back to demo/dev user
// via src/lib/auth.ts.
const isWorkOSConfigured =
process.env.WORKOS_API_KEY &&
process.env.WORKOS_CLIENT_ID &&
!process.env.WORKOS_API_KEY.includes("placeholder")
if (!isWorkOSConfigured) {
return handleNoWorkOS(request)
}
// --- WorkOS IS configured: use authkit as normal ---
const { authkit, handleAuthkitHeaders } = await import(
"@workos-inc/authkit-nextjs"
)
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
// get session and headers from authkit (handles token refresh automatically) // get session and headers from authkit (handles token refresh automatically)