Skip to content
Open
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
99 changes: 99 additions & 0 deletions apps/web/lib/auth-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* @file Authentication helper utilities for web worker
*
* Contains reusable functions for handling Better Auth session validation
* and cache control for authentication-dependent content.
*/

import type { BetterAuthApiResponse, BetterAuthSession } from "../types/env";

/**
* Better Auth default cookie names
*
* - SESSION: Standard cookie name for HTTP
* - SESSION_SECURE: Secure cookie name for HTTPS with __Secure- prefix
*/
export const COOKIE_NAMES = {
SESSION: "better-auth.session_token",
SESSION_SECURE: "__Secure-better-auth.session_token",
} as const;

/**
* Set cache control headers to prevent caching of auth-dependent content
*
* Headers set:
* - Cache-Control: private, no-store
* - private: Only browser can cache, not CDN
* - no-store: Don't cache at all
* - Vary: Cookie
* - Cache key varies by cookie value
*
* This prevents CDN/browser from serving cached authenticated content
* to unauthenticated users (or vice versa), which would be a security issue.
*
* @param response - The Response object to modify
* @returns The same Response object with cache headers set
*
* @example
* ```ts
* const response = await fetch(url);
* return setCacheHeaders(response);
* ```
*/
export function setCacheHeaders(response: Response): Response {
response.headers.set("Cache-Control", "private, no-store");
response.headers.set("Vary", "Cookie");
return response;
}

/**
* Extract session from Better Auth API response
*
* Handles multiple response formats from different Better Auth plugins/wrappers:
* - Direct session field: `{ session: {...} }` (most common)
* - Wrapped in data object: `{ data: { session: {...} } }` (some client wrappers)
* - Data itself is session: `{ data: {...session fields...} }` (rare)
*
* @param json - The parsed JSON response from Better Auth API, or null
* @returns The extracted session object, or null if no valid session found
*
* @example
* ```ts
* const response = await apiService.fetch('/api/auth/get-session');
* const json = await response.json();
* const session = extractSession(json);
* if (session) {
* // User is authenticated
* }
* ```
*/
export function extractSession(
json: BetterAuthApiResponse | null,
): BetterAuthSession | null {
if (!json) return null;

// Format 1: Direct session field (most common)
if (json.session) {
return json.session;
}

// Format 2 & 3: Session in data field
if (json.data) {
// Check if data has a session property
if (typeof json.data === "object" && "session" in json.data) {
const nestedSession = (json.data as { session?: BetterAuthSession })
.session;
return nestedSession ?? null;
}
// data itself might be the session - validate it has required fields
if (
typeof json.data === "object" &&
"id" in json.data &&
"userId" in json.data
) {
return json.data as BetterAuthSession;
}
}

return null;
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@astrojs/react": "^4.4.2",
"@repo/ui": "workspace:*",
"astro": "^5.16.4",
"hono": "^4.10.7",
"react": "^19.2.1",
"react-dom": "^19.2.1"
},
Expand Down
10 changes: 8 additions & 2 deletions apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@
"@repo/ui": ["../../packages/ui"],
"@repo/ui/*": ["../../packages/ui/*"]
},
"types": ["astro/client"]
"types": ["astro/client", "@cloudflare/workers-types"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.json", "**/*.astro"],
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.json",
"**/*.astro",
"types/**/*.d.ts"
],
"exclude": ["**/dist/**/*", "**/node_modules/**/*"],
"references": [{ "path": "../api" }, { "path": "../../packages/ui" }]
}
77 changes: 77 additions & 0 deletions apps/web/types/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Type definitions for Cloudflare Workers environment bindings
*/

/**
* Cloudflare Workers environment bindings
* These are injected at runtime by the Cloudflare Workers platform
*/
export interface Env {
/**
* Built-in static assets fetcher
* Serves files from the 'assets.directory' specified in wrangler.jsonc
* Automatically available when using Workers with assets
*/
ASSETS: Fetcher;

/**
* Service binding to the main application worker (apps/app)
* Used to proxy authenticated users to the dashboard
* Configured in wrangler.jsonc services section
*/
APP_SERVICE: Fetcher;

/**
* Service binding to the API worker (apps/api)
* Used to validate authentication sessions and proxy API requests
* Configured in wrangler.jsonc services section
*/
API_SERVICE: Fetcher;

/**
* Current deployment environment
* Values: "development" | "staging" | "preview" | "production"
*/
ENVIRONMENT: string;
}

/**
* Better Auth API response structure
* Response shape can vary based on plugins and client wrappers
* See: https://www.better-auth.com/docs/integrations
*/
export interface BetterAuthApiResponse {
/**
* Direct session field (most common format)
*/
session?: BetterAuthSession;

/**
* Nested data field (used by some client wrappers)
*/
data?:
| {
session?: BetterAuthSession;
}
| BetterAuthSession;

/**
* User information if session is valid
*/
user?: {
id: string;
email: string;
name?: string;
[key: string]: unknown;
};
}

/**
* Better Auth session structure
*/
export interface BetterAuthSession {
id: string;
userId: string;
expiresAt: Date | string;
[key: string]: unknown;
}
118 changes: 118 additions & 0 deletions apps/web/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @file Cloudflare Workers entrypoint for the web marketing site.
*
* Acts as an authentication-aware router for the home page:
* - Unauthenticated users see marketing content (static assets)
* - Authenticated users see the app dashboard (proxied to APP_SERVICE)
*
* Uses service bindings to communicate with app and API workers.
*/

import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import {
COOKIE_NAMES,
extractSession,
setCacheHeaders,
} from "./lib/auth-helpers";
import type { BetterAuthApiResponse } from "./types/env";

interface Env {
ASSETS: Fetcher; // Built-in for static assets
APP_SERVICE: Fetcher; // Service binding to apps/app
API_SERVICE: Fetcher; // Service binding to apps/api
ENVIRONMENT: string;
}

const app = new Hono<{ Bindings: Env }>();

/**
* API routes - proxy all /api/* requests to API service
* PRIORITY 1: Must come before other routes to ensure API requests are handled correctly
* Uses .all() to handle all HTTP methods (GET, POST, PUT, DELETE, etc.)
*/
app.all("/api/*", (c) => {
return c.env.API_SERVICE.fetch(c.req.raw);
});

/**
* App routes - proxy app-specific paths to APP service
* PRIORITY 2: These routes always go to the main application worker
* Uses .all() to handle all HTTP methods for SPA routing
*/
app.all("/_app/*", (c) => c.env.APP_SERVICE.fetch(c.req.raw));
app.all("/login*", (c) => c.env.APP_SERVICE.fetch(c.req.raw));
app.all("/signup*", (c) => c.env.APP_SERVICE.fetch(c.req.raw));
app.all("/settings*", (c) => c.env.APP_SERVICE.fetch(c.req.raw));
app.all("/analytics*", (c) => c.env.APP_SERVICE.fetch(c.req.raw));
app.all("/reports*", (c) => c.env.APP_SERVICE.fetch(c.req.raw));

/**
* Home page routing logic
* PRIORITY 3: Dynamic routing based on authentication status
* Routes authenticated users to app, unauthenticated users to marketing site
*
* Supports both GET and HEAD methods for proper HTTP compliance
* Applies cache control headers to prevent auth-state mixing
*/
app.on(["GET", "HEAD"], "/", async (c) => {
try {
// Support both regular and secure cookie names (HTTPS uses __Secure- prefix)
const sessionToken =
getCookie(c, COOKIE_NAMES.SESSION) ||
getCookie(c, COOKIE_NAMES.SESSION_SECURE);

if (!sessionToken) {
// No session cookie, serve marketing site with cache headers
return setCacheHeaders(await c.env.ASSETS.fetch(c.req.raw));
}

// Forward full cookie header (Better Auth may use multiple cookies)
const cookieHeader = c.req.header("cookie") ?? "";

// Verify session with API service
// Hostname can be arbitrary for internal service binding calls
const authCheckResponse = await c.env.API_SERVICE.fetch(
new Request("https://api.internal/api/auth/get-session", {
method: "GET",
headers: {
cookie: cookieHeader, // Forward all cookies, not just session token
accept: "application/json",
},
}),
);

if (!authCheckResponse.ok) {
// Invalid response, serve marketing site with cache headers
return setCacheHeaders(await c.env.ASSETS.fetch(c.req.raw));
}

const json = (await authCheckResponse
.json()
.catch(() => null)) as BetterAuthApiResponse | null;

// Handle varying Better Auth response shapes
const session = extractSession(json);

if (session) {
// Valid session, proxy to app service with cache headers
return setCacheHeaders(await c.env.APP_SERVICE.fetch(c.req.raw));
}

// No valid session, serve marketing site with cache headers
return setCacheHeaders(await c.env.ASSETS.fetch(c.req.raw));
} catch (error) {
// On any error, default to marketing site for best UX
console.error("Auth check failed for home page:", error);
return setCacheHeaders(await c.env.ASSETS.fetch(c.req.raw));
}
});

/**
* All other routes serve static assets
* PRIORITY 4: Wildcard must come last to catch marketing pages
* This ensures marketing pages (about, features, pricing) work normally
*/
app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw));

export default app;
Loading