| name | kevinc-project |
| description | Architecture and infrastructure context for the KevinC.dev portfolio site. Use when modifying auth, API routes, middleware, environment config, protected pages, or deployment. Also use when adding new /projects/* pages, new API proxy routes, or modifying the authentication flow. Complements kevinc-design (which covers visual patterns). |
KevinC.dev Project Architecture
Personal portfolio at kevinc.dev (+ kevin-chen.dev, k3vnc.dev). Next.js 16 App Router on Vercel, dia-design branch.
Repository Layout
homepage/ # Next.js app root (run all commands here)
├── src/
│ ├── auth.ts # Auth.js v5 config (Google OAuth, JWT, email whitelist)
│ ├── proxy.ts # Next.js 16 route protection for /projects/* and /tools/*
│ ├── lib/secrets.ts # Runtime secret overrides (Turso-backed, encrypted)
│ └── app/
│ ├── page.tsx # Public homepage (resume/portfolio)
│ ├── globals.css # Aurora, blur animations, dark mode vars, status overrides
│ ├── components/ # Shared: AuroraBackground, BackButton, ProfileMenu, ThemeProvider, ThemeToggle, etc.
│ ├── auth/signin/ # Custom Google sign-in page
│ ├── projects/ # Protected project hub
│ │ ├── page.tsx # Hub with card navigation
│ │ ├── layout.tsx # Header with user info, sign-out, back nav (BackButton)
│ │ ├── usage/ # API usage monitoring dashboard
│ │ └── tools/ # Tools & utilities sub-hub
│ │ ├── page.tsx # Tools hub with card navigation
│ │ ├── coverletter/ # Cover letter workbench with reusable DB-backed blocks/tags
│ │ └── speech/ # Speech Lab (TTS, STT, Pronunciation)
│ └── api/
│ ├── auth/[...nextauth]/ # Auth.js handler (2 lines)
│ ├── coverletter/ # Library CRUD + Gemini block matching
│ ├── secrets/ # Runtime secret override API for /tools/secrets
│ ├── usage/ # Server-side API proxies (tavily, vercel, render, etc.)
│ │ ├── history/ # Snapshot-backed daily usage history for burn-rate projections
│ └── speech/ # Speech tool API proxies
│ ├── tts/ # Gemini 2.5 Flash TTS (POST, GEMINI_API_KEY)
│ ├── stt/ # Voxtral Transcribe/Realtime 2 (POST, MISTRAL_API_KEY)
│ └── pronunciation/ # Azure Speech pronunciation (POST, AZURE_SPEECH_KEY)
├── .env.local # Local env vars (gitignored)
└── next.config.ts # Image remote patterns, etc.
docs/ # Local retrospectives (gitignored)
CLAUDE.md # Flat project context file
Authentication
Auth.js v5 (next-auth@beta) with Google OAuth, JWT sessions (no database).
Key patterns:
src/auth.ts exports { handlers, auth, signIn, signOut }
- Email whitelist in
signIn callback reads ALLOWED_EMAILS env var
authorized callback runs via src/proxy.ts on /projects/*, /tools/*, and selected protected API families
- Invited users can be restricted to selected protected pages and APIs via
src/lib/accessGrants.ts + Turso login_access_grants
ALLOWED_EMAILS owner accounts bypass the page-level grant system and keep full access
- Unauthenticated users redirect to
/auth/signin
AUTH_TRUST_HOST=true required for multi-domain Vercel deployment
Adding a new protected route: Put it under src/app/projects/ or src/app/tools/ and ensure src/proxy.ts plus auth.ts's authorized callback both match the new path family.
If the route should be grantable to invited users, also add it to src/lib/accessGrants.ts and include any related protected API prefixes there.
Dev-only auth bypass: Set DEV_BYPASS_AUTH=true in homepage/.env.local to skip Google SSO when testing protected routes locally. Two safety conditions are checked — NODE_ENV === 'development' AND the env var — so it is impossible to activate in a Vercel deployment (which always sets NODE_ENV=production). Note: API routes that read auth?.user?.email will receive null in bypass mode, so per-user data flows (e.g. cover-letter workbench) may behave differently.
Runtime Secrets
API keys can be overridden at runtime via /tools/secrets without a redeploy.
Key patterns:
src/lib/secrets.ts exposes getSecret() which checks Turso overrides first, then process.env
src/lib/managedSecrets.ts is the single source of truth for the keys and related config shown in /tools/secrets
- The registry now covers the full
homepage/.env.example inventory; each entry is either runtime-override or env-sync-only
- The
/tools/secrets UI surfaces an env set badge when the key is already present in the Vercel project envs, falling back to the current deployment env when that lookup is unavailable
- Overrides are encrypted at rest using a key derived from
AUTH_SECRET
/api/secrets manages the overrides for authenticated users
- After a successful save,
/api/secrets can also upsert the same key into Vercel project envs when VERCEL_API_TOKEN and VERCEL_PROJECT_ID (or VERCEL_PROJECT_NAME) are configured
JOBS_TURSO_DATABASE_URL and JOBS_TURSO_AUTH_TOKEN are fetched through getSecret() in src/lib/jobsDb.ts, so they can now be rotated through the secrets UI without a redeploy
CRON_SECRET is an env-sync-only secret used to secure Vercel cron routes such as /api/cron/usage-snapshots
- Use
getSecret("KEY_NAME") in API routes instead of reading process.env.KEY_NAME directly when the value may be rotated via the UI
API Routes
Cover Letter Workbench (/api/coverletter/*)
Protected CRUD and matching routes for the reusable cover-letter library.
src/lib/coverLetterDb.ts owns the Jobs Turso schema and CRUD helpers
src/lib/coverLetterRubric.ts is the source of truth for the verbatim cover-letter grading rubric used by Gemini review
src/lib/coverLetterStorage.ts owns the S3-backed reference resume and saved-draft helpers
- Tables:
cover_letter_blocks, cover_letter_tags, cover_letter_block_tags
- The original 12 client-side seed blocks were moved server-side and are inserted with stable
legacy-* keys during schema initialization
src/app/api/coverletter/library/route.ts returns { blocks, tags } for the UI
src/app/api/coverletter/blocks and src/app/api/coverletter/tags provide create/list, while [id] routes provide update/delete
src/app/api/coverletter/grade/route.ts evaluates the current letter against the rubric with Gemini, attaches up to the three most recent uploaded reference resume PDFs as multimodal context, and returns structured scores, the highest-impact change, and a weakest-paragraph rewrite
src/app/api/coverletter/match/route.ts sends the current block library to Gemini and filters returned ids against known block ids before responding
src/app/api/coverletter/reference-resumes plus [id] provide upload/list/open/delete for S3-backed reference resume PDFs stored under coverletter/reference-resumes/
src/app/api/coverletter/letters plus [id] provide create/list/load/update/delete for S3-backed cover letter draft JSON stored under coverletter/letters/
- The workbench currently reuses
RESUME_S3_BUCKET and the existing AWS credentials, separating public resume files from private cover-letter assets by S3 prefix instead of adding a second bucket config
scripts/seed-coverletter-library.mjs is the durable bootstrap/verification script for seeding the live Jobs Turso DB, smoke-testing CRUD, and checking Gemini matching
Usage Proxies (/api/usage/*)
All follow the same GET pattern with auth check:
import { auth } from "@/auth"
import { NextResponse } from "next/server"
import { getSecret } from "@/lib/secrets"
export async function GET() {
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const apiKey = await getSecret("SERVICE_API_KEY")
}
Services with on-track/burn-rate logic on the dashboard:
- Tavily — monthly credits with plan limit
- GitHub — Codespaces usage + Copilot premium requests via personal billing endpoints
- Usage snapshots — daily cumulative totals persisted to Turso with cycle metadata (
cycle_key, cycle_start, cycle_end, window_source)
- Usage collectors — shared fetch/normalize modules in
src/lib/usageCollectors/* that feed both the authenticated /api/usage/* routes and the daily cron route
- Turso — rows read/written against Starter plan limits
- Odds API — request count against monthly limit
- Venice AI — DIEM epoch allocation vs remaining balance
- Azure — student credit balance with cost projection
- OpenRouter — prepaid credits usage
- Render — service inventory plus month-to-date bandwidth via
/v1/metrics/bandwidth
The daily cron capture lives at /api/cron/usage-snapshots and should be protected with CRON_SECRET. Live usage routes still upsert snapshots during interactive refreshes, but the cron route is what makes the snapshot cadence reliable even when the dashboard is not opened.
/api/usage/history no longer just returns the current month bucket. It now returns a recent snapshot window plus an activeCycles map so src/app/projects/usage/page.tsx can filter each metric to its active cycle and render Last 7d vs prev 7d comparisons without assuming month boundaries.
The current cycle resolver lives in src/lib/usageCycles.ts. Today the registry defaults tracked metrics to calendar-month fallback, but this is also where provider-reported or configured anchor-day cycle rules should be added as billing APIs improve.
Add next: { revalidate: 60 } to fetch options for caching.
Speech Tool Proxies (/api/speech/*)
All follow POST pattern with auth check + request body/formData:
-
/api/speech/tts — Gemini 2.5 Flash TTS (gemini-2.5-flash-preview-tts)
- Body:
{ text, voice?, instructions? }
- Voice default:
Gacrux (30 available voices)
- Returns:
{ audio: base64, mimeType }
- Env:
GEMINI_API_KEY
-
/api/speech/stt — Mistral Voxtral transcription
- FormData:
audio (file), model?
- Models:
voxtral-mini-transcribe-2602 (batch, default), voxtral-mini-transcribe-realtime-2602 (streaming)
- Returns:
{ text, segments? }
- Env:
MISTRAL_API_KEY
-
/api/speech/pronunciation — Azure Speech pronunciation assessment
- FormData:
audio (file), referenceText, language?
- Returns: Azure NBest assessment (AccuracyScore, FluencyScore, etc.)
- Env:
AZURE_SPEECH_KEY, AZURE_SPEECH_REGION
Azure OpenAI STT / Diarization
The Azure OpenAI deployment has three STT models:
gpt-4o-transcribe — standard transcription
gpt-4o-mini-transcribe — cheaper/faster transcription
gpt-4o-transcribe-diarize — speaker-labeled transcription (configured in AZURE_OPENAI_STT_DEPLOYMENT_GPT4O_TRANSCRIBE_DIARIZE)
Critical Azure gotchas (hard-won):
-
Must use raw requests/fetch, NOT the OpenAI SDK — The SDK's extra_body is silently dropped for multipart/form-data requests. Audio goes as multipart; JSON-only fields like chunking_strategy don't reach the server via SDK.
-
Azure requires {"type":"server_vad"}, NOT "auto" — OpenAI's documented "auto" value is rejected by Azure with a 400. Serialized as a JSON string in form-data:
data={"chunking_strategy": json.dumps({"type": "server_vad"})}
-
Duration limit on diarize model — Even files under 25MB fail if the audio is too long. Chunk to 10-minute segments for the full-file diarize workflow.
-
Speaker labels are local per chunk — "Speaker A" in chunk 0 and "Speaker A" in chunk 7 are independent labels; cross-chunk consistency requires known_speaker_references or custom post-processing.
-
API version: 2025-03-01-preview — required for response_format="diarized_json".
Minimum working pattern:
import json, requests
resp = requests.post(
f"{endpoint}/openai/deployments/{deployment}/audio/transcriptions?api-version=2025-03-01-preview",
headers={"api-key": api_key},
files={"file": (filename, open_file, "audio/mpeg")},
data={
"response_format": "diarized_json",
"chunking_strategy": json.dumps({"type": "server_vad"}),
},
timeout=300,
)
Parallel chunking pattern:
- Split at 10-min intervals via ffmpeg, extract directly from source (avoid re-encoding)
- Use
ThreadPoolExecutor(max_workers=4) with 2s stagger between starts
- Shift
seg["start"] += offset_sec for each chunk's absolute timestamps
- Sort merged segments by
start after all chunks complete
- Cache chunk files to avoid re-encoding on retry
Env vars: AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_STT_DEPLOYMENT_GPT4O_TRANSCRIBE_DIARIZE
Environment Variables
Use homepage/.env.example as the canonical current template.
Required: AUTH_SECRET, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET, AUTH_TRUST_HOST, ALLOWED_EMAILS
Optional (usage dashboard):
TAVILY_API_KEY, VERCEL_API_TOKEN, RENDER_API_KEY, REPLICATE_API_TOKEN
VERCEL_PROJECT_ID, VERCEL_PROJECT_NAME, VERCEL_TEAM_ID, VERCEL_TEAM_SLUG, CRON_SECRET
GITHUB_PAT, GITHUB_USERNAME
GCP_BILLING_EXPORT_PROJECT_ID, GCP_BILLING_EXPORT_DATASET
OPENROUTER_API_KEY, ODDS_API_KEY, VENICE_API_KEY
TURSO_API_TOKEN, TURSO_ORG_SLUG
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID
AZURE_BILLING_ACCOUNT_ID, AZURE_BILLING_PROFILE_ID
RESEND_API_KEY, AUTH_EMAIL_FROM
SHEETS_WEBHOOK_URL
GCP_SERVICE_ACCOUNT_KEY
Optional (speech tools):
GEMINI_API_KEY — Google AI API key for TTS
MISTRAL_API_KEY — Mistral API key for Voxtral STT
AZURE_SPEECH_KEY, AZURE_SPEECH_REGION — Azure Speech Service for pronunciation
AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY — Azure OpenAI STT
Optional (storage / databases):
TURSO_DATABASE_URL, TURSO_AUTH_TOKEN
JOBS_TURSO_DATABASE_URL, JOBS_TURSO_AUTH_TOKEN
AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
RESUME_S3_BUCKET, RESUME_S3_KEY, RESUME_S3_PUBLIC_URL, SPEECH_S3_BUCKET
Render Services
Two Render-hosted Docker services run alongside the Vercel deployment:
Polymarket EV Bot (polymarket-ev-bot-docker.onrender.com)
- Proxied at
/polymarket and /polymarket/:path* via next.config.ts rewrites
- Has its own Google OAuth client (separate callback URIs registered in Google Cloud Console)
speech-tools (speech-tools.onrender.com, ID: srv-d7c3nhh9rddc739ese9g)
- Purpose: Audio processing too long-running for Vercel (transcription + diarization take 5-10+ min)
- Source:
speech-tools/ at repo root — TypeScript/Express, Dockerfile, deployed from dia-design branch
- Routes:
GET /health, POST /diarize (SSE), POST /transcribe (SSE)
POST /diarize
- Accepts
multipart/form-data with audio field + optional max_workers (default 10)
- Splits audio into 10-min chunks via ffmpeg
- Sends chunks to Azure
gpt-4o-transcribe-diarize in parallel (Semaphore-limited)
- SSE events:
started → chunk_start → chunk_done → complete
chunk_done includes segments: DiarizedSegment[] for live partial transcripts
complete event payload: {segments:[{speaker,text,start,end}], totalSegments, uniqueSpeakers, totalMs}
POST /transcribe
- Accepts
multipart/form-data with audio field + optional model, max_workers (default 10)
- Supports:
voxtral-mini-latest, voxtral-mini-transcribe-2507, gpt-4o-transcribe
- Small files (≤ 20 MB): single-file path — Azure streams
delta events, Voxtral returns done
- Large files (> 20 MB): parallel chunked path — ffmpeg splits, all chunks run concurrently
- Azure chunks: 300-second (5 min) segments to stay under Azure's 25 MB per-file limit
- Voxtral chunks: 600-second (10 min) segments
- SSE events:
transcribe_started → chunk_text_done × N → done
chunk_text_done: {index, text, completed, total, durationMs}
Shared utilities in speech-tools/src/
semaphore.ts — shared Semaphore class used by both diarize and transcribe parallel paths
audio.ts — splitIntoChunks(buffer, filename, workDir, chunkDurationSec=600) with optional duration override; makeTempDir()
logger.ts — pino logger, conditionally ships to Axiom (AXIOM_TOKEN + AXIOM_DATASET env vars)
types.ts — discriminated union SseEvent covering all events for both paths
Speech Lab integration (homepage/src/app/projects/tools/speech/page.tsx)
handleDiarizeTranscribe → Render /diarize SSE; accumulates partialSegs on chunk_done
handleRenderTranscribe → Render /transcribe SSE; handles both delta (small) and chunk slot (large) paths; shows [Chunk N — processing…] placeholders for in-flight chunks
Env vars needed on Render
AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_API_VERSION=2025-03-01-preview
AZURE_OPENAI_DIARIZE_DEPLOYMENT=gpt-4o-transcribe-diarize
MISTRAL_API_KEY — required for Voxtral (not yet set as of last session)
AXIOM_TOKEN, AXIOM_DATASET — optional, enables structured log shipping to Axiom
Cold start / limits
- Free tier spins down after 15 min idle → ~50s cold start on first request
- File upload limit: 200 MB (multer in
index.ts)
- Page.tsx file limits: voxtral 1 GB, gpt-4o-transcribe 200 MB, diarize 500 MB
Proxying External Apps
When proxying an external app under a subpath like /polymarket, preserve the same base path in the destination if the upstream app is mounted there too.
Example:
- source
/polymarket -> destination https://upstream.example.com/polymarket
- source
/polymarket/:path* -> destination https://upstream.example.com/polymarket/:path*
If you strip the prefix and proxy /polymarket to upstream /, apps that redirect or serve assets from /polymarket/... can get stuck in a trailing-slash or self-redirect loop.
Tech Stack
- Next.js 16 (App Router, TypeScript, Turbopack dev)
- Tailwind CSS v4 (
@tailwindcss/postcss)
- Framer Motion (animations)
- Auth.js v5 (
next-auth@5.0.0-beta.30)
- No database — JWT sessions in encrypted cookies
Theming
Class-based dark mode: .dark on <html>, managed by ThemeProvider.
Architecture:
- Inline
<script> in <head> reads localStorage('theme') before paint → prevents flash
ThemeProvider (client component) wraps entire app in root layout.tsx
- CSS variables in
globals.css swap between :root and .dark
ThemeToggle component on homepage (fixed top-right)
ProfileMenu dropdown has theme toggle on projects pages
Adding a new themed surface: Use bg-glass, border-glass-border, text-foreground etc.
Never use hardcoded bg-white, text-gray-N, or bg-black. See kevinc-design skill for full var list.
Branches
dia-design — Active development, deployed to Vercel with SSR
main — Legacy static export to GitHub Pages
Common Tasks
Add a new protected page:
- Create
src/app/projects/<name>/page.tsx
- If it's under
/projects/* or /tools/*, make sure src/proxy.ts and auth.ts already cover that path family
Add a new tool to the Tools hub:
- Create
src/app/projects/tools/<name>/page.tsx for the UI
- Create
src/app/api/<name>/route.ts (or appropriate api path) for the backend proxy
- Add a card to
src/app/projects/tools/page.tsx
Add a new API proxy route:
- Create
src/app/api/usage/<service>/route.ts
- Follow the auth-check pattern above
- Fetch secrets via
getSecret() so runtime overrides work
- If the route feeds burn-rate UI, record a daily cumulative total in
src/lib/usageSnapshots.ts
- Add a card to
src/app/projects/usage/page.tsx
Add a new OAuth callback domain:
- Register URI in Google Cloud Console:
https://<domain>/api/auth/callback/google
AUTH_TRUST_HOST=true handles the rest
ESLint Rules
- Use
<Link> not <a> for internal navigation
- Use
<Image> not <img> — add external domains to next.config.ts images.remotePatterns
References