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
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ jobs:
defaults:
run:
working-directory: interviewhub-lld
env:
PUPPETEER_SKIP_DOWNLOAD: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -68,8 +66,12 @@ jobs:
run: npm run typecheck
- name: Verify session isolation and revocation queue
run: npm run test:session-boundaries
- name: Check browser smoke script
run: node --check scripts/verify-e2e.mjs
- name: Verify immediate workspace snapshots
run: npm run test:workspace-snapshot
- name: Check browser test scripts
run: |
node --check scripts/verify-e2e.mjs
node --check scripts/verify-workspace-snapshot.mjs
- name: Build
run: npm run build

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
*.env
!*.env.example

# Local certificates and mounted development secrets
.local-secrets/
*/.local-secrets/

# Dependencies
node_modules/
*/node_modules/
Expand Down
9 changes: 5 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ FRONTEND_DIR := $(ROOT)/interviewhub-lld
help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "; printf "JudgeMyCode development commands:\n"} /^[a-zA-Z_-]+:.*## / {printf " %-10s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

setup: ## Prepare env files, PostgreSQL, migrations, seeds, and dependencies
setup: ## Prepare env files, database schema, local seeds, and dependencies
./run-local.sh --setup-only

dev: ## Start the complete local development stack
dev: ## Start frontend and API with the configured database mode
./run-local.sh

stop: ## Stop API and frontend; leave PostgreSQL running
./stop-local.sh

stop-all: ## Stop API, frontend, and PostgreSQL without deleting data
stop-all: ## Stop API, frontend, and local Docker PostgreSQL
./stop-local.sh --dependencies

check: ## Run static checks without producing build artifacts
Expand All @@ -31,9 +31,10 @@ check: ## Run static checks without producing build artifacts
cd "$(FRONTEND_DIR)" && npm run test:session-boundaries
docker compose --file "$(BACKEND_DIR)/docker-compose.yml" --project-directory "$(BACKEND_DIR)" config --quiet

test: ## Run backend tests, including the race detector
test: ## Run backend tests and frontend browser regressions
cd "$(BACKEND_DIR)" && go test ./...
cd "$(BACKEND_DIR)" && go test -race ./...
cd "$(FRONTEND_DIR)" && npm run test:workspace-snapshot

build: ## Build backend and production frontend
mkdir -p "$(ROOT)/.local-run/bin"
Expand Down
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# JudgeMyCode

JudgeMyCode is a React/Vite interview workspace backed by a Go API and PostgreSQL. The supported local workflow runs PostgreSQL in Docker and runs the API and frontend directly on the host for fast reloads.
JudgeMyCode is a React/Vite interview workspace backed by a Go API and PostgreSQL. The supported development workflow can use either loopback-only PostgreSQL in Docker or an explicitly configured managed PostgreSQL database, while the API and frontend run directly on the host.

## One-command local development

Prerequisites:

- Go 1.25 or newer compatible toolchain
- Node.js 22.12+ and npm
- Docker with Compose v2
- Docker with Compose v2 when using the default local database mode
- `curl`, `lsof`, and `cksum` (included by default on macOS and most Unix systems)
- For every local execution language: Python 3, a JDK (`javac`/`java`), and a
C++17 compiler (`g++`). Missing toolchains do not prevent the application
Expand All @@ -20,7 +20,7 @@ From this directory:
./run-local.sh
```

The command is safe to repeat. It creates missing local environment defaults, starts and waits for PostgreSQL, applies migrations, explicitly loads the local demo users and problem catalog, installs locked frontend dependencies when needed, rebuilds/restarts the API and Vite processes, and waits for both HTTP endpoints. The explicit restart prevents a previous binary from serving stale source or configuration.
The command is safe to repeat. In the default `DATABASE_MODE=local`, it starts and waits for Docker PostgreSQL, applies migrations, and loads local demo users and the problem catalog. With `DATABASE_MODE=remote` (or `--remote-db`), it uses the non-loopback `DATABASE_URL`, skips Docker, applies migrations only, and never automatically seeds users or catalog data. Both modes install locked frontend dependencies when needed, rebuild/restart the API and Vite processes, and wait for both HTTP endpoints.

Yes: `run-local.sh` starts the database dependency, backend, and frontend. To
start again after stopping—or to rebuild/restart after code changes—run the
Expand All @@ -37,7 +37,7 @@ seeds, and npm packages prepared without starting the API or frontend.
| --- | --- |
| Frontend | `http://127.0.0.1:5173` |
| API | `http://127.0.0.1:5050/api` |
| PostgreSQL | `127.0.0.1:5432` |
| PostgreSQL | `127.0.0.1:5432` or the configured managed service |

Runtime logs are under `logs/`; validated PID files are `backend.pid` and `frontend.pid`.

Expand All @@ -49,15 +49,41 @@ Runtime logs are under `logs/`; validated PID files are `backend.pid` and `front
The Makefile exposes the same workflow:

```bash
make setup # dependencies, database, migrations, local seeds
make dev # complete local stack
make setup # dependencies, configured database schema, local-only seeds
make dev # frontend + API using the configured database mode
make stop # application processes only
make stop-all # application + PostgreSQL
make stop-all # application + local Docker PostgreSQL
make check # shell, Go, TypeScript, and Compose checks
make test # Go tests and race detector
make build # API binary and production frontend bundle
```

## Managed PostgreSQL / Aiven

Store the development connection URI in `interviewhub-backend-go/.env.local`, which is ignored by Git and excluded from Docker builds. Never put it in frontend variables, `.env.example`, source code, documentation, or logs. A production deployment must inject `DATABASE_URL` through its secret manager rather than copying an env file into the image.

```dotenv
DATABASE_MODE=remote
DATABASE_URL='postgres://<user>:<url-encoded-password>@<host>:<port>/<database>?sslmode=require'
DATABASE_MAX_OPEN_CONNS=5
DATABASE_MAX_IDLE_CONNS=2
DATABASE_CONN_MAX_LIFETIME=15m
DATABASE_CONN_MAX_IDLE_TIME=5m
```

The URI must stay quoted if it contains `&`, `$`, spaces, or other shell-significant characters. `sslmode=require` encrypts the connection. For server identity verification, download Aiven's project CA into the ignored `interviewhub-backend-go/.local-secrets/` directory and use `sslmode=verify-full&sslrootcert=/absolute/path/to/aiven-ca.pem`.

For a new hosted database, initialize the built-in problem catalog once and deliberately; remote full-stack startup otherwise performs schema migrations only:

```bash
cd interviewhub-backend-go
./scripts/seed-db.sh --skip-migrate
cd ..
./run-local.sh
```

Do not use `--users`, `--force-users`, or `--reset-problems` against the hosted database. `./stop-local.sh --dependencies` never stops or deletes a remote database.

## Repository layout

- `interviewhub-backend-go/`: Go API with `internal/httpapi` as the composition/router boundary, embedded SQL migrations, PostgreSQL adapter, and local execution engine.
Expand Down
20 changes: 18 additions & 2 deletions interviewhub-backend-go/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copy to .env locally; inject real values through your deployment platform.
# Copy to .env.local for local development. Inject real values through your
# deployment platform's secret manager outside local development.
PORT=5050
HOST=127.0.0.1
APP_ENV=development
Expand Down Expand Up @@ -34,10 +35,25 @@ LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
LINKEDIN_REDIRECT_URL=http://localhost:5050/api/auth/oauth/linkedin/callback

# Local development database and queue settings.
# DATABASE_MODE is read only by the repository's run-local.sh. "local" starts
# Docker PostgreSQL; "remote" uses DATABASE_URL and never seeds demo users.
DATABASE_MODE=local

# Host-run API (go run ./cmd/server or ./scripts/seed-db.sh):
DATABASE_URL=postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable
# Docker Compose exposes PostgreSQL only on the loopback interface.
# For Aiven/managed PostgreSQL, keep the URI quoted in .env.local:
# DATABASE_MODE=remote
# DATABASE_URL='postgres://<user>:<url-encoded-password>@<host>:<port>/<database>?sslmode=require'
# Prefer verify-full with the provider CA when available:
# DATABASE_URL='postgres://<user>:<url-encoded-password>@<host>:<port>/<database>?sslmode=verify-full&sslrootcert=/absolute/path/to/aiven-ca.pem'

# Pool sizes are per API replica. Keep total replica usage below the managed
# service limit, leaving headroom for migrations and operator connections.
DATABASE_MAX_OPEN_CONNS=5
DATABASE_MAX_IDLE_CONNS=2
DATABASE_CONN_MAX_LIFETIME=15m
DATABASE_CONN_MAX_IDLE_TIME=5m

EXECUTION_ENABLED=false
EXECUTION_RUNNER=local
Expand Down
25 changes: 24 additions & 1 deletion interviewhub-backend-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The supported full-stack command is run from the repository root:
./run-local.sh
```

It starts loopback-only PostgreSQL, applies embedded migrations, explicitly seeds local demo users and the problem catalog, installs frontend dependencies, and starts the API on `http://127.0.0.1:5050` and Vite on `http://127.0.0.1:5173`. Repeating the command rebuilds and restarts both application processes.
With `DATABASE_MODE=local`, it starts loopback-only PostgreSQL, applies embedded migrations, and explicitly seeds local demo users and the problem catalog. With `DATABASE_MODE=remote` (or `./run-local.sh --remote-db`), it skips Docker and applies migrations without automatic data seeds. Both modes install frontend dependencies and start the API on `http://127.0.0.1:5050` and Vite on `http://127.0.0.1:5173`. Repeating the command rebuilds and restarts both application processes.

For backend-only work:

Expand All @@ -34,6 +34,29 @@ postgres://judgemycode:local-dev-password@127.0.0.1:5432/judgemycode?sslmode=dis

Do not use these credentials outside local development.

### Aiven or another managed PostgreSQL service

Place the service URI only in this directory's Git-ignored `.env.local`:

```dotenv
DATABASE_MODE=remote
DATABASE_URL='postgres://<user>:<url-encoded-password>@<host>:<port>/<database>?sslmode=require'
DATABASE_MAX_OPEN_CONNS=5
DATABASE_MAX_IDLE_CONNS=2
DATABASE_CONN_MAX_LIFETIME=15m
DATABASE_CONN_MAX_IDLE_TIME=5m
```

`DATABASE_MODE` controls only the root development launcher. The Go commands use `DATABASE_URL` directly. Remote URLs must enable TLS; `sslmode=require` is accepted, while Aiven's stronger `verify-full` mode also needs its project CA:

```dotenv
DATABASE_URL='postgres://<user>:<url-encoded-password>@<host>:<port>/<database>?sslmode=verify-full&sslrootcert=/absolute/path/to/aiven-ca.pem'
```

Store the downloaded CA under the ignored `.local-secrets/` directory or outside the repository. In production, mount the CA read-only and inject `DATABASE_URL` through the hosting platform's secret manager.

Remote `run-local.sh` startup applies migrations only and never creates demo users or rewrites catalog data. Initialize the problem catalog once on a fresh development database with `./scripts/seed-db.sh --skip-migrate`. Never use `--users`, `--force-users`, or `--reset-problems` against a hosted database.

## Checks

```bash
Expand Down
5 changes: 4 additions & 1 deletion interviewhub-backend-go/cmd/oauth-bind/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,13 @@ func main() {
if strings.TrimSpace(cfg.DatabaseURL) == "" {
log.Fatal("DATABASE_URL is required")
}
if err := cfg.ValidateDatabase(); err != nil {
log.Fatalf("invalid database configuration: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()

postgres, err := store.OpenPostgres(ctx, cfg.DatabaseURL)
postgres, err := store.NewPostgres(ctx, cfg)
if err != nil {
log.Fatalf("connect: %v", err)
}
Expand Down
5 changes: 4 additions & 1 deletion interviewhub-backend-go/cmd/seed/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,14 @@ func main() {
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required. Example:\n DATABASE_URL='postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable' go run ./cmd/seed")
}
if err := cfg.ValidateDatabase(); err != nil {
log.Fatalf("invalid database configuration: %v", err)
}

ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()

pg, err := store.OpenPostgres(ctx, cfg.DatabaseURL)
pg, err := store.NewPostgres(ctx, cfg)
if err != nil {
log.Fatalf("connect: %v", err)
}
Expand Down
4 changes: 4 additions & 0 deletions interviewhub-backend-go/docs/production-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ and a future isolated judge must enforce independent compile and run budgets.
present, list only its exact networks; forwarded addresses from any other
peer are ignored.
- Use TLS for public traffic and PostgreSQL. Do not use local Compose credentials.
- Size each API replica's PostgreSQL pool with `DATABASE_MAX_OPEN_CONNS`,
`DATABASE_MAX_IDLE_CONNS`, `DATABASE_CONN_MAX_LIFETIME`, and
`DATABASE_CONN_MAX_IDLE_TIME`. The combined replica budget must leave
capacity for migration, monitoring, and operator connections.
- Run `/app/seed --migrate-only` from the backend image as a deployment job before rolling out API replicas. Demo users are opt-in local data and forbidden in production.
- Before enabling OAuth on an upgraded database, use `/app/oauth-bind` from
the backend image (or `go run ./cmd/oauth-bind` from source) to bind
Expand Down
82 changes: 80 additions & 2 deletions interviewhub-backend-go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
Expand All @@ -20,6 +21,10 @@ type Config struct {
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
DatabaseURL string
DatabaseMaxOpenConns int
DatabaseMaxIdleConns int
DatabaseConnMaxLifetime time.Duration
DatabaseConnMaxIdleTime time.Duration
AppEnv string
AllowedOrigins []string
TrustedProxyCIDRs []string
Expand Down Expand Up @@ -82,6 +87,10 @@ func LoadConfig() *Config {
AccessTokenTTL: durationFromEnv("ACCESS_TOKEN_TTL", 15*time.Minute),
RefreshTokenTTL: durationFromEnv("REFRESH_TOKEN_TTL", 30*24*time.Hour),
DatabaseURL: os.Getenv("DATABASE_URL"),
DatabaseMaxOpenConns: intFromEnv("DATABASE_MAX_OPEN_CONNS", 5),
DatabaseMaxIdleConns: intFromEnv("DATABASE_MAX_IDLE_CONNS", 2),
DatabaseConnMaxLifetime: durationFromEnv("DATABASE_CONN_MAX_LIFETIME", 15*time.Minute),
DatabaseConnMaxIdleTime: durationFromEnv("DATABASE_CONN_MAX_IDLE_TIME", 5*time.Minute),
AppEnv: environment(),
AllowedOrigins: csvEnv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173"),
TrustedProxyCIDRs: csvEnv("TRUSTED_PROXY_CIDRS", ""),
Expand Down Expand Up @@ -143,8 +152,8 @@ func (c *Config) Validate() error {
return fmt.Errorf("TRUSTED_PROXY_CIDRS contains invalid CIDR %q", cidr)
}
}
if c.DatabaseURL == "" {
return errors.New("DATABASE_URL is required; all application data is loaded from PostgreSQL (JSON mock store removed)")
if err := c.ValidateDatabase(); err != nil {
return err
}
outsideLocalDevelopment := c.IsProduction() || c.AppEnv == "staging"
if outsideLocalDevelopment && (c.JWTSecretIsDefault || insecureJWTSecret(c.JWTSecret)) {
Expand Down Expand Up @@ -206,6 +215,75 @@ func (c *Config) Validate() error {
return nil
}

// ValidateDatabase checks only persistence configuration so migration and
// operator commands can enforce the same TLS and pool rules without requiring
// unrelated HTTP, JWT, OAuth, or execution settings.
func (c *Config) ValidateDatabase() error {
if strings.TrimSpace(c.DatabaseURL) == "" {
return errors.New("DATABASE_URL is required; all application data is loaded from PostgreSQL (JSON mock store removed)")
}
if err := validateDatabaseURL(c.DatabaseURL); err != nil {
return err
}
if c.DatabaseMaxOpenConns < 1 {
c.DatabaseMaxOpenConns = 5
}
if c.DatabaseMaxIdleConns < 1 {
c.DatabaseMaxIdleConns = 2
}
if c.DatabaseMaxIdleConns > c.DatabaseMaxOpenConns {
return errors.New("DATABASE_MAX_IDLE_CONNS must not exceed DATABASE_MAX_OPEN_CONNS")
}
if c.DatabaseConnMaxLifetime <= 0 {
c.DatabaseConnMaxLifetime = 15 * time.Minute
}
if c.DatabaseConnMaxIdleTime <= 0 {
c.DatabaseConnMaxIdleTime = 5 * time.Minute
}
return nil
}

// validateDatabaseURL prevents a remote PostgreSQL connection from silently
// falling back to an unverified, plaintext-capable SSL mode. Loopback remains
// exempt so the Docker development database can continue using sslmode=disable.
func validateDatabaseURL(databaseURL string) error {
parsed, err := url.Parse(strings.TrimSpace(databaseURL))
if err != nil {
return errors.New("DATABASE_URL must be a valid PostgreSQL connection URI")
}
if parsed.Scheme != "postgres" && parsed.Scheme != "postgresql" {
return errors.New("DATABASE_URL must use the postgres or postgresql scheme")
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return errors.New("DATABASE_URL must include a database hostname")
}
if isLoopbackDatabaseHost(host) {
return nil
}

sslMode := strings.ToLower(strings.TrimSpace(parsed.Query().Get("sslmode")))
switch sslMode {
case "require":
return nil
case "verify-ca", "verify-full":
if strings.TrimSpace(parsed.Query().Get("sslrootcert")) == "" {
return errors.New("DATABASE_URL using sslmode=verify-ca or verify-full must include sslrootcert")
}
return nil
default:
return errors.New("remote DATABASE_URL must set sslmode=require, verify-ca, or verify-full")
}
}

func isLoopbackDatabaseHost(host string) bool {
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

func insecureJWTSecret(secret string) bool {
normalized := strings.ToLower(strings.TrimSpace(secret))
if normalized == "" || normalized == developmentJWTSecret {
Expand Down
Loading
Loading