Epsilon

Epsilon

Epsilon is a block-based workspace for notes, canvases, and lightweight project boards. Users authenticate, create spaces, and arrange blocks (text, markdown, images, code, todos) on a drag-and-drop canvas.

This repository is a Turborepo monorepo: a Next.js frontend, a Go REST API, PostgreSQL, object storage for uploads, and shared packages.

System overview

Production is split across three hosts. The browser talks to Vercel for HTML and to EC2 for JSON. Sessions are cookie-based and stored in Postgres. Images go to S3 via short-lived presigned URLs.

flowchart TB subgraph client["Browser"] UI[Next.js UI] end subgraph vercel["Vercel — apps/web"] RSC[Server Components] Proxy[proxy.ts route guard] UI --> Proxy Proxy --> RSC end subgraph ec2["EC2 — apps/api-golang"] API[Go HTTP API :8080] MW[Middleware chain] H[Handlers] S[Services] R[Repositories] API --> MW --> H --> S --> R end subgraph data["Managed services"] Neon[(Neon PostgreSQL)] S3[(AWS S3 bucket)] Resend[Resend email API] end UI -->|"credentials: include REST"| API RSC -->|"Cookie: session_id"| API R --> Neon UI -->|"PUT presigned URL"| S3 API -->|"presign PutObject"| S3 API --> Resend

| Component | Runtime | Role | |-----------|---------|------| | apps/web | Vercel | UI, canvas, auth pages, SSR data loading, proxy.ts session gate | | apps/api-golang | EC2 (systemd) | Auth, spaces, blocks, sessions, OAuth, upload presign | | PostgreSQL | Neon | Users, sessions, spaces, blocks | | S3 | AWS | Space icons and block images (private bucket, HTTPS URLs) | | packages/emails | Local / internal | React Email templates and render service | | PostHog | Cloud | Product analytics (pageviews, client events) | | Sentry | Cloud | Error monitoring (Next.js + Go API in production) |

Backend layering: handler → service → repository → sqlc.

Request flows

Sign-in (email / password)

sequenceDiagram participant B as Browser participant W as Vercel participant A as Go API participant D as Neon B->>A: POST /auth/login credentials include A->>D: validate user, create session A-->>B: Set-Cookie session_id Domain=.yourdomain B->>W: GET /auth/callback loop until session valid B->>A: GET /auth/me credentials include end B->>W: GET /home W->>A: GET /auth/me Cookie header W->>A: GET /spaces Cookie header A->>D: ListSpaces by user_id W-->>B: HTML + space list

Image upload

sequenceDiagram participant B as Browser participant A as Go API participant S as S3 B->>A: GET /upload-url authenticated A-->>B: presigned PUT URL plus public fileUrl B->>S: PUT file bytes Content-Type image/* B->>A: PUT /spaces/:slug icon_url https://bucket...

Repository layout

apps/ web/ Next.js 16 frontend app/ App Router pages features/ Dashboard, canvas, auth UI lib/ api.ts, server-api.ts, spaces, upload proxy.ts Protected route cookie check api-golang/ Go 1.26 REST API cmd/server/ HTTP entrypoint internal/ handler/ HTTP adapters service/ Business rules repository/ Persistence middleware/ Auth, CORS, rate limit, security headers db/ Migrations, sqlc, connection helpers packages/ ui/ Shared React components emails/ Email templates + render server docker-compose.yml Local PostgreSQL only turbo.json Turborepo pipeline .github/workflows/ security.yml Lint, typecheck, Go build, audits deploy.yml SSH deploy API to EC2 on main

Prerequisites

| Tool | Version | Notes | |------|---------|--------| | Node.js | ≥ 18 | Frontend and tooling | | Yarn | 1.22.x | Package manager | | Go | 1.26+ | API server | | Docker | Latest | Local PostgreSQL | | golang-migrate | Latest | yarn db:migrate | | sqlc | Latest | yarn db:generate |

Optional: pg_dump (yarn db:schema), psql for manual inspection.

See CONTRIBUTING.md for install commands.

Quick start (local)

git clone <repo-url> cd epsilon-app yarn install cp apps/api-golang/.env.example apps/api-golang/.env.local cp apps/web/.env.example apps/web/.env.local yarn db:up yarn db:sync yarn db:seed # optional demo data yarn dev

| Service | URL | |---------|-----| | Web | http://localhost:3000 | | API | http://localhost:8080 | | Health | http://localhost:8080/health | | Swagger (dev only) | http://localhost:8080/swagger/ | | Email render | http://localhost:3001 |

Environment variables

Configuration is per application. There is no shared root .env for secrets.

| File | Copy to | Purpose | |------|---------|---------| | apps/api-golang/.env.example | .env.local (dev) or .env (EC2) | Database, OAuth, Resend, S3, URLs | | apps/web/.env.example | .env.local (Vercel) | API_URL, NEXT_PUBLIC_API_URL, assets |

Root DB scripts read apps/api-golang/.env.local.

Web (Vercel)

NEXT_PUBLIC_API_URL=https://api.yourdomain.com API_URL=https://api.yourdomain.com # SEO / metadata (canonical, Open Graph, JSON-LD) NEXT_PUBLIC_SITE_URL=https://yourdomain.com NEXT_PUBLIC_OG_IMAGE=https://assets.example.com/og.png NEXT_PUBLIC_LOGO_WHITE_IMAGE= NEXT_PUBLIC_DEFAULT_SPACE_ICON= # PostHog (optional — omit keys to disable client analytics) NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # Sentry (optional — @sentry/nextjs; source maps via build plugin) NEXT_PUBLIC_SENTRY_DSN= SENTRY_DSN=

API_URL and NEXT_PUBLIC_API_URL must be identical in production. Server Components use API_URL; the browser uses NEXT_PUBLIC_API_URL.

PostHog initializes in the root layout (PostHogProvider); pageviews are captured on route changes. Sentry uses separate DSNs for browser (NEXT_PUBLIC_SENTRY_DSN) and edge/server (SENTRY_DSN) via @sentry/nextjs.

API (EC2)

ENV=production DATABASE_URL=postgres://...@ep-xxx-pooler....neon.tech/neondb?sslmode=require FRONTEND_URL=https://yourdomain.com BACKEND_URL=https://api.yourdomain.com COOKIE_SECURE=true TRUST_PROXY=true COOKIE_DOMAIN=.yourdomain.com RESEND_API_KEY=... GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... AWS_REGION=us-east-1 S3_BUCKET=your-bucket AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... EMAIL_RENDER_URL=http://127.0.0.1:3001 # Sentry (optional — enabled when ENV=production) SENTRY_DSN_BACKEND=

Do not run yarn db:seed in production.

In production, the API initializes Sentry when ENV=production and SENTRY_DSN_BACKEND is set; panics in handlers are reported via recovery middleware.

Database (Neon)

The API uses pgx with prefer_simple_protocol=true when the host is Neon or a pooler. That avoids Postgres error 08P01 (bind message has N result formats but query has M columns) caused by transaction-mode poolers reusing connections with stale prepared statement metadata.

If you still see ListSpaces error: pq: bind message... after deploy:

  1. Confirm migrations 000003 and 000004 ran on the production database (description, icon_url on spaces).
  2. Restart the API after deploy so connection pools reset.
  3. Prefer the Neon pooler host in DATABASE_URL for EC2; the code appends prefer_simple_protocol=true automatically.

Authentication

| Mechanism | Detail | |-----------|--------| | Session | session_id HttpOnly cookie, 7-day TTL, row in sessions | | Password | Argon2id | | OAuth | Google and GitHub; callback redirects to /auth/callback | | Web guard | proxy.ts requires cookie on /home and /spaces/* | | API guard | AuthMiddleware on mutating and private reads |

Cookie attributes in production: Secure, SameSite=None, Partitioned (CHIPS), domain from COOKIE_DOMAIN or derived from FRONTEND_URL.

Brave / strict browsers: the web app calls the API through /api/* (Next.js rewrite) so session cookies are first-party. Direct fetch from the site to api.yourdomain.com is often blocked by Brave Shields even when a cookie appears in DevTools. OAuth still starts on the API host; after redirect, /auth/callback uses the same-origin /api path.

Object storage (S3)

| Concern | Implementation | |---------|----------------| | Access | GET /upload-url?content_type=&content_length=&folder= (authenticated) returns a 15-minute presigned PUT (max 5 MiB enforced at presign request; signature is bucket/key only so browsers are not tied to signed Content-Type/Content-Length) | | Types | image/jpeg, image/png, image/webp only | | Layout | spaces/{userId}/{nano}.ext or blocks/{userId}/{nano}.ext | | Size cap | 5 MiB per object on presigned PUT | | Icons in prod | https:// URLs only (no data: URLs on spaces) |

Lock down the bucket with IAM least privilege and block public listing; objects are referenced by HTTPS URL after upload.

Observability

| Tool | Where | Env vars | Behavior | |------|--------|----------|----------| | PostHog | apps/web (client) | NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST | Product analytics; manual $pageview on navigation | | Sentry (web) | apps/web | NEXT_PUBLIC_SENTRY_DSN, SENTRY_DSN | Next.js client, edge, and server errors; wired in next.config.ts | | Sentry (API) | apps/api-golang | SENTRY_DSN_BACKEND | Production-only init; panic recovery flushed to Sentry |

Leave analytics/error keys empty locally if you do not need them. For Sentry releases and source maps on Vercel, configure the project org/token in the Sentry dashboard and follow @sentry/nextjs docs (build plugin is already wrapped in withSentryConfig).

Development commands

yarn dev # web + api + email render yarn build # production build all workspaces yarn lint yarn check-types # web TypeScript yarn db:up yarn db:migrate yarn db:generate yarn db:sync # migrate + schema dump + sqlc yarn db:seed # dev only yarn db:reset yarn format

Deployment

| Step | Target | Action | |------|--------|--------| | 1 | Neon | Run migrations (yarn db:migrate against prod URL once) | | 2 | EC2 | Clone repo to e.g. /home/ubuntu/epsilon-app, set apps/api-golang/.env, enable epsilon-backend.service | | 3 | GitHub | Add secrets EC2_HOST, EC2_SSH_KEY for automated API deploys | | 4 | Vercel | Connect apps/web, set env vars (API URLs, PostHog, Sentry, assets), deploy on push | | 5 | AWS | S3 bucket + IAM credentials on EC2 for presign |

Put TLS termination on nginx or ALB in front of the API; set TRUST_PROXY=true so rate limits use X-Forwarded-For.

Automated backend deploy

.github/workflows/deploy.yml runs on push to main when files under apps/api-golang/** change. It SSHs into EC2 and runs:

cd /home/ubuntu/epsilon-app git pull origin main cd apps/api-golang go build -o epsilon-server ./cmd/server sudo systemctl restart epsilon-backend

Required GitHub Actions secrets:

| Secret | Purpose | |--------|---------| | EC2_HOST | Public hostname or IP of the API server | | EC2_SSH_KEY | Private key for the ubuntu user |

The frontend is not deployed by this workflow; use Vercel (or your own pipeline) for apps/web.

CI

| Workflow | Trigger | What it does | |----------|---------|----------------| | .github/workflows/security.yml | Push/PR to main or master | yarn workspace web lint, check-types, go vet / go build, yarn audit and govulncheck (informational, continue-on-error) | | .github/workflows/deploy.yml | Push to main (API paths only) | SSH deploy and restart epsilon-backend on EC2 |

Contributing

See CONTRIBUTING.md for workflow, migrations, and conventions.

License

This project is licensed under the MIT License.

Copyright (c) 2026 Kaustubh Sankhe