Imported from Zlatanoski/snippetvault (
AGENTS.md). Install upstream withnpx skills add Zlatanoski/snippetvault. Copyright stays with the author.
AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Project Overview
SnippetVault is a full-stack code snippet manager. Users authenticate, then create/organize snippets into collections, tag them, comment on them, track version history, and share individual snippets publicly via share tokens. The repo is a monorepo with two independent apps: backend/ (Express + TypeScript + PostgreSQL/Drizzle) and frontend/ (React + TypeScript + Vite).
Commands
Backend (cd backend)
pnpm dev # start with nodemon + ts-node (auto-restart on changes)
pnpm start # run compiled build/index.js
pnpm build # tsc compile to build/
pnpm typecheck # tsc --noEmit
Frontend (cd frontend)
pnpm dev # Vite dev server (http://localhost:5173)
pnpm build # production build
pnpm exec tsc --noEmit # typecheck
pnpm lint # ESLint
pnpm preview # preview production build
There are no tests yet.
Environment Setup
- Repo root
.env— private configuration consumed by Docker Compose. The local stack usesdocker-compose.yml; the public HTTPS stack usesdocker-compose.selfhost.yml. These modes require different URL values and should not share one unchanged.env. .env.selfhost.example— committed template for the public self-hosted stack. PostgreSQL passwords must contain at least 32 cryptographically random characters fromA-Z,a-z,0-9,_, and-because they are embedded directly inDATABASE_URL.backend/.env— private environment used when running or deploying the backend outside Docker Compose. Do not commit or copy it into Docker images.backend/.env.example— documentation for environment variables understood by the backend.frontend/.env.production— officialsnippetvault.mefrontend build configuration. Docker self-hosted builds use theVITE_API_URL=/apibuild argument instead.
docker compose up --build starts PostgreSQL, runs the one-shot Drizzle migrate service, starts the private backend after migrations succeed, and serves the frontend at http://localhost:8080. PostgreSQL and the backend are not published to host ports. The HTTPS stack uses docker compose -f docker-compose.selfhost.yml up -d --build and publishes only ports 80 and 443 through the frontend Caddy server.
Social provider credentials are optional — backend/src/lib/auth.ts only registers a Google/GitHub provider when both its environment variables are set and are not the literal string placeholder.
Schema is defined in Drizzle (backend/src/db/schema.ts); backend/drizzle.config.ts + backend/drizzle/ hold generated migrations. The Drizzle schema is authoritative.
The root package.json/pnpm-lock.yaml only hold stray CodeMirror dependencies. Install the backend and frontend independently with pnpm using their respective manifests and lockfiles.
Architecture
Backend
- Entry point:
backend/src/index.ts— applieshelmet+ the CORS allowlist, exposesGET /api/health, mounts the Better Auth handler at/api/auth/{*any}beforeexpress.json()(Better Auth needs the raw body — keep that ordering), then mounts resource routers under/api/. It does not serve the frontend; the official frontend is deployed separately and Docker builds serve it through Caddy - Database:
backend/src/lib/db.tsexports a Drizzle instance (db, default export) wrapping apgPool. Every route importsdbfrom../lib/dband builds queries with Drizzle's query builder (eq,and,or,ilike,sql, etc. fromdrizzle-orm) — no raw SQL strings except insidesql\...`` fragments - Schema ↔ API casing convention: Drizzle table columns are camelCase (
userId,collectionId,createdAt). Every route maps query results through a localmapX/mapXWithYfunction that translates fields to snake_case before sending JSON (user_id,collection_id,created_at) — the wire format is intentionally snake_case even though the ORM layer is camelCase. Keep new endpoints consistent with this - Auth: Better Auth (
better-authpackage), session-based. Configured inbackend/src/lib/auth.ts: Drizzle adapter (provider: 'pg', serial integer IDs), email+password enabled, optional Google/GitHub social providers, and custom field mapping onto the existinguserstable (name→displayName,image→avatarUrl,createdAt→registeredAt) plus adatabaseHooks.user.create.beforehook that derivesusernamefrom the signup name. Auth state lives in theauth_session/auth_account/auth_verificationtables.backend/src/middleware/authMiddleware.tsresolves the session viaauth.api.getSession(fromNodeHeaders(req.headers))and setsreq.userId(anumber) for downstream handlers. There is no customroutes/auth.ts— Better Auth serves all/api/auth/*endpoints itself. (express-session,connect-pg-simple, andbcryptjsremain inpackage.jsonbut are vestigial — do not build on them) - Routes: One file per resource under
backend/src/routes/(snippets,collections,tags,comments,aiSettings,profile,share). All routes except/api/auth/*and/api/share/:tokenrequireauthMiddleware. Every mutation route runsexpress-validatormiddleware first, then checksvalidationResult(req)at the top of the handler - Validators: Defined in
backend/src/validators/and imported asValidationChain[]arrays into the route files. The allowed snippet languages are the sharedSUPPORTED_LANGUAGESconst, duplicated inbackend/src/constants/languages.tsandfrontend/src/constants/languages.ts— keep both in sync - Versioning:
snippet_versionrows are created lazily — only when aPATCHactually changescodeand that exact code isn't already saved as a prior version (see theshouldSaveVersionlogic insnippets.ts). Restoring a version snapshots the current code as a new version first if it differs - Public sharing:
snippet.visibilityis'private' | 'public'. When a snippet is created as (or patched to) public, ashare_tokenis generated viacrypto.randomBytes(24).toString('base64url'); an existing token is reused when toggling back to public.GET /api/share/:token(inroutes/share.ts) is the only unauthenticated resource endpoint — it returns the snippet only ifvisibility = 'public', plus anowner_name(the owner'sdisplayName, falling back tousername) for attribution; it must never leak email, user id, or any other private field
API base URLs:
POST /api/auth/sign-up/email,POST /api/auth/sign-in/email,POST /api/auth/sign-out,POST /api/auth/sign-in/social,POST /api/auth/change-password,POST /api/auth/delete-user— all handled by Better Auth, not by route filesGET|POST /api/snippets(list supports?q=search over title/language/description/tag),GET|PATCH|DELETE /api/snippets/:idGET /api/snippets/:id/versions,GET|DELETE /api/snippets/:id/versions/:versionId,POST /api/snippets/:id/versions/:versionId/restoreGET|POST /api/collections,PATCH|DELETE /api/collections/:id,PATCH /api/collections/:id/snippets/:snippetId(assign/unassign a snippet)GET|POST /api/tags,GET /api/tags/:id,GET /api/tags/:id/snippets,POST|DELETE /api/tags/:id/snippets/:snippetIdGET|POST|PATCH|DELETEcomments viaroutes/comments.ts, mounted at/api(/api/snippets/:snippetId/comments,/api/comments/:id)- AI settings (
routes/aiSettings.ts) are mounted at the bare/apiroot —GET|POST|DELETE /api/(a known quirk; the frontend callsVITE_API_URLdirectly). Stores per-user AI provider config;api_keyis persisted asapiKeyEnc GET|PATCH|DELETE /api/profile(password change and account deletion go through the Better Auth endpoints above;DELETE /api/profileremoves the app-side user row)GET /api/share/:token— public, no auth
Frontend
- Entry:
frontend/src/main.tsx→App.tsx(React Router routes:/login,/register,/dashboard,/share/:token(public shared-snippet view),/docs(redirects to/docs/introduction),/docs/:slug, catch-all → landing page) - Pages:
src/pages/—LandingPage,LogIn,Register,Dashboard,CollectionsView,SearchView,NewSnippet,SnippetDetailPanel,ProfileView,SharedSnippetView. The docs site lives undersrc/pages/docs/(DocsPage,DocsLayout,DocsSidebar,DocsToc,DocBlocks,registry.tsx) with per-topic content insrc/pages/docs/content/(Introduction, QuickStart, Snippets, Collections, Tags, Comments, Search, PublicSharing, VersionHistory, ApiReference) - API layer:
src/api/— one module per resource, plustypes.ts(shared response types) andutils.ts(ApiError,throwIfNotOk— every API function should parse errors through this so callers can checkerr instanceof ApiError && err.status === ...). All modules usecredentials: 'include'so the session cookie is sent automatically. The backend base URL is read fromVITE_API_URL. Auth calls (src/api/auth.ts, plus password-change/delete insrc/api/profile.ts) hit Better Auth's REST endpoints with plainfetch— thebetter-authclient library is intentionally not used - Data fetching pattern: custom hooks (
src/hooks/useSnippets.ts,useCollections.ts,useUser.ts,useToast.ts) wrapuseState+useEffectaround the API modules, redirect to/loginon a 401ApiError, and surface other errors via local state or the toast context - Contexts:
UserContext(src/contexts/UserContext.tsx) owns the current-user profile plus save/change-password/delete-account actions;ToastContextprovides app-wide toast notifications - Components:
src/components/(PascalCase.tsx) — includesCodeEditor(CodeMirror 6 wrapper),VersionHistoryPanel,CollectionCard/CollectionDialog,SnippetList/SnippetRow,LanguageBadge,TagPill,Sidebar,TopBar,Toast,StatCard,ShareDialog(visibility toggle + copy-link UI for public sharing);src/components/landing/holds the marketing landing page sections - Styling: Tailwind CSS v4 (via
@tailwindcss/viteplugin) only — no other CSS frameworks or UI libraries. Interactive primitives live insrc/components/ui/(Button, Input, Textarea, Badge, Card, Select, Field, Dialog, AlertDialog, Toast, Tabs, Spinner, Alert) built on@base-ui/react+cva, withcn()insrc/lib/utils.ts— use these instead of writing inline primitives - Code editor:
@uiw/react-codemirrorwith per-language@codemirror/lang-*packages (JS, Python, CSS, HTML, SQL, C++, Go, Java, Rust) and the One Dark theme frontend/src/data.tscontains legacy mock data and is no longer imported anywhere — the app is fully wired to the API
Database Schema
Tables (defined in backend/src/db/schema.ts): users, auth_session, auth_account, auth_verification (Better Auth tables), collection, snippet, tag, snippet_tag (junction), snippet_version, comment, user_ai_settings.
snippet.collection_id is nullable (snippets don't need a collection); snippet also carries visibility and a unique nullable share_token.
Tags are global (not per-user); ownership is enforced at the snippet level.
user_ai_settings has a unique constraint on user_id (one settings row per user); writes use Drizzle's onConflictDoUpdate as an upsert.
Password hashes live in auth_account.password (managed by Better Auth), not on users.
Code Style
- Do not write comments anywhere in generated code — no inline comments, no block comments, no docstrings
Security & Performance
This project and all future changes must follow OWASP best practices (OWASP Top 10 / ASVS) and standard performance practices. Concretely, for this codebase:
- Injection: All queries go through Drizzle's query builder — never interpolate user input into raw
sql\...`fragments; use parameterizedsql` template placeholders if a raw fragment is unavoidable - Broken access control: Every route handler must verify
req.userIdownership in itsWHEREclause (never trust a:idparam alone); when reassigning a foreign key owned by another table (e.g.collection_idon a snippet), verify the referenced row belongs toreq.userIdbefore writing. The share endpoint must only ever expose snippets withvisibility = 'public', matched by token - Authentication/session: Auth is delegated to Better Auth with server-side sessions in Postgres; never put auth state or secrets in a JWT/localStorage; never reimplement password handling outside Better Auth; passwords are never logged or returned in API responses
- Input validation: Every mutation route needs an
express-validatorchain checked viavalidationResult(req)before touching the DB — validate type, length, and allowed values (enums) server-side, not just in the frontend - Secrets management: Never commit
.envfiles or hardcode credentials/API keys;user_ai_settings.api_key_encmust stay encrypted at rest, never returned decrypted in a response - Output encoding / XSS: React's default JSX escaping handles most of this — never use
dangerouslySetInnerHTMLon user-controlled content (snippet code, descriptions, comments) without sanitization - CORS: Keep the
ALLOWED_ORIGINSallowlist inbackend/src/index.tsexplicit; do not widen it to a wildcard or reflect arbitrary origins. KeeptrustedOriginsinbackend/src/lib/auth.tsin sync with it - Dependencies: Avoid introducing packages with known CVEs; prefer well-maintained libraries already in use over adding new ones for the same purpose
- Performance: Add DB indexes on foreign key columns that are filtered/joined on (e.g.
snippet.user_id,snippet.collection_id,comment.snippet_id); avoid N+1 query patterns — prefer a single joined Drizzle query over looping queries per row; keep payloads paginated for list endpoints rather than returning unbounded result sets
Key Patterns
- All backend queries go through Drizzle (
drizzle-orm/node-postgres) — no raw query strings in route handlers - Route handlers always verify
user_id/userIdownership inWHEREclauses to prevent horizontal privilege escalation - When setting
collection_idon a snippet — both on create (POST /api/snippets) and when reassigning it (PATCH /api/snippets/:id) — the handler manually checks that the collection belongs toreq.userIdbefore writing - API responses are snake_case; Drizzle schema and TS variables are camelCase — translate at the route boundary via
mapXhelpers, not in the schema
Repo-specific Codex setup
.Codex/agents/defines specialized subagents:db-migrator,backend-ts-converter,frontend-ts-converter,ts-migration-reviewer(the MySQL→PostgreSQL and JS→TS migrations they were built for are complete, but they remain useful for follow-up type-safety or query cleanup passes),frontend-ui-masterfor frontend UI work,code-auditorfor OWASP/performance sweeps, and a read-onlyfrontend-visual-testerthat verifiesfrontend-ui-master's output across breakpoints.Codex/hooks/block-tester-writer.shblocksfrontend-visual-testerfrom ever usingWrite/Edit, even if its tool list is widened later — it must stay read-only so its review offrontend-ui-master's output stays meaningful- Browser testing: use the Playwright MCP tools (
mcp__playwright__*) — Chrome is installed and working on this machine (since 2026-07-15). Only start Playwright testing when the user explicitly asks for it; never launch browsers or visual-test runs proactively after UI changes
Frontend UI migration workflow (Base UI conversion)
When migrating a component to Base UI, fixing responsive/alignment issues, or doing any styling work, follow this loop instead of editing files directly:
- Invoke
frontend-ui-masterwith the specific component(s) and task. - Invoke
frontend-visual-testerto verify the result across breakpoints. - Read the tester's report:
RESULT: PASS→ move to the next component, or stop if done.RESULT: FAIL (n issues)→ invokefrontend-ui-masteragain with the tester's exact issue list as input.
- Cap at 3 build→test rounds per component. If still failing after 3 rounds, stop and report the unresolved issues directly — do not keep looping silently.
Never edit component/JSX/TSX/Tailwind files directly in the main thread — always delegate to frontend-ui-master so the visual-tester's review stays meaningful. Track round count per component explicitly before each re-invocation.
Frontend Design System
Stack
- Framework: React 19 + Vite (
frontend/) - Styling: Tailwind CSS only — no CSS modules, no inline styles
- Font: Inter (loaded via Google Fonts in
index.html) - Icons:
lucide-react— the one exception to "no other UI libraries"; use it instead of inline SVGs - Components: PascalCase
.tsxfiles infrontend/src/components/ - No hardcoded pixel widths/heights — use Tailwind responsive utilities and flex/grid
Color Palette
// Backgrounds (darkest → lightest)
'#0f0f0f' // bg-app — page root background
'#161616' // bg-sidebar — sidebar panel
'#1a1a1a' // bg-card — snippet row, secondary buttons
'#222222' // bg-input — search bar fill
'#242424' // bg-tag — tag pill fill
// Borders & Dividers
'#2a2a2a' // border-subtle — all dividers, input borders
// Text hierarchy
'#ffffff' // text-primary — titles, active labels
'#9ba3af' // text-secondary — inactive nav, descriptions
'#595e69' // text-muted — timestamps, counts, placeholders
// Accent (indigo)
'#6366f1' // accent — logo bg, active nav highlight, primary CTA button
Language Badge Color Map
Each code language has a colored dot + text + dark tinted background:
| Language | Dot & Text | Badge Background |
|---|---|---|
| TS | #3d77fc |
#0b152d |
| PY | #22c55e |
#062311 |
| SH | #8c5af3 |
#19102c |
| SQL | #ef4444 |
#2b0c0c |
| JS | #fba528 |
#1f1200 |
Sidebar Tag Dot Colors
| Tag | Dot Color |
|---|---|
| react | #3d77fc |
| utils | #22c55e |
| auth | #8c5af3 |
| db | #fba528 |
Design Conventions
- Theme: Dark only for now. Light mode toggle is UI placeholder — not implemented yet.
- Border radius:
rounded-md(6px) for buttons/inputs,rounded(4px) for tags/badges - Interactive states:
hover:bg-white/5 transition-colors duration-150for nav items;hover:bg-indigo-500for primary CTA - Snippet rows:
hover:bg-[#1f1f1f] cursor-pointer— entire row is clickable - Responsiveness: Sidebar collapses to a drawer on mobile (
lg:breakpoint for persistent sidebar) - Truncation: Always use
truncate+min-w-0on flex children that hold text — never let rows overflow horizontally
Deploy Agent — SnippetVault (AWS)
This section defines how Codex should behave when the user asks to deploy, redeploy, or check deployment health for SnippetVault. Follow it exactly when a deploy task is requested — do not improvise flags or skip steps.
Scope & permissions
- You have permission to run
pnpm,aws,eb, andcurlcommands without asking for confirmation on each one, as long as they match the commands listed below. Destructive or unlisted AWS commands (e.g. anything that deletes a resource, changes IAM, or modifies billing) always require explicit user approval first. - These tasks require network access. If it is unavailable, stop and report the blocker instead of continuing with partial deployment steps.
Environment
- Frontend: React/Vite app, S3 bucket
snippetvault-frontend(regioneu-central-1), CloudFront distributionE2SYZG5BTAFCXA, live athttps://snippetvault.me. - Backend: Node/Express on Elastic Beanstalk (Node 22 platform), live at
https://api.snippetvault.me. Deployed viaeb deployfrom the backend repo root (must contain.elasticbeanstalk/config.yml).
Task: Deploy frontend
Run, in order, and inspect the output of each before proceeding to the next:
pnpm build— must exit 0. If it fails, stop and report the build error; do not attempt to deploy a staledist/.aws s3 sync dist/ s3://snippetvault-frontend --delete --no-cli-pager— review the sync output; if it uploads 0 files, something is wrong (empty or missingdist/) — stop and report rather than invalidating a nonexistent deploy.aws cloudfront create-invalidation --distribution-id E2SYZG5BTAFCXA --paths "/*" --no-cli-pager— capture theInvalidation.Idfrom the JSON output.- Poll
aws cloudfront get-invalidation --distribution-id E2SYZG5BTAFCXA --id <id> --no-cli-pagerevery ~10s untilInvalidation.StatusisCompleted. Don't poll more than once every 10 seconds. If it's not done after ~10 minutes, report that it's taking unusually long rather than continuing to poll silently. curl -s -o /dev/null -w "%{http_code}" https://snippetvault.me— confirm200. If not, report the actual status code and do not assume success.
Task: Deploy backend
- Verify the Elastic Beanstalk environment defines every required backend
variable, including
RESEND_API_KEYandEMAIL_FROM, without printing their values. Stop if either is missing. eb deployfrom the backend repo root. Watch the output live — EB CLI streams deploy events; if it reports a failed health transition or a deployment abort, stop and surface the actual error text, don't just say "deploy failed."- Poll
eb statusevery ~10s until it reportsStatus: ReadyandHealth: Green. If health isYelloworRed, runeb health --refreshand/oreb logsto pull the actual cause before reporting back — don't just say "unhealthy," say why. curl -s -o /dev/null -w "%{http_code}" https://api.snippetvault.me/api/healthand confirm200.
Task: Full deploy
Do frontend first, then backend, in the sequence above. If frontend fails, stop — do not proceed to backend deploy.
Task: Status check only (no deploy)
Run the curl checks against both URLs and eb status, report current
state. Do not run pnpm build, s3 sync, eb deploy, or create any
CloudFront invalidation for a status-only check.
Reporting back
After any deploy task, give a short pass/fail summary per component (frontend / backend), not a raw transcript of every command. Include the actual HTTP status codes and EB health status in the summary. If something failed, include the specific error, not just which step it was.
