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
40 changes: 40 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copy this file to .env.local and fill in the values:
#
# cp .env.example .env.local
#
# .env.local is gitignored — never commit real secrets.

# ── OpenAI ────────────────────────────────────────────────────────────────
# Required. Used for the "Generate Fun Versions" feature (GPT-5 Nano).
OPENAI_API_KEY=

# Optional. Override the model id. Defaults to gpt-5-nano.
# OPENAI_MODEL=gpt-5-nano

# ── Signing secret ────────────────────────────────────────────────────────
# Signs the suggestion tokens (HMAC-SHA256) so the browser can never submit an
# arbitrary rewrite prompt.
# - Development: optional. An insecure built-in fallback is used if unset.
# - Production: REQUIRED. Startup fails without it.
# Generate one with: openssl rand -hex 32
BIO_SIGNING_SECRET=

# ── Redis (rate limiting + response cache) ────────────────────────────────
# Local development: run `docker compose up -d` and use these two lines.
USE_LOCAL_REDIS=true
REDIS_URL=redis://localhost:6379

# Production (Vercel): leave USE_LOCAL_REDIS unset (or false). Add a Redis store
# in the Vercel dashboard and it injects these automatically — no extra config.
# KV_REST_API_URL=
# KV_REST_API_TOKEN=

# ── PostHog (analytics + LLM observability) ───────────────────────────────
# Optional. Without it, analytics are a complete no-op (great for local dev).
# Deliberately NOT prefixed with NEXT_PUBLIC_ so it stays server-controlled —
# set it only in your deploy environment and nothing fires locally.
# This is the PostHog *project* token (write-only, safe to expose in the browser).
# POSTHOG_TOKEN=

# Optional. PostHog API host for server-side events. Defaults to US cloud.
# POSTHOG_HOST=https://us.i.posthog.com
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Typecheck
run: npm run typecheck

- name: Test
run: npm test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
docs/superpowers
.swc

# Created by https://www.toptal.com/developers/gitignore/api/nextjs,macos
# Edit at https://www.toptal.com/developers/gitignore?templates=nextjs,macos
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20
90 changes: 90 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# AGENTS.md

Conventions for working in this repo — for humans and AI assistants alike. This
is the canonical guide; `CLAUDE.md` points here.

## What this is

A personal portfolio site (Next.js 16, App Router, TypeScript, Tailwind v4) with
one feature: an AI that rewrites the biography in funny voices. It is **not** a
SaaS product, chatbot, or AI playground. Keep it small, cheap, and tasteful.
When in doubt, do less.

## Commands

```bash
npm run dev # dev server
npm run build # production build
npm run lint # ESLint
npm run typecheck # tsc --noEmit
npm test # Jest
docker compose up -d # local Redis on :6379
```

Before opening a PR, make sure `lint`, `typecheck`, and `test` pass — CI runs all
three on `main` and on PRs.

## Hard rules

1. **`lib/bio.ts` is the single source of truth for content.** The biography,
profile, links, and analytics ID live there. Never hard-code that copy
anywhere else, and never duplicate the bio text — the homepage and the AI
feature both read from this module.

2. **Never trust client input for prompts.** The browser may display a rewrite
prompt but must never be able to submit an arbitrary one. Every prompt is
signed (HMAC-SHA256, `lib/signing.ts`) when generated and re-verified before
any rewrite runs. The signed payload — not the request body — is
authoritative. Don't add a code path that rewrites from an unsigned prompt.

3. **Structured Outputs only.** AI responses come back through OpenAI Structured
Outputs with Zod schemas (`zodTextFormat`, see `lib/schemas.ts` and
`lib/openai.ts`). Do not hand-parse model JSON.

4. **Don't scatter Redis code.** All cache access goes through `getCache()`
(`lib/cache/`) and all rate limiting through `enforceRateLimit()`
(`lib/rate-limit/`). No `ioredis` or `@upstash/*` imports outside those two
folders. The environment picks the implementation.

5. **No extra AI infrastructure.** Use the OpenAI SDK directly. Do **not** add
the Vercel AI SDK, LangChain, other orchestration frameworks, or additional
model providers. (This is also why LLM analytics use `posthog-node` directly
rather than `@posthog/ai`, which hard-depends on LangChain et al.)

6. **Env: lenient in dev, strict in prod.** `lib/env.ts` allows a fallback
signing secret and optional Redis in development; in production it throws on
missing required config. Add new required variables to both `lib/env.ts` and
`.env.example`.

7. **Analytics are optional and gated on `POSTHOG_TOKEN`.** Both web analytics
and the server-side `$ai_generation` capture must stay a complete no-op when
the token is unset (local dev). Browser events go through the first-party
`/ingest` proxy; server events flush via `next/server`'s `after()` so they
never add request latency. PostHog access lives in `lib/posthog.ts` and the
`components/posthog-*` files — keep it there.

## Failure behavior (don't regress this)

- **Caching fails open** — a Redis error is treated as a cache miss. Caching is
an optimization, never a guarantee.
- **Rate limiting fails closed in production** — if the limiter errors, deny
(`reason: "unavailable"`, surfaced as a friendly "overloaded" message). It
must never silently disable protection. In development it degrades to "allow
with a warning."
- **Server actions never leak internals** — they catch everything and return a
typed `{ ok: false, error }` with a friendly message. No stack traces to the
client.

## Style

- Match the existing visual tone; this is a faithful port of the original static
site, not a redesign. Don't add marketing copy.
- Keep modules small and single-purpose. Within `lib/`, prefer relative imports;
in `app/` and `components/`, use the `@/` alias.
- Family-friendly, non-offensive AI output is a product requirement — the
suggestion system prompt enforces it. Keep those guardrails intact.

## Local planning docs

`docs/superpowers/` holds local design/planning notes and is gitignored — it is
not part of the published project.
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# CLAUDE.md

This project's conventions live in **[AGENTS.md](AGENTS.md)** — read it first.
It's the single source of truth for commands, architecture, and the hard rules
(content lives in `lib/bio.ts`; never trust client prompts; Structured Outputs
only; all Redis access goes through `lib/cache/` and `lib/rate-limit/`; no extra
AI frameworks).

A few Claude-specific notes:

- `docs/superpowers/` contains local planning/spec docs and is gitignored. Don't
rely on it being present, and don't commit it.
- Before claiming work is done, actually run `npm run lint`, `npm run typecheck`,
and `npm test` and confirm they pass.
- This is a deliberately small personal site. Resist scope creep — a change that
adds a framework, a provider, or a new "platform" feature is almost certainly
wrong here.
162 changes: 147 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,161 @@
# nickcatalano.com

My personal site. A [Next.js](https://nextjs.org) app deployed on [Vercel](https://vercel.com).
**🔗 [www.nickcatalano.com](https://www.nickcatalano.com)**

> **Status:** scaffolding. This is currently a minimal Next.js shell so the Vercel
> deployment pipeline is wired up. The real site (and a fun little AI feature) is
> on the way — this README will grow with it.
My personal site. It's a quiet little page — a photo, a short bio, a few links —
with one playful trick up its sleeve.

## Develop
## The fun part

Below the biography there's a button: **Generate Fun Versions**. Click it and a
small AI (GPT-5 Nano) dreams up five tongue-in-cheek concepts for retelling the
bio — _Fantasy Wizard CTO_, _Suspicious Pigeon Expert_, _Medieval Town Crier_,
that kind of thing. Pick one and it rewrites the bio in that voice, right below
the original. The real bio never goes away; the rewrite is just an alternate
telling.

The concepts change every time, the facts stay the same (the model is told to
keep jobs, places, and people intact and only play with tone), and the whole
thing is built to stay cheap and safe to run.

## Stack

- **Next.js 16** (App Router) + **React 19**, mostly Server Components
- **TypeScript** + **Tailwind CSS v4**
- **Server Actions** for the two AI calls — no API routes, no client-side keys
- **OpenAI SDK** with **Structured Outputs** (Zod schemas, no hand-parsed JSON)
- **Redis** for rate limiting and a response cache, behind a small abstraction
so it's **local Redis** in dev and **Upstash** in production
- **PostHog** for web analytics + LLM observability (lightweight `posthog-node`
capture — no LangChain, no extra AI SDK)
- Deployed on **Vercel**

## Quickstart

```bash
npm install
docker compose up -d # local Redis on :6379
cp .env.example .env.local # then add your OPENAI_API_KEY
npm run dev
```

Then open [http://localhost:3000](http://localhost:3000).
Open <http://localhost:3000>.

## Build
You only strictly need an `OPENAI_API_KEY` to try the feature. Redis is optional
in development — if it's not running, rate limiting simply turns off with a
warning and everything else works.

```bash
npm run build
npm run start
```
### Environment variables

## Stack
| Variable | Required | What it's for |
| -------------------------- | ----------------- | -------------------------------------------------------------------- |
| `OPENAI_API_KEY` | yes (prod) | Calls GPT-5 Nano for suggestions and rewrites. |
| `OPENAI_MODEL` | no | Override the model id. Defaults to `gpt-5-nano`. |
| `BIO_SIGNING_SECRET` | yes (prod) | HMAC secret for signing rewrite prompts. Dev uses a fallback. |
| `USE_LOCAL_REDIS` | dev | Set `true` to use a local Redis via `REDIS_URL`. |
| `REDIS_URL` | dev | e.g. `redis://localhost:6379`. |
| `KV_REST_API_URL` | prod | Upstash REST URL (rate limiting + cache). Vercel's Redis integration injects it. |
| `KV_REST_API_TOKEN` | prod | Upstash REST token (injected alongside the URL). |
| `POSTHOG_TOKEN` | no | PostHog project token. Unset → analytics are a no-op (e.g. locally). |
| `POSTHOG_HOST` | no | Server-side PostHog host. Defaults to `https://us.i.posthog.com`. |

In **development** the app is lenient: a fallback signing secret is allowed and
Redis is optional. In **production** it fails fast — if a required variable is
missing, it throws rather than running without protection.

## Scripts

| Command | Does |
| ------------------- | ------------------------------------- |
| `npm run dev` | Start the dev server. |
| `npm run build` | Production build. |
| `npm run start` | Serve the production build. |
| `npm run lint` | ESLint (Next core-web-vitals). |
| `npm run typecheck` | `tsc --noEmit`. |
| `npm test` | Jest (signing/security tests). |

CI runs lint + typecheck + test on every push and PR to `main`
(`.github/workflows/ci.yml`). None of it needs secrets.

## How it works

1. **The page** is a Server Component. It renders the image, bio, and links from
[`lib/bio.ts`](lib/bio.ts) — the single source of truth for all content. No
AI calls happen on load.
2. **Generate Fun Versions** calls the `generateSuggestions` Server Action. It
rate-limits the caller, asks GPT-5 Nano (via Structured Outputs) for five
`{ title, prompt }` concepts, and **signs each prompt** with HMAC-SHA256
before sending it to the browser.
3. **Picking a concept** calls `rewriteBio` with the prompt and its signed
token. The server re-verifies the signature and expiry, confirms the prompt
matches the signed payload, rate-limits, checks the cache, and only then asks
the model to rewrite the bio.

### Security model

The browser may _show_ a prompt but is never _trusted_ to supply one. Every
prompt is signed when generated and re-verified before any rewrite runs — the
signed payload, not the request body, is authoritative. Tokens expire after 15
minutes. See [`lib/signing.ts`](lib/signing.ts) (and its tests).

### Cost control

- Suggestions are generated fresh each click (cheap — five short items from a
nano model) so the ideas always feel new.
- Rewrites are cached in Redis for 30 days, keyed by the biography + the exact
prompt, so a repeated concept is served from cache instead of the model.
- Daily per-IP limits: **5** suggestion generations and **25** rewrites.

### Swappable Redis

Nothing outside [`lib/cache/`](lib/cache/) and [`lib/rate-limit/`](lib/rate-limit/)
knows whether it's talking to a local Redis or Upstash. The factory picks the
implementation from the environment. Caching fails open (a backend hiccup is
just a cache miss); rate limiting fails **closed in production** (it never
silently disables protection) and degrades to "allow with a warning" in
development.

### Analytics

PostHog handles both web analytics and LLM observability, and it's entirely
gated on `POSTHOG_TOKEN` — unset (e.g. local dev), nothing fires.

- **Web**: `posthog-js` via a small provider ([components/posthog-provider.tsx](components/posthog-provider.tsx)),
with manual `$pageview` capture for App Router navigations. Browser events go
through a first-party `/ingest` reverse proxy (rewrites in
[next.config.ts](next.config.ts)) so ad-blockers don't eat them.
- **LLM**: each OpenAI call emits a `$ai_generation` event from the server
([lib/posthog.ts](lib/posthog.ts)) with model, token counts, and latency —
using `posthog-node` directly, so PostHog's LLM dashboards light up without
pulling in any AI framework. Events flush via `next/server`'s `after()`, so
analytics never add latency to a response.

(Google Analytics from the original site is preserved alongside PostHog.)

## Deploying to Vercel

1. Import the repo into Vercel. It auto-detects Next.js — no build config needed.
2. Add a Redis store from the project's **Storage** tab (Vercel's Upstash/KV
integration). It injects `KV_REST_API_URL` and `KV_REST_API_TOKEN`
automatically — the app reads those directly, so there's nothing to copy.
3. Set the remaining environment variables in **Project Settings → Environment
Variables**:
- `OPENAI_API_KEY`
- `BIO_SIGNING_SECRET` (`openssl rand -hex 32`)
- `POSTHOG_TOKEN` (optional — enables analytics + LLM observability)
- Redis: the `KV_REST_API_*` pair from step 2 is injected automatically.
Leave `USE_LOCAL_REDIS` unset.
4. Deploy. `main` is production; pull requests get preview deployments.

## Project layout

```
app/ layout (+ analytics), homepage, server actions
components/ BioRewriter + PostHog provider/pageview
lib/ bio (content), env, signing, schemas, openai, posthog
cache/ cache interface + local (ioredis) and Upstash impls
rate-limit/ limiter interface + local and Upstash impls
public/ ntr600.jpg
```

- Next.js 15 (App Router) + React 19
- TypeScript
- Tailwind CSS v4
See [AGENTS.md](AGENTS.md) for conventions if you're contributing (human or AI).
Loading
Loading