Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .npmrc

This file was deleted.

1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ COPY . .
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
ENV SKIP_ENV_VALIDATION=1

# Generate Prisma Client and compile the Next.js application.
RUN npm run build
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ ownership model.

## Requirements

- Node.js 22.12 or later, including its bundled npm 10 or later
- PostgreSQL
- a GitHub OAuth App
- Node.js 22, including its bundled npm 10 or later

The public application does not require PostgreSQL or an OAuth provider. The
legacy authenticated workspace additionally requires PostgreSQL and a GitHub
OAuth App.

Configure the GitHub OAuth callback as:

Expand All @@ -36,6 +38,13 @@ ${BETTER_AUTH_URL}/api/auth/callback/github

```bash
npm ci
npm run dev
```

This starts the public application with sign-in disabled. To exercise the legacy
authenticated workspace instead:

```bash
cp .env.template .env.local
# Fill in the database, Better Auth, and GitHub values.
npm run prisma:migrate
Expand Down Expand Up @@ -81,3 +90,14 @@ Fulling database does not delete Kubernetes resources created by v2.

Use [docs/github-oauth-verification.md](./docs/github-oauth-verification.md) to
verify a real OAuth application before release.

## Vercel Deployment

Fulling can use Vercel's native Next.js deployment without a `vercel.json` file.
The public application builds and starts without environment variables. The
current GitHub sign-in, database-backed workspace, and kubeconfig flows remain
disabled until their complete legacy configuration is present.

Follow [docs/vercel-deployment.md](./docs/vercel-deployment.md) for the complete
project setup, zero-configuration behavior, verification steps, and rollback
procedure.
19 changes: 16 additions & 3 deletions app/(auth)/login/_components/github-login-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { Github, LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { signIn } from '@/lib/auth-client'

export function GitHubLoginButton() {
export function GitHubLoginButton({ enabled }: { enabled: boolean }) {
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<string | null>(null)

async function handleSignIn() {
if (!enabled) return

setIsPending(true)
setError(null)
const result = await signIn.social({
Expand All @@ -26,10 +28,21 @@ export function GitHubLoginButton() {

return (
<div className="mt-8 border-t border-border pt-6">
<Button type="button" size="lg" className="w-full" disabled={isPending} onClick={handleSignIn}>
<Button
type="button"
size="lg"
className="w-full"
disabled={!enabled || isPending}
onClick={handleSignIn}
>
{isPending ? <LoaderCircle className="animate-spin" /> : <Github />}
{isPending ? 'Connecting...' : 'Continue with GitHub'}
{isPending ? 'Connecting...' : enabled ? 'Continue with GitHub' : 'Sign-in unavailable'}
</Button>
{!enabled ? (
<p className="mt-3 text-sm text-muted-foreground">
GitHub sign-in is not available in this deployment.
</p>
) : null}
{error ? <p role="alert" className="mt-3 text-sm text-destructive">{error}</p> : null}
</div>
)
Expand Down
9 changes: 6 additions & 3 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation'

import { FullingBrand } from '@/components/fulling-brand'
import { isAuthConfigured } from '@/lib/auth'
import { getSession } from '@/lib/auth/session'

import { GitHubLoginButton } from './_components/github-login-button'
Expand All @@ -25,17 +26,19 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
Workspace access
</p>
<h1 className="mt-3 text-xl font-semibold leading-[var(--leading-heading)]">
Sign in to your workspace
{isAuthConfigured ? 'Sign in to your workspace' : 'Workspace access is being rebuilt'}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Use your GitHub account to continue to Fulling.
{isAuthConfigured
? 'Use your GitHub account to continue to Fulling.'
: 'The public preview is available, but sign-in and workspace data are temporarily disabled.'}
</p>
{error === 'oauth' ? (
<p role="alert" className="mt-5 text-sm text-destructive">
GitHub sign-in could not be completed. Please try again.
</p>
) : null}
<GitHubLoginButton />
<GitHubLoginButton enabled={isAuthConfigured} />
</div>
</section>
<footer className="h-12 shrink-0 border-t border-border px-4 text-xs leading-[48px] text-muted-foreground sm:px-6">
Expand Down
20 changes: 20 additions & 0 deletions app/api/auth/[...all]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/auth', () => ({ auth: null }))

import { GET, POST } from './route'

describe('/api/auth/[...all]', () => {
it.each([
['GET', GET],
['POST', POST],
])('returns an explicit unavailable response for %s without auth configuration', async (_method, handler) => {
const response = await handler()

expect(response.status).toBe(503)
await expect(response.json()).resolves.toEqual({
code: 'AUTH_UNAVAILABLE',
message: 'Authentication is not configured.',
})
})
})
13 changes: 12 additions & 1 deletion app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,15 @@ import { toNextJsHandler } from 'better-auth/next-js'

import { auth } from '@/lib/auth'

export const { GET, POST } = toNextJsHandler(auth)
export const runtime = 'nodejs'

const unavailable = () =>
Response.json(
{ code: 'AUTH_UNAVAILABLE', message: 'Authentication is not configured.' },
{ status: 503 }
)

const handlers = auth ? toNextJsHandler(auth) : null

export const GET = handlers?.GET ?? unavailable
export const POST = handlers?.POST ?? unavailable
2 changes: 2 additions & 0 deletions app/api/kubeconfig/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from '@/lib/kubeconfig'
import { logger } from '@/lib/logger'

export const runtime = 'nodejs'

const unauthorized = () =>
NextResponse.json({ code: 'UNAUTHORIZED', message: 'Sign in to continue.' }, { status: 401 })

Expand Down
75 changes: 75 additions & 0 deletions docs/vercel-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Vercel Deployment

Fulling's public application can be deployed to Vercel without configuring
GitHub OAuth, Better Auth, or PostgreSQL. Those integrations are currently
optional so they do not block preview and production builds while workspace
access is being rebuilt.

## Project settings

Connect `FullAgent/fulling` to a Vercel project and keep these settings:

- Framework preset: Next.js
- Root directory: repository root
- Install command: Vercel default (`npm install` from `package-lock.json`)
- Build command: `npm run build`
- Output directory: Vercel default
- Node.js: 22.x, enforced by `package.json`
- Production branch: `main`

No `vercel.json` file or application environment variables are required for this
deployment mode. Do not set `SKIP_ENV_VALIDATION`; absent optional integrations
are handled by the application itself.

`output: 'standalone'` remains enabled because the Docker image consumes it.
Vercel uses its native Next.js output.

## Zero-configuration behavior

Without the legacy Auth and database variables:

- `/` renders the public landing page.
- `/login` renders an explicit sign-in unavailable state.
- `/api/auth/*` returns `503 AUTH_UNAVAILABLE` instead of initializing Better
Auth with an unsafe default secret.
- Protected workspace routes redirect to `/login`.
- No Prisma query or Kubernetes credential operation is attempted for anonymous
requests.

The legacy Auth path is enabled only when all five variables are present:

- `DATABASE_URL`
- `BETTER_AUTH_URL`
- `BETTER_AUTH_SECRET`
- `GITHUB_CLIENT_ID`
- `GITHUB_CLIENT_SECRET`

This compatibility path is not required for the current Vercel deployment and
will be replaced with the next authentication design.

## Deploy and verify

With Git integration, push a feature branch to create a Preview deployment and
merge the verified commit to `main` for Production.

Verify the zero-configuration deployment:

1. Confirm `/` returns a successful response and renders the landing page.
2. Confirm `/login` returns a successful response and shows the unavailable
state without a Better Auth error.
3. Confirm a request to `/api/auth/session` returns a structured 503 response.
4. Inspect the build and Function logs and confirm there are no missing-secret,
Prisma connection, or Kubernetes connection errors.

Inspect a deployment in the Vercel dashboard or with the CLI:

```bash
vercel inspect <deployment-url>
vercel logs <deployment-url>
```

Roll back a bad production deployment with:

```bash
vercel rollback
```
51 changes: 34 additions & 17 deletions lib/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,39 @@ import { nextCookies } from 'better-auth/next-js'
import { prisma } from '@/lib/db'
import { env } from '@/lib/env'

export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
session: {
cookieCache: {
enabled: false,
function createAuth() {
const {
BETTER_AUTH_SECRET: secret,
BETTER_AUTH_URL: baseURL,
DATABASE_URL: databaseUrl,
GITHUB_CLIENT_ID: clientId,
GITHUB_CLIENT_SECRET: clientSecret,
} = env

if (!baseURL || !secret || !databaseUrl || !clientId || !clientSecret) {
return null
}

return betterAuth({
baseURL,
secret,
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
session: {
cookieCache: {
enabled: false,
},
},
},
socialProviders: {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
socialProviders: {
github: {
clientId,
clientSecret,
},
},
},
plugins: [nextCookies()],
})
plugins: [nextCookies()],
})
}

export const auth = createAuth()
export const isAuthConfigured = auth !== null
2 changes: 1 addition & 1 deletion lib/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { auth } from './config'
export { auth, isAuthConfigured } from './config'
export type { AppSession } from './session'
export {
getSession,
Expand Down
33 changes: 24 additions & 9 deletions lib/auth/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { getSessionMock, headersMock, redirectMock } = vi.hoisted(() => ({
getSessionMock: vi.fn(),
headersMock: vi.fn(),
redirectMock: vi.fn(),
}))
const { authState, getSessionMock, headersMock, redirectMock } = vi.hoisted(() => {
const getSessionMock = vi.fn()
return {
authState: {
current: { api: { getSession: getSessionMock } } as null | {
api: { getSession: typeof getSessionMock }
},
},
getSessionMock,
headersMock: vi.fn(),
redirectMock: vi.fn(),
}
})

vi.mock('./config', () => ({
auth: {
api: {
getSession: getSessionMock,
},
get auth() {
return authState.current
},
}))

Expand Down Expand Up @@ -63,6 +69,15 @@ const appSession = {
describe('session helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
authState.current = { api: { getSession: getSessionMock } }
})

it('returns null without reading request headers when auth is not configured', async () => {
authState.current = null

await expect(getSession()).resolves.toBeNull()
expect(headersMock).not.toHaveBeenCalled()
expect(getSessionMock).not.toHaveBeenCalled()
})

it('passes request headers and returns only application user fields', async () => {
Expand Down
4 changes: 4 additions & 0 deletions lib/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export class UnauthorizedError extends Error {
}

export async function getSession(): Promise<AppSession | null> {
if (!auth) {
return null
}

const session = await auth.api.getSession({
headers: await headers(),
})
Expand Down
12 changes: 6 additions & 6 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { z } from 'zod'

export const env = createEnv({
server: {
DATABASE_URL: z.url(),
BETTER_AUTH_URL: z.url(),
BETTER_AUTH_SECRET: z.string().min(32),
GITHUB_CLIENT_ID: z.string().min(1),
GITHUB_CLIENT_SECRET: z.string().min(1),
DATABASE_URL: z.url().optional(),
BETTER_AUTH_URL: z.url().optional(),
BETTER_AUTH_SECRET: z.string().min(32).optional(),
GITHUB_CLIENT_ID: z.string().min(1).optional(),
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
LOG_LEVEL: z.string().optional(),
},
client: {},
experimental__runtimeEnv: {},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
})
Loading
Loading