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
40 changes: 30 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@ The application uses Prisma ORM with PostgreSQL. The schema defines two main mod
- Date picker for filtering books by completion date
- Default date range is current year (Jan 1 - Dec 31)

- **pages/admin.tsx**: Admin page for adding books
- Contains `upsertBooksAndAuthors` function for inserting/updating books
- Handles many-to-many author relationships
- **Note**: This page runs on client-side and directly calls Prisma, which will fail in production since Prisma is backend-only
- **pages/p/[id]/index.tsx**: Admin dashboard (protected)
- Full CRUD operations for books
- Uses dynamic route with server-side validation
- Only accessible at `/p/{ADMIN_PATH}` where path matches env var

- **pages/p/[id]/login.tsx**: Admin login page
- Password-based authentication
- Sets HTTP-only cookie on successful login

### Components

Expand Down Expand Up @@ -83,6 +87,8 @@ Add new domains to `next.config.mjs` remotePatterns if needed.

Required in `.env`:
- `DATABASE_URL`: PostgreSQL connection string for Prisma
- `ADMIN_PATH`: Secret URL path segment for admin access (required, no default)
- `ADMIN_PASSWORD`: Password for admin authentication

Comment on lines +90 to 92
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing required environment variable AUTH_SECRET.

The PR objectives explicitly state that AUTH_SECRET is a required environment variable for HMAC-SHA256 signed tokens. This critical configuration is not documented.

📝 Add AUTH_SECRET to documentation
 Required in `.env`:
 - `DATABASE_URL`: PostgreSQL connection string for Prisma
 - `ADMIN_PATH`: Secret URL path segment for admin access (required, no default)
 - `ADMIN_PASSWORD`: Password for admin authentication
+- `AUTH_SECRET`: Secret key for HMAC-SHA256 token signing (required for authentication)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `ADMIN_PATH`: Secret URL path segment for admin access (required, no default)
- `ADMIN_PASSWORD`: Password for admin authentication
- `ADMIN_PATH`: Secret URL path segment for admin access (required, no default)
- `ADMIN_PASSWORD`: Password for admin authentication
- `AUTH_SECRET`: Secret key for HMAC-SHA256 token signing (required for authentication)
🤖 Prompt for AI Agents
In `@AGENTS.md` around lines 90 - 92, Add documentation for the required
AUTH_SECRET environment variable alongside the existing ADMIN_PATH and
ADMIN_PASSWORD entries: state that AUTH_SECRET is required (no default) and used
as the HMAC-SHA256 signing secret for tokens, include a short usage note (e.g.,
format/length guidance or how to generate/store securely), and mirror the style
of the existing lines (bullet entry like `- AUTH_SECRET: ...`) so it appears
with ADMIN_PATH and ADMIN_PASSWORD in AGENTS.md.

## Key Implementation Patterns

Expand All @@ -103,10 +109,24 @@ The date picker implementation:
- Closes after both dates selected or picker cleared
- Shows all books when date range is not fully set

### Book/Author Upsert Logic
### Admin Security (Path Obfuscation)

The admin interface uses a multi-layered security approach:

1. **Secret URL Path**: The admin is only accessible at `/p/{ADMIN_PATH}` where `ADMIN_PATH` is an environment variable. There are no hardcoded paths like `/admin` in the codebase.

2. **Dynamic Route Validation**: Pages at `pages/p/[id]/` use `getServerSideProps` to validate the `id` parameter matches `ADMIN_PATH`. Non-matching paths return 404.

3. **API Route Validation**: API endpoints at `pages/api/p/[id]/` validate the path segment before processing requests. Invalid paths return 404.

4. **Password Authentication**: Even with the correct path, users must authenticate with `ADMIN_PASSWORD` via a login form.

5. **HTTP-only Cookies**: Auth state is stored in HTTP-only cookies to prevent XSS attacks.

**Why this approach**: The source code is public, so we can't hardcode secret paths. By using environment variables and dynamic routes with server-side validation:
- Scanning the codebase reveals nothing about the admin URL
- Brute-forcing `/p/{random}` returns 404 for incorrect guesses
- API enumeration doesn't reveal admin-related endpoints
- Even finding the path requires knowing the password

Comment on lines +112 to 131
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Security documentation is incomplete.

The security section provides good coverage of path obfuscation but omits several critical security features mentioned in the PR objectives:

  1. Token-based authentication: HMAC-SHA256 signed tokens with 24-hour expiry
  2. Middleware protection: middleware.ts enforces authentication on protected routes
  3. Timing-safe comparisons: crypto.timingSafeEqual prevents timing attacks on password/token validation
  4. Defense-in-depth: Multiple validation layers (middleware + getServerSideProps + API)

These implementation details are essential for understanding the full security model.

📝 Suggested additions to security documentation
 5. **HTTP-only Cookies**: Auth state is stored in HTTP-only cookies to prevent XSS attacks.
 
+6. **Token-based Authentication**: Authentication uses HMAC-SHA256 signed tokens with 24-hour expiry. Tokens are validated using `crypto.timingSafeEqual` to prevent timing attacks.
+
+7. **Middleware Protection**: `middleware.ts` provides the first layer of defense, enforcing authentication on all `/p/:id*` routes using the Web Crypto API (Edge-compatible).
+
+8. **Defense-in-Depth**: Authentication is validated at multiple layers:
+   - Middleware (route-level protection)
+   - `getServerSideProps` (server-side page rendering)
+   - API route handlers (endpoint-level validation)
+
 **Why this approach**: The source code is public, so we can't hardcode secret paths. By using environment variables and dynamic routes with server-side validation:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Admin Security (Path Obfuscation)
The admin interface uses a multi-layered security approach:
1. **Secret URL Path**: The admin is only accessible at `/p/{ADMIN_PATH}` where `ADMIN_PATH` is an environment variable. There are no hardcoded paths like `/admin` in the codebase.
2. **Dynamic Route Validation**: Pages at `pages/p/[id]/` use `getServerSideProps` to validate the `id` parameter matches `ADMIN_PATH`. Non-matching paths return 404.
3. **API Route Validation**: API endpoints at `pages/api/p/[id]/` validate the path segment before processing requests. Invalid paths return 404.
4. **Password Authentication**: Even with the correct path, users must authenticate with `ADMIN_PASSWORD` via a login form.
5. **HTTP-only Cookies**: Auth state is stored in HTTP-only cookies to prevent XSS attacks.
**Why this approach**: The source code is public, so we can't hardcode secret paths. By using environment variables and dynamic routes with server-side validation:
- Scanning the codebase reveals nothing about the admin URL
- Brute-forcing `/p/{random}` returns 404 for incorrect guesses
- API enumeration doesn't reveal admin-related endpoints
- Even finding the path requires knowing the password
### Admin Security (Path Obfuscation)
The admin interface uses a multi-layered security approach:
1. **Secret URL Path**: The admin is only accessible at `/p/{ADMIN_PATH}` where `ADMIN_PATH` is an environment variable. There are no hardcoded paths like `/admin` in the codebase.
2. **Dynamic Route Validation**: Pages at `pages/p/[id]/` use `getServerSideProps` to validate the `id` parameter matches `ADMIN_PATH`. Non-matching paths return 404.
3. **API Route Validation**: API endpoints at `pages/api/p/[id]/` validate the path segment before processing requests. Invalid paths return 404.
4. **Password Authentication**: Even with the correct path, users must authenticate with `ADMIN_PASSWORD` via a login form.
5. **HTTP-only Cookies**: Auth state is stored in HTTP-only cookies to prevent XSS attacks.
6. **Token-based Authentication**: Authentication uses HMAC-SHA256 signed tokens with 24-hour expiry. Tokens are validated using `crypto.timingSafeEqual` to prevent timing attacks.
7. **Middleware Protection**: `middleware.ts` provides the first layer of defense, enforcing authentication on all `/p/:id*` routes using the Web Crypto API (Edge-compatible).
8. **Defense-in-Depth**: Authentication is validated at multiple layers:
- Middleware (route-level protection)
- `getServerSideProps` (server-side page rendering)
- API route handlers (endpoint-level validation)
**Why this approach**: The source code is public, so we can't hardcode secret paths. By using environment variables and dynamic routes with server-side validation:
- Scanning the codebase reveals nothing about the admin URL
- Brute-forcing `/p/{random}` returns 404 for incorrect guesses
- API enumeration doesn't reveal admin-related endpoints
- Even finding the path requires knowing the password
🤖 Prompt for AI Agents
In `@AGENTS.md` around lines 112 - 131, Update the Admin Security section to
include the missing implementation details: document the token-based
authentication using HMAC-SHA256 signed tokens with 24-hour expiry, mention the
middleware enforcement via middleware.ts that protects /p/* and API routes,
describe use of crypto.timingSafeEqual for timing-safe comparisons during
password/token validation, and state the defense-in-depth approach (middleware +
getServerSideProps validation + API route checks) so readers can understand the
full security model and where to find the relevant implementations
(middleware.ts, getServerSideProps in pages/p/[id]/, and API handlers in
pages/api/p/[id]/).

The `upsertBooksAndAuthors` function in `pages/admin.tsx`:
- Upserts books by title
- Upserts authors by name
- Creates many-to-many relationships using Prisma's `connect`
- Runs sequentially (not transactional)
**Important**: `ADMIN_PATH` has no fallback value. The app will throw an error if it's not set, ensuring the admin cannot accidentally be exposed at a default path.
101 changes: 100 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,100 @@
This is a starter template for [Learn Next.js](https://nextjs.org/learn).
# Half-Baked

A personal book collection app that displays books on a visual 3D bookshelf. Built with Next.js, Prisma, and PostgreSQL.

## Features

- Visual bookshelf display with 3D shelf effects
- Filter books by date range
- Responsive layout that adapts to screen width
- Admin dashboard for managing books (CRUD operations)
- Protected admin access with password authentication

## Tech Stack

- **Framework**: Next.js (Pages Router)
- **Database**: PostgreSQL with Prisma ORM
- **Styling**: Tailwind CSS + SCSS modules
- **Hosting**: Vercel + Supabase

## Getting Started

### Prerequisites

- Node.js 20.x or later
- PostgreSQL database (or Supabase account)

### Environment Variables

Create a `.env` file in the root directory:

```env
DATABASE_URL="postgresql://user:password@host:port/database"
ADMIN_PATH="your-secret-path"
ADMIN_PASSWORD="your-secure-password"
```

- `DATABASE_URL`: PostgreSQL connection string
- `ADMIN_PATH`: Secret URL path segment for admin access (e.g., `x7k9m2p4`)
- `ADMIN_PASSWORD`: Password for admin authentication

### Installation

```bash
# Install dependencies
npm install

# Generate Prisma client
npx prisma generate

# Run database migrations (if using migrations)
npx prisma migrate dev

# Or sync schema directly (for existing databases)
npx prisma db push
```

### Development

```bash
npm run dev
```

Open [http://localhost:3000](http://localhost:3000) to view the bookshelf.

### Admin Access

The admin dashboard is accessible at `/p/{ADMIN_PATH}` where `{ADMIN_PATH}` is the value of your `ADMIN_PATH` environment variable.

Example: If `ADMIN_PATH=x7k9m2p4`, access admin at `http://localhost:3000/p/x7k9m2p4`

### Production Build

```bash
npm run build
npm run start
```

## Project Structure

```
pages/
├── index.tsx # Main bookshelf page
├── p/[id]/
│ ├── index.tsx # Admin dashboard
│ └── login.tsx # Admin login
├── api/
│ ├── books/ # Book CRUD endpoints
│ └── p/[id]/ # Auth endpoints
components/
├── Book/ # Book display component
└── Shelf/ # 3D shelf component
lib/
└── prisma.ts # Prisma client
prisma/
└── schema.prisma # Database schema
```

## License

Private project.
114 changes: 114 additions & 0 deletions lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import crypto from 'crypto';

export const AUTH_COOKIE_NAME = 'admin_auth';
export const COOKIE_MAX_AGE = 60 * 60 * 24; // 24 hours in seconds

/**
* Generates a signed authentication token using HMAC-SHA256.
* The token is based on a timestamp, making it unique per session.
*/
export function generateAuthToken(): string {
const secret = process.env.ADMIN_PASSWORD;
if (!secret) {
throw new Error('ADMIN_PASSWORD environment variable is not set');
}

const timestamp = Date.now().toString();
const hmac = crypto.createHmac('sha256', secret);
hmac.update(timestamp);
const signature = hmac.digest('hex');

// Format: timestamp.signature
return `${timestamp}.${signature}`;
}

/**
* Validates an authentication token using timing-safe comparison.
* Returns true if the token is valid and not expired.
*/
export function validateAuthToken(token: string | undefined): boolean {
if (!token) {
return false;
}

const secret = process.env.ADMIN_PASSWORD;
if (!secret) {
return false;
}

const parts = token.split('.');
if (parts.length !== 2) {
return false;
}

const [timestamp, providedSignature] = parts;

// Check if token is expired (older than COOKIE_MAX_AGE)
const tokenAge = Date.now() - parseInt(timestamp, 10);
if (isNaN(tokenAge) || tokenAge > COOKIE_MAX_AGE * 1000) {
return false;
}
Comment on lines +46 to +50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reject tokens with future timestamps.

The token age validation only checks if the token is too old, but doesn't reject tokens with future timestamps. A token with a timestamp in the future would have a negative tokenAge and pass the expiry check.

🛠️ Proposed fix
   // Check if token is expired (older than COOKIE_MAX_AGE)
   const tokenAge = Date.now() - parseInt(timestamp, 10);
-  if (isNaN(tokenAge) || tokenAge > COOKIE_MAX_AGE * 1000) {
+  if (isNaN(tokenAge) || tokenAge < 0 || tokenAge > COOKIE_MAX_AGE * 1000) {
     return false;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check if token is expired (older than COOKIE_MAX_AGE)
const tokenAge = Date.now() - parseInt(timestamp, 10);
if (isNaN(tokenAge) || tokenAge > COOKIE_MAX_AGE * 1000) {
return false;
}
// Check if token is expired (older than COOKIE_MAX_AGE)
const tokenAge = Date.now() - parseInt(timestamp, 10);
if (isNaN(tokenAge) || tokenAge < 0 || tokenAge > COOKIE_MAX_AGE * 1000) {
return false;
}
🤖 Prompt for AI Agents
In `@lib/auth.ts` around lines 46 - 50, The token age check in lib/auth.ts only
rejects overly old tokens but allows future timestamps (negative tokenAge);
update the validation around tokenAge (computed from timestamp via parseInt) to
also reject tokenAge < 0 (return false) and ensure isNaN(tokenAge) is still
handled—i.e., after computing tokenAge, add a check like if (isNaN(tokenAge) ||
tokenAge < 0 || tokenAge > COOKIE_MAX_AGE * 1000) return false so tokens with
future timestamps are rejected.


// Regenerate the expected signature
const hmac = crypto.createHmac('sha256', secret);
hmac.update(timestamp);
const expectedSignature = hmac.digest('hex');

// Use timing-safe comparison to prevent timing attacks
return timingSafeEqual(providedSignature, expectedSignature);
}

/**
* Timing-safe string comparison to prevent timing attacks.
*/
export function timingSafeEqual(a: string, b: string): boolean {
const encoder = new TextEncoder();
const bufA = encoder.encode(a);
const bufB = encoder.encode(b);

// If lengths differ, compare with itself to maintain constant time
// but return false
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}

return crypto.timingSafeEqual(bufA, bufB);
}

/**
* Validates password using timing-safe comparison.
*/
export function validatePassword(password: string): boolean {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || !password) {
return false;
}

return timingSafeEqual(password, adminPassword);
}

/**
* Generates the Set-Cookie header value for authentication.
*/
export function generateAuthCookie(token: string): string {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `${AUTH_COOKIE_NAME}=${token}; HttpOnly; Path=/; Max-Age=${COOKIE_MAX_AGE}; SameSite=Strict${secure}`;
}

/**
* Generates the Set-Cookie header value to clear authentication.
*/
export function generateClearAuthCookie(): string {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `${AUTH_COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict${secure}`;
}

/**
* Validates authentication from API request cookies.
* Returns true if the request has a valid auth token.
*/
export function isAuthenticated(cookies: Partial<{ [key: string]: string }>): boolean {
const token = cookies[AUTH_COOKIE_NAME];
return validateAuthToken(token);
}
96 changes: 96 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const AUTH_COOKIE_NAME = 'admin_auth';
const COOKIE_MAX_AGE = 60 * 60 * 24; // 24 hours in seconds

/**
* Validates an authentication token in Edge runtime.
* Uses Web Crypto API which is available in Edge.
*/
async function validateAuthToken(token: string | undefined, secret: string): Promise<boolean> {
if (!token || !secret) {
return false;
}

const parts = token.split('.');
if (parts.length !== 2) {
return false;
}

const [timestamp, providedSignature] = parts;

// Check if token is expired (older than COOKIE_MAX_AGE)
const tokenAge = Date.now() - parseInt(timestamp, 10);
if (isNaN(tokenAge) || tokenAge > COOKIE_MAX_AGE * 1000) {
return false;
}

// Regenerate the expected signature using Web Crypto API
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);

const signatureBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(timestamp));
const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');

// Timing-safe comparison
if (providedSignature.length !== expectedSignature.length) {
return false;
}

let result = 0;
for (let i = 0; i < providedSignature.length; i++) {
result |= providedSignature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
}

return result === 0;
}

export async function middleware(request: NextRequest) {
const adminPath = process.env.ADMIN_PATH;
const adminPassword = process.env.ADMIN_PASSWORD;

// If env vars not set, let the request through - pages will handle the error
if (!adminPath || !adminPassword) {
console.error('ADMIN_PATH or ADMIN_PASSWORD environment variable is not set');
return NextResponse.next();
}
Comment on lines +61 to +65
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Security concern: Requests proceed when environment variables are missing.

When ADMIN_PATH or ADMIN_PASSWORD are not set, the middleware logs an error but allows the request through. This could expose admin routes if environment variables are accidentally unset in production.

Consider returning a 500 error or redirecting to a safe page instead of allowing the request to proceed.

🔒 Proposed fix
   // If env vars not set, let the request through - pages will handle the error
   if (!adminPath || !adminPassword) {
     console.error('ADMIN_PATH or ADMIN_PASSWORD environment variable is not set');
-    return NextResponse.next();
+    // Return 500 to prevent exposing potentially misconfigured admin routes
+    return new NextResponse('Server configuration error', { status: 500 });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If env vars not set, let the request through - pages will handle the error
if (!adminPath || !adminPassword) {
console.error('ADMIN_PATH or ADMIN_PASSWORD environment variable is not set');
return NextResponse.next();
}
// If env vars not set, let the request through - pages will handle the error
if (!adminPath || !adminPassword) {
console.error('ADMIN_PATH or ADMIN_PASSWORD environment variable is not set');
// Return 500 to prevent exposing potentially misconfigured admin routes
return new NextResponse('Server configuration error', { status: 500 });
}
🤖 Prompt for AI Agents
In `@middleware.ts` around lines 61 - 65, The middleware currently logs and calls
NextResponse.next() when adminPath or adminPassword are missing, which allows
requests to proceed; change this to fail closed by returning an explicit 500
response (or redirect to a safe page) instead of NextResponse.next(). Locate the
check using adminPath and adminPassword and replace the NextResponse.next()
branch with a NextResponse.error() or a NextResponse.redirect(...) that provides
a clear error response so admin routes cannot be accessed when those env vars
are unset.


const { pathname } = request.nextUrl;
const authCookie = request.cookies.get(AUTH_COOKIE_NAME);

// Protected dashboard route: /p/${ADMIN_PATH}
if (pathname === `/p/${adminPath}`) {
const isValid = await validateAuthToken(authCookie?.value, adminPassword);

if (!isValid) {
// Redirect to login page
const loginUrl = new URL(`/p/${adminPath}/login`, request.url);
return NextResponse.redirect(loginUrl);
}
}

// If already authenticated and trying to access login page, redirect to dashboard
if (pathname === `/p/${adminPath}/login`) {
const isValid = await validateAuthToken(authCookie?.value, adminPassword);

if (isValid) {
const dashboardUrl = new URL(`/p/${adminPath}`, request.url);
return NextResponse.redirect(dashboardUrl);
}
}

return NextResponse.next();
}

export const config = {
matcher: ['/p/:id*'],
};
Loading