This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
npm run dev # Start all apps in parallel
npm run build # Build all apps
npm run lint # Lint all apps
npm run test # Run all testsnpm run dev # NestJS watch mode (port 3001)
npm run build # Compile to dist/
npm run test # Jest
npm run test:watch # Jest watch mode
npm run test:e2e # E2E tests
npm run db:generate # Generate Drizzle migration SQL from schema changes
npm run db:migrate # Apply pending migrations to Postgres
npm run db:seed # Upsert test user + interests
npm run db:studio # Open Drizzle Studio at https://local.drizzle.studionpm run dev # Next.js dev server (port 3000)
npm run build # Production build
npm run lint # ESLint via next lintdocker compose up -d # Start Postgres (5433) + Redis (6380)
docker compose ps # Verify healthyapps/api— NestJS REST API, port 3001apps/web— Next.js 15 frontend, port 3000packages/types— shared TypeScript types (minimal usage currently)- Turbo orchestrates tasks; pnpm workspaces link packages
PostgreSQL with pgvector extension via pgvector/pgvector:pg16 Docker image. Drizzle ORM is used throughout — no TypeORM, no Prisma.
Schema tables: users, articles, refresh_tokens, mcp_tokens, user_interests, jobs, market_reports, ai_evaluations, mcp_clients, pending_mcp_authorizations, pending_auth_codes, cron_locks
The articles.embedding and user_interests.queryEmbedding columns are vector(1536) (OpenAI text-embedding-3-small). Vector similarity search uses the native <=> operator via raw SQL.
Column naming: The Drizzle schema uses snake_case column names (e.g. google_id, created_at). Keep all new columns snake_case.
Drizzle config lives at apps/api/drizzle.config.ts with ssl: false for local Docker Postgres. Migration files output to apps/api/drizzle/.
Never edit, renumber, or backdate a migration once it has been applied anywhere (local, CI, or prod). Only ever add a NEW migration. If a migration is wrong, fix it forward with a new one.
Why this matters here: production and local already have drifted migration history because earlier migrations were edited after the fact (e.g. 0006 was rewritten to add DROP TABLE IF EXISTS, 0003/0007 were backdated, 0007_jobs_table was renumbered when merged from another branch). Each database froze a different snapshot, so neither __drizzle_migrations log perfectly matches the repo journal. This is currently cosmetic and safe — drizzle-kit migrate decides what to apply by the journal when timestamp, not by hash, and all real tables/data are correct. Editing applied migrations is what caused the drift; keep doing it and you risk "table already exists" failures or silently-skipped migrations.
Rules of thumb:
- Schema change →
npm run db:generate(creates a new file), thennpm run db:migrate. Don't hand-edit generated SQL for already-applied versions. - New migrations always get a
when = Date.now()greater than the current max, so they apply cleanly to both drifted databases. - For prod (Neon), run migrations against
DATABASE_URLpointed at Neon withDB_SSL=true. Verify the target and that the change is additive before applying.
| Module | Responsibility |
|---|---|
DrizzleModule |
Global database module, provides the DRIZZLE provider injection symbol |
AuthModule |
Google OAuth 2.0, token generation/rotation, session handling & guards |
UsersModule |
User preference persistence & account deletion cascades |
AiModule |
Integrates OpenAI SDK to generate summaries and vector embeddings |
FeedModule |
Fetches and ranks articles using cosine similarity vectors + tag boosts |
ChatModule |
LangGraph agentic RAG pipeline (retrieve -> grade -> rewrite -> generate) |
ScraperModule |
Pulls raw developer articles from Hacker News, Dev.to, and Cheerio |
JobsModule |
Remotive job board scraper and market report generator |
SchedulerModule |
Runs daily automated cron pipelines under a database-backed distributed lock |
McpModule |
SSE listener exposing search_articles, get_personalized_feed, and ask_inferr tools |
McpAuthModule |
Sub-module of McpModule providing custom OAuth 2.1 authorization & tokens |
EvaluationsModule |
Internal LLM-as-judge service for RAG response quality metrics |
LangfuseModule |
Integrates callback handlers for prompt tracing & pipeline telemetry |
DB injection pattern — every service that needs the DB injects it the same way:
constructor(@Inject(DRIZZLE) private db: DrizzleDB) {}No repository layer — services query Drizzle directly. This is intentional and consistent across all modules.
- Browser →
GET /auth/google→ Google consent screen - Google →
GET /auth/google/callback→ upserts user in DB → redirects to${FRONTEND_URL}/auth/callback?token=<user.id> - Frontend stores
user.idUUID as the auth token inlocalStorage+google_id_tokencookie - Protected API calls send
Authorization: Bearer <user.id>;GoogleTokenGuardvalidates by DB lookup (no JWT)
- MCP client (Claude Desktop / Claude Code) registers at
POST /register→ gets aclient_id - Client opens
GET /authorize(PKCE) →McpOAuthProviderredirects toGET /auth/google/mcp?state=… GoogleMcpStrategy(separate from the web-app strategy) carries the MCPstatethrough Google consent- Google →
GET /auth/google/mcp-callback→ upserts user →completeMcpAuthorization()issues a single-use auth code - Client exchanges code for tokens at
POST /token(PKCE verified) → receives a 1h JWT access token + 7d refresh token - MCP requests hit
POST /GET /DELETE /mcpwithAuthorization: Bearer <jwt>;McpOAuthProvider.verifyAccessToken()validates and extractsuserId - Each session gets a per-user
McpServerinstance — tools close overuserIdso one user cannot read another's feed
MCP tools exposed:
| Tool | Description |
|---|---|
search_articles |
pgvector semantic search over the article corpus |
get_personalized_feed |
Returns the authenticated user's personalised feed |
ask_inferr |
Agentic RAG pipeline (retrieve → grade → rewrite → generate) |
Security notes: Refresh tokens are rotated on every use and stored SHA-256-hashed in mcp_tokens. Reuse of an already-rotated token nukes the entire chain for that user. MCP JWTs carry type: 'mcp_access' to prevent cross-use with web-app tokens.
Single-instance caveat: Active MCP SSE session transports are stored in-memory (in McpService's transports Map), meaning horizontal scaling requires sticky sessions at the load balancer/gateway level so that subsequent requests with a given session ID route to the container holding that socket. Registered OAuth clients, tokens, and pending PKCE authorization states are fully database-backed and cluster-safe.
NestJS internal cron tasks are scheduled via @nestjs/schedule decorators in SchedulerService. To prevent concurrent execution across multiple running API replicas (e.g., inside Kubernetes pods), executions are wrapped in runWithLock(jobName, ttl, callback):
- Uses the
cron_lockstable (primary keyjob_name). - Acquires the lock by attempting to insert a row; unique constraint violation indicates the lock is currently active on another instance, causing the execution to be skipped.
- Expired locks (older than a 6-hour TTL safety window) are cleaned up automatically before attempting acquisition.
- Releases the lock on callback completion or failure via a
finallyblock.
app/page.tsx— public landing page; Sign In links to${NEXT_PUBLIC_API_URL}/auth/googleapp/auth/callback/page.tsx— receives?token=from API redirect, stores token, redirects to/dashboardapp/dashboard/page.tsx— fetches/auth/mewith Bearer token; redirects to/if unauthenticatedmiddleware.ts— blocks/dashboardifgoogle_id_tokencookie is absentsrc/lib/server-status.tsx— thin wrapper: exportsAPI_BASEconstant andapiFetch(plainfetchpass-through). The former wake overlay andServerStatusProviderhave been removed.
Single .env at the repo root, loaded by both apps. Key values:
DB_PORT=5433 # Docker maps postgres to 5433 (not 5432)
REDIS_PORT=6380 # Docker maps redis to 6380 (not 6379)
API_PORT=3001
NEXT_PUBLIC_API_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3000
OPENAI_API_KEY= # Required for embeddings and RAG
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:3001/auth/google/callback
GOOGLE_MCP_CALLBACK_URL=http://localhost:3001/auth/google/mcp-callback # MCP OAuth flow
API_URL=http://localhost:3001 # Used by mcpAuthRouter (issuerUrl/baseUrl) and MCP WWW-Authenticate headers
In production set API_URL=https://api.inferr.xyz and GOOGLE_MCP_CALLBACK_URL=https://api.inferr.xyz/auth/google/mcp-callback. Also add the production callback URL to Google Cloud Console → OAuth 2.0 Client → Authorized redirect URIs.
Requests live in bruno/. To manually trigger the scraper: Run Scraper (POST /scraper/run). The collection also includes RAG init/query requests and a health check.