diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2d33154 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..8d34a01 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,34 @@ +name: Bug report +description: Something in TandemCode does not work as it should. +labels: [bug] +body: + - type: textarea + id: what + attributes: + label: What happened + description: What you did, what you expected, and what you saw instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open a room + 2. ... + validations: + required: true + - type: dropdown + id: where + attributes: + label: Where + options: + - tandemcode.space + - A local checkout + validations: + required: true + - type: textarea + id: details + attributes: + label: Browser, logs, screenshots + description: Browser and OS, any errors from the browser console, and API or runner logs if you run it locally. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9d16cb5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability + url: https://github.com/naman0r/tandemcode/security/advisories/new + about: Please report vulnerabilities privately, not in a public issue. See SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/problem.yml b/.github/ISSUE_TEMPLATE/problem.yml new file mode 100644 index 0000000..8b10e1b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/problem.yml @@ -0,0 +1,31 @@ +name: Problem proposal +description: Suggest a practice problem. If you can write it yourself, CONTRIBUTING.md shows how. +labels: [problem] +body: + - type: input + id: title + attributes: + label: Problem + placeholder: Move Zeroes + validations: + required: true + - type: textarea + id: statement + attributes: + label: Statement + description: The task in your own words, with one example input and output. Do not paste text from other sites. + validations: + required: true + - type: dropdown + id: difficulty + attributes: + label: Difficulty + options: [easy, medium, hard] + validations: + required: true + - type: checkboxes + id: pr + attributes: + label: Contribution + options: + - label: I would like to open the pull request for this myself. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 605dc33..d76edbd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,3 +5,7 @@ ## How this was verified + +## Docs + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4c71fa5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +# Monthly and grouped, so updates arrive as one pull request per ecosystem. +version: 2 +updates: + - package-ecosystem: npm + directory: /apps/web + schedule: + interval: monthly + groups: + web: + patterns: ["*"] + ignore: + # Pinned on purpose. Upgrade it by hand and check the shared editor. + - dependency-name: monaco-editor + - package-ecosystem: pip + directory: /apps/backend + schedule: + interval: monthly + groups: + backend: + patterns: ["*"] + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..c74abe4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,21 @@ +name: Docs + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Relative links only: a moved or deleted file fails the build, and an + # outside site being down does not. + - uses: lycheeverse/lychee-action@v2 + with: + args: --offline --no-progress --exclude-path node_modules './**/*.md' diff --git a/AGENTS.md b/AGENTS.md index 8948527..e7ce195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,17 @@ ## Local checks -- Backend: `cd apps/backend && docker compose up -d db && docker compose run --rm -v "$PWD:/workspace" backend sh -lc 'cd /workspace && HOME=/tmp pip install -r requirements-dev.txt && HOME=/tmp python -m pytest'` -- Web: `cd apps/web && npm run lint && npm run build` +- Everything CI runs: `make check`. `make` lists the other commands. +- Backend only: `make test` (runs pytest in the API image against the compose database). +- Web only: `make lint` (lint and production build). + +## Docs + +- Start at `docs/README.md`. `docs/architecture.md` says where each part of the system lives. +- A change to how something works updates the page that describes it, in the same pull request. +- Docs name the constant or file that holds a value; they do not copy the value. +- `docs/decisions/` records are never edited. A changed decision gets a new record. +- `docs/internal/` is gitignored and never committed. ## Content rules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cfc504..f825145 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,9 @@ # Contributing -Pull requests are welcome. `README.md` explains how to run the app locally and -which checks CI runs; run those before opening a pull request. +Pull requests are welcome. [docs/development.md](docs/development.md) explains +how to run the app locally and make common changes. Run `make check`, which is +what CI runs, before opening a pull request. For anything larger than a small +fix, open an issue first so we can agree on the approach. ## Adding a problem @@ -125,7 +127,7 @@ in your migration, hidden ones included. It fails if: ### 5. Check it -Run the backend checks from `README.md`. To see the problem in the app, +Run `make test`. To see the problem in the app, restart the API so the migration runs: ```bash diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c4921fb --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +# Everyday commands. `make` alone lists them. +BACKEND := apps/backend +WEB := apps/web +COMPOSE := docker compose -f $(BACKEND)/docker-compose.yml + +.DEFAULT_GOAL := help +.PHONY: help setup up down logs web test lint check smoke + +help: ## List the commands + @grep -E '^[a-z]+:.*## ' $(MAKEFILE_LIST) | awk -F ':.*## ' '{ printf " make %-7s %s\n", $$1, $$2 }' + +setup: ## Copy the env templates and install web dependencies + @test -f $(BACKEND)/.env || cp $(BACKEND)/.env.example $(BACKEND)/.env + @test -f $(WEB)/.env || cp $(WEB)/.env.example $(WEB)/.env + cd $(WEB) && npm install + @echo "Now fill in DB_PASSWORD and the Clerk keys in $(BACKEND)/.env and $(WEB)/.env" + +up: ## Start Postgres, the API and the runner + $(COMPOSE) up -d --build + +down: ## Stop them + $(COMPOSE) down + +logs: ## Follow the API and runner logs + $(COMPOSE) logs -f backend runner + +web: ## Run the web app at http://localhost:5173 + cd $(WEB) && npm run dev + +test: ## Backend tests, in the API image against the compose database + $(COMPOSE) up -d db + $(COMPOSE) run --rm -v "$(CURDIR)/$(BACKEND):/workspace" backend sh -lc 'cd /workspace && HOME=/tmp pip install -q -r requirements-dev.txt && HOME=/tmp python -m pytest' + +lint: ## Web lint and production build + cd $(WEB) && npm run lint && npm run build + +check: test lint ## Everything CI runs + +smoke: ## Check a deployment: make smoke [SITE=...] [API=...] + ./infra/smoke.sh $(or $(SITE),https://tandemcode.space) $(or $(API),https://api.tandemcode.space) diff --git a/README.md b/README.md index 7279e01..e9360ea 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,72 @@ -# TandemCode - -Real-time pair programming with a judge. Two people share a room, edit the same -code in a Monaco editor, chat, pick a problem, and run their solution against -hidden tests. - -## What works today - -- Clerk sign-in. Every API route and both websockets verify a session token. -- Rooms: create, join by id, presence, chat, owner assigns a problem. -- Shared editor: Yjs over a websocket relay, one document per room. -- Problems: statement, starter code, public samples, hidden tests, for all - fifteen seeded problems. -- Run: a runner process judges Python submissions against the tests with time - and memory limits and the room shows the verdict and the first failing test. - -## What does not exist yet - -- A queue or separate judge hosts. The runner takes work from the submissions - table on the API host. See #33. -- Complexity estimates, counterexamples, languages other than Python. - -## Stack - -| Piece | Choice | -| --- | --- | -| Web | React 19, Vite, TypeScript, Tailwind, Monaco, Yjs, Clerk | -| API | FastAPI on Python 3.11, asyncpg, raw JSON over websockets | -| Runner | Same image as the API, `python -m app.runner` | -| Database | Postgres 17 in production and CI (13 in the local compose), forward-only SQL migrations in `apps/backend/migrations` | -| CI | pytest with a Postgres service; web lint and build | - -## Layout - -``` -apps/ - backend/ - app/ - core/ settings, Clerk token verification - routes/ HTTP endpoints - services/ rules, take an authenticated caller id - dao/ all SQL - websocket/ room chat and Yjs relay - runner/ judge and the polling worker - migrations/ V__.sql - tests/ - web/ - src/ - routes/ pages: /, /dashboard, /rooms, /rooms/create, /rooms/join, /rooms/:id, /problems - components/ editor, chat, members, header - hooks/ room websocket - lib/ API client, auth, config -infra/ production compose, Caddy, host scripts -``` - -Requests flow routes to services to dao. Authentication happens at the HTTP -and websocket boundary; services never see a token. +

+ + TandemCode logo + +

+ +

TandemCode

+ +

+ Practice coding problems with a partner.
+ One shared editor, one judge, and a replay of the whole session. +

+ +

+ tandemcode.space +  ·  + Docs +  ·  + Add a problem +

+ +

+ Site status + API status + Vercel deployment + Backend tests + Web checks + License +

+ +Two people share a room, edit the same code with live cursors, chat, and run their solution against the problem's tests together. Everyone in the room sees the verdict, and the session can be replayed afterwards. TandemCode is free and open source under the Apache License 2.0. + +## What it does + +- Rooms that are public or unlisted, shared by invite link, with a way to ask for a partner. +- One shared editor with live cursors, built on Yjs. +- Problems with starter code, visible examples and hidden tests, added by pull request. +- Python submissions judged in an isolated container per run, with the verdict shown to everyone in the room. +- A replay of each session: the code as it was typed, the chat and every run. ## Run it locally -You need Docker, Node 20.19 or later, and a Clerk application. - -```bash -cd apps/backend -cp .env.example .env # set DB_PASSWORD, CLERK_ISSUER, CLERK_SECRET_KEY -docker compose up -d --build # db on 5433, api on 8080, runner - -cd ../web -cp .env.template .env # set VITE_CLERK_PUBLISHABLE_KEY -npm install -npm run dev # http://localhost:5173 -``` - -Migrations run when the API starts. The runner waits for the API's health -check, so it never sees a half-migrated schema. - -To serve the built web app from nginx instead of Vite, add -`VITE_CLERK_PUBLISHABLE_KEY` to apps/backend/.env and run -`docker compose --profile prod up -d --build`. The app is on port 3000. - -To run the API outside Docker instead: - -```bash -cd apps/backend -python3.11 -m venv .venv && .venv/bin/pip install -r requirements.txt -.venv/bin/uvicorn app.main:app --port 8080 --reload -.venv/bin/python -m app.runner -``` - -## Checks +You need Docker, Node 22 and a free Clerk application. ```bash -cd apps/backend && docker compose run --rm -v "$PWD:/workspace" backend \ - sh -lc 'cd /workspace && HOME=/tmp pip install -r requirements-dev.txt && HOME=/tmp python -m pytest' -cd apps/web && npm run lint && npm run build +make setup # copy the .env templates, install web dependencies + # then fill in the Clerk keys and a DB password +make up # Postgres, the API and the runner +make web # http://localhost:5173 ``` -Tests use a `tandemcode_test` database that is dropped and recreated per run. - -## Judge contract - -A problem's tests are `[{"input", "expected", "hidden"}]`. The program reads -stdin and prints; a test passes when trimmed stdout equals the trimmed -expected string. The runner stops at the first failure. Statuses are -`accepted`, `wrong_answer`, `runtime_error` and `time_limit_exceeded`. The -judge is a pure function in `apps/backend/app/runner/judge.py`, so a hosted -runner can call the same thing. +[docs/development.md](docs/development.md) has the details, the checks and the common changes. -## Sandbox - -With `SANDBOX_IMAGE` set, as it is in docker compose, the runner judges each -submission in a fresh container from that image: no network, read-only root, -a 64 MB `/tmp`, uid 65534, all capabilities dropped, a memory cap and 32 -processes. Without it the runner refuses to start, unless `ALLOW_UNSANDBOXED=1` -is set, in which case it judges in-process with rlimits only: fine for tests, -not for strangers' code. Output of hidden tests is never returned. - -Containers share the host kernel, so one kernel bug is enough to escape them. -With `SANDBOX_RUNTIME=runsc` each judge container runs under gVisor, which -handles the program's system calls in its own user-space kernel; production -sets it, and `infra/install-gvisor.sh` installs it on an Ubuntu host. +## Stack -The runner reaches Docker through the host's socket, so the runner itself is -as trusted as the host. Keep it on a machine that runs nothing else. +- Web: React 19, Vite, TypeScript, Tailwind, Monaco, Yjs, Clerk. +- API: FastAPI on Python 3.11 with asyncpg, HTTP plus two websockets. +- Runner: the API's image running `python -m app.runner`, judging in Docker containers under gVisor. +- Database: Postgres 17 with forward-only SQL migrations. +- Production: one Linux host with Docker Compose and Caddy, and the web app on Vercel. -## Deploy +## Docs -`infra/` runs the API, runner, Postgres and Caddy on one Docker host with -`docker compose -f infra/docker-compose.prod.yml up -d --build`; settings go in -`infra/.env` (see `infra/.env.example`). The web app is a static Vite build, -with `apps/web/vercel.json` for Vercel. +- [Development](docs/development.md) +- [Architecture](docs/architecture.md) +- [Judge and sandbox](docs/judge-and-sandbox.md) +- [Self-hosting](docs/self-hosting.md) +- [Decisions](docs/decisions/) ## Contributing -`CONTRIBUTING.md` covers pull requests and how to add a problem. `AGENTS.md` -has the rules for agents and the same checks as above. +Pull requests are welcome, and adding a problem is the easiest place to start. [CONTRIBUTING.md](CONTRIBUTING.md) explains both. Report security issues privately as described in [SECURITY.md](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9386d7f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security + +## Reporting a vulnerability + +Report it privately through GitHub: open the repository's Security tab and choose "Report a vulnerability". Please do not open a public issue or pull request for it. Include what you found, how to reproduce it, and what it lets someone do. + +Only tandemcode.space and the latest `main` are supported. Fixes land on `main` and are deployed from there. + +## In scope + +- Getting out of the judge's sandbox, or running code on the host. +- Signing in as someone else, or acting as another user. +- Reading or changing a room, its runs or its chat without having been in it. +- Taking the site down, or running up its hosting bill, from one account. + +The protections are described in [docs/judge-and-sandbox.md](docs/judge-and-sandbox.md) and [docs/architecture.md](docs/architecture.md). + +## Not a vulnerability + +- Reading a problem's hidden tests. They are public in the migrations that add them. +- Findings that need access to the host, the database or a maintainer's account. + +Please test against your own local copy rather than tandemcode.space, and never against other people's rooms or accounts. diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index 1a0517d..711c49a 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -2,7 +2,8 @@ # ${VAR:?...} makes Compose fail loudly instead of substituting an empty string. services: db: - image: postgres:13 + # The same major version as production and CI. + image: postgres:17 container_name: tandemcode-db environment: POSTGRES_DB: ${DB_NAME:?missing DB_NAME - copy .env.example to .env} @@ -100,4 +101,7 @@ services: volumes: db-data: - name: tandemcode-db-data + # Postgres cannot open another major version's data files, so the volume + # is named for the version. An old tandemcode-db-data volume is unused and + # can be removed with `docker volume rm tandemcode-db-data`. + name: tandemcode-db-data-17 diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..c623c47 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,10 @@ +# Template. Copy to .env and fill in the key: +# cp .env.example .env +# .env is gitignored. Both values end up in the browser bundle, so neither is +# a secret. + +# Clerk dashboard -> API keys -> Publishable key. +VITE_CLERK_PUBLISHABLE_KEY= + +# Where the browser reaches the API. Websocket URLs are derived from it. +VITE_BACKEND_URL=http://localhost:8080 diff --git a/apps/web/.env.template b/apps/web/.env.template deleted file mode 100644 index 5142367..0000000 --- a/apps/web/.env.template +++ /dev/null @@ -1 +0,0 @@ -VITE_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY \ No newline at end of file diff --git a/apps/web/.nvmrc b/apps/web/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/apps/web/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a4035bf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# Docs + +Start with the page for what you are doing. + +- [Development](development.md): run TandemCode locally, run the checks, and make common changes. +- [Architecture](architecture.md): the parts of the system, how a request and a run move through them, and where each piece lives in the code. +- [Judge and sandbox](judge-and-sandbox.md): how submissions are judged and what isolates them. +- [Self-hosting](self-hosting.md): run your own instance on one Linux host. +- [Decisions](decisions/): why the system is shaped the way it is. + +Adding a problem is in [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Keeping these pages current + +These pages go stale when they copy what the code already says. So they follow a few rules: + +1. Explain what and why. Leave exact values in the code. A page names the constant or file that holds a limit, port or timeout (`MAX_SOCKETS_PER_USER` in `app/websocket/room_chat.py`), and does not repeat its value. +2. Link to files and directories, not line numbers. +3. Change a page in the same pull request as the behaviour it describes. The pull request template asks. +4. Decision records are written once and not edited. When a decision changes, add a new record that replaces the old one, and mark the old one as replaced. +5. CI checks every relative link in the repository's Markdown, so a moved or deleted file fails the build. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ed0d276 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,58 @@ +# Architecture + +TandemCode has five parts: + +- The web app (`apps/web`), a React single-page app built with Vite. In production it is static files on Vercel. +- The API (`apps/backend/app`), a FastAPI process that serves HTTP under `/api` and two websockets under `/ws`. +- The runner (`apps/backend/app/runner`), a separate process from the same image that judges submissions. +- Postgres, which holds everything: users, rooms, chat, editor history, problems and submissions. +- Clerk, which signs people in. TandemCode stores no passwords. + +In production Caddy sits in front of the API and terminates TLS. See [self-hosting](self-hosting.md). + +## Signing in + +The browser signs in with Clerk and gets a short-lived session token. It sends that token as a bearer header on HTTP calls, and as a `token` query parameter when it opens a websocket, because browsers cannot set headers on websockets. + +The API verifies the token at the edge and nowhere else: `app/core/auth.py` checks the signature against Clerk's keys, the issuer and the origin that requested it. Routes and websocket handlers turn it into a user id, and everything past that point works with the id. A user's name and email come from Clerk's API (`app/core/clerk.py`), never from the browser. + +## A request + +HTTP requests go from `app/routes/` to `app/services/` to `app/dao/`. Routes parse input and authenticate. Services hold the rules: who may read a room, who may change its problem, how many rooms or runs a user may start per hour. DAOs hold all the SQL, written by hand for asyncpg. Rate limits that must hold under parallel requests are enforced inside a transaction in the DAO, not by counting first and inserting later. + +## A room + +A room has two websockets, and the web app opens one of each: + +- `/ws/room/{id}` (`app/websocket/room_chat.py`) carries presence, chat, submission updates and problem changes as JSON. Joining it is what puts you on the room's roster. The server stamps every chat message with the sender's identity and time; the client sends only text. +- `/ws/yjs/{id}` (`app/websocket/yjs.py`) carries the shared editor. The document is a [Yjs](https://yjs.dev) CRDT, and the server does not hold a copy. It relays each frame from one peer to the others and answers for an empty document when someone is alone. It checks each frame's framing and rate before relaying it, and it records document changes in `room_updates` for the replay. + +Sends to many sockets go through `app/websocket/fanout.py`, which sends to each in parallel with a deadline, so one peer that stops reading cannot hold up the room. Per-user socket caps, frame budgets and chat limits are constants at the top of `room_chat.py` and `yjs.py`. + +Rooms are public or unlisted, and the owner can ask for a partner, which highlights the room on the rooms page. Reading a room's runs, roster or replay requires having been in it. + +## A run + +1. `POST /api/submissions` stores the code as a pending submission and tells the room that a run started. +2. The runner (`app/runner/__main__.py`) claims the oldest pending submission, judges it (see [judge and sandbox](judge-and-sandbox.md)), and stores the verdict. +3. Storing a verdict sends a Postgres `NOTIFY`. The API listens for it (`app/websocket/verdicts.py`) and broadcasts the verdict to the room. + +The runner polls for work and handles one submission at a time. A submission left running by a runner that died is put back in the queue when a runner starts. + +## Replay + +When a room closes, its history stays. `GET /api/rooms/{id}/replay` returns the recorded editor changes, the chat and the runs, and the web app (`apps/web/src/routes/rooms/Replay.tsx`) plays them back. Each room has a recording budget (`MAX_RECORDED_BYTES_PER_ROOM` in `yjs.py`), after which it keeps relaying but stops recording. + +## Data + +The schema is the SQL in `apps/backend/migrations/`, applied in order by `app/migrate.py` when the API starts. Problems are rows too. Each one is added by a migration, which is why contributing a problem is a pull request. + +## Where to change what + +- A page or component: `apps/web/src/routes/`, `apps/web/src/components/`. +- The API client and websocket URLs: `apps/web/src/lib/api.ts`, `apps/web/src/lib/config.ts`. +- An endpoint's rules: `apps/backend/app/services/`. +- A query: `apps/backend/app/dao/`. +- Realtime behaviour: `apps/backend/app/websocket/`. +- Judging: `apps/backend/app/runner/`. +- Production: `infra/`. diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000..44aa38d Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/decisions/0001-one-host-for-the-backend.md b/docs/decisions/0001-one-host-for-the-backend.md new file mode 100644 index 0000000..7e30064 --- /dev/null +++ b/docs/decisions/0001-one-host-for-the-backend.md @@ -0,0 +1,18 @@ +# 0001. One host for the backend + +Status: accepted, 2026-09-23 + +## Context + +The runner starts a container for every submission, so it needs a Docker daemon. The managed container platforms considered do not give a container access to one. The project also has no budget beyond a few dollars a month, and a launch on public forums can bring bursts of traffic. + +## Decision + +The API, the runner, Postgres and Caddy run with Docker Compose on one Linux host (`infra/docker-compose.prod.yml`). tandemcode.space uses a 2 GB AWS Lightsail instance, which has a fixed monthly price with transfer included. The web app is a static build on Vercel, which deploys every push to `main`. The API deploys by hand with `infra/deploy.sh`. + +## Consequences + +- The whole backend costs one flat monthly price, and nothing in it scales its bill with traffic. +- Moving hosts is a dump, a restore and a DNS change, because the host runs nothing but Compose. +- One host is one point of failure. Automatic disk snapshots and nightly dumps cover data loss, not downtime. +- The runner shares a machine with the API, so a heavy judging load slows the API. Separate judge hosts are the next step if that happens. diff --git a/docs/decisions/0002-a-container-per-run.md b/docs/decisions/0002-a-container-per-run.md new file mode 100644 index 0000000..abaf49f --- /dev/null +++ b/docs/decisions/0002-a-container-per-run.md @@ -0,0 +1,18 @@ +# 0002. A container per run + +Status: accepted, 2026-09-23 + +## Context + +Anyone can sign up and submit code, so every submission may be hostile. Resource limits inside the runner's own process stop runaway programs but not a program that reads files or opens connections. + +## Decision + +Each submission is judged in a new container with no network, a read-only filesystem, an unprivileged user and memory and process caps (`app/runner/sandbox.py`). In production the container runs under gVisor, so the program's system calls go to gVisor's kernel rather than the host's. + +## Consequences + +- Getting out of the sandbox takes a gVisor bug and then a kernel bug. +- Starting a container adds time to every run. +- The runner holds the host's Docker socket, so it is as trusted as the host. The host runs nothing else. +- The runner judges one submission at a time. More runners, or runners on other hosts, need a change to how work is claimed. diff --git a/docs/decisions/0003-the-server-relays-the-editor.md b/docs/decisions/0003-the-server-relays-the-editor.md new file mode 100644 index 0000000..2324f0f --- /dev/null +++ b/docs/decisions/0003-the-server-relays-the-editor.md @@ -0,0 +1,18 @@ +# 0003. The server relays the editor + +Status: accepted, 2026-09-23 + +## Context + +Two people edit the same code. Yjs merges concurrent edits in each browser, and it only needs a way to pass messages between them. + +## Decision + +The editor websocket (`app/websocket/yjs.py`) forwards Yjs messages between the people in a room and does not hold the document. It checks each message's framing and rate before forwarding it, answers for an empty document when someone is alone, and records document changes so a room can be replayed. + +## Consequences + +- The server keeps no per-room document in memory and needs no Yjs implementation of its own. +- The live document exists only in the browsers in the room. +- The server cannot check what a change does to the document, only that the message is well formed and within its limits. +- Anything that needs the current code on the server, such as running it without the browser sending it, would need the server to hold the document. diff --git a/docs/decisions/0004-problems-are-migrations.md b/docs/decisions/0004-problems-are-migrations.md new file mode 100644 index 0000000..7a02723 --- /dev/null +++ b/docs/decisions/0004-problems-are-migrations.md @@ -0,0 +1,18 @@ +# 0004. Problems are migrations + +Status: accepted, 2026-09-23 + +## Context + +People outside the project should be able to add problems, and a problem with a wrong test is worse than no problem. + +## Decision + +A problem is one SQL migration that inserts it and one reference solution in `apps/backend/tests/solutions/`. It arrives as a pull request. CI judges the reference solution against every test, and checks that the migration is exactly one `INSERT` of the expected shape, because migrations run on deploy. [CONTRIBUTING.md](../../CONTRIBUTING.md) is the guide. + +## Consequences + +- Every problem is reviewed, and its tests are proven by a solution that passes them. +- Every environment gets the same problems by running the same migrations. +- Hidden tests are public, in the migrations. They keep examples short, but they do not stop someone who reads the repository. +- Changing a problem after it merges takes a new migration. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..55ffe91 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,30 @@ +# Decisions + +Each record explains one choice that shapes the system, and why. Records are not edited after they are merged. When a decision changes, add a new record, and change the old one's status line to `Replaced by NNNN` so the history stays readable. + +- [0001](0001-one-host-for-the-backend.md): the API, runner and database share one host; the web app is hosted separately +- [0002](0002-a-container-per-run.md): each submission runs in its own container, under gVisor in production +- [0003](0003-the-server-relays-the-editor.md): the server relays the shared editor and keeps no copy of it +- [0004](0004-problems-are-migrations.md): problems are added by migration, through pull requests + +## Writing one + +Copy this into `NNNN-short-title.md`, numbered after the last record: + +```markdown +# NNNN. Title + +Status: accepted, YYYY-MM-DD + +## Context + +What forced a choice. + +## Decision + +What was chosen. + +## Consequences + +What this makes easy, what it makes hard, and what would make it worth revisiting. +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..bcc5403 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,80 @@ +# Development + +## What you need + +- Docker with Compose v2. Postgres, the API and the runner run in containers, and the runner starts one more container per submission. +- Node 22, the version in `apps/web/.nvmrc` (`nvm use` in `apps/web` picks it up). +- `make`, which macOS and most Linux distributions already have. +- A Clerk application. The free development instance is enough, and in development Clerk supplies the credentials for social sign-in itself. + +## First run + +```bash +make setup # copies both .env templates, installs web dependencies +``` + +Then fill in the two `.env` files it created: + +- `apps/backend/.env`: any `DB_PASSWORD`, `CLERK_ISSUER` (the Frontend API URL under API keys in the Clerk dashboard) and `CLERK_SECRET_KEY`. +- `apps/web/.env`: `VITE_CLERK_PUBLISHABLE_KEY` from the same Clerk instance. + +```bash +make up # Postgres, the API and the runner +make web # the web app, at http://localhost:5173 +``` + +The API applies migrations when it starts, and the runner waits for the API's health check, so the runner never reads a half-migrated schema. On its first start the runner pulls the image it judges submissions in, which takes a minute. + +To try a room with two people, sign in with a second Clerk account in a private window or another browser profile. + +`make` on its own lists every command. The ports are 5173 for the web app, 8080 for the API and 5433 for Postgres, which listens on loopback only. + +## Checks + +```bash +make check # backend tests, web lint, web build +``` + +This is what CI runs (`.github/workflows/`), plus a check that every relative link in the Markdown files resolves. Backend tests run inside the API image against the compose database, in a `tandemcode_test` database that is dropped and recreated on each run. + +## Common changes + +### A schema change + +Add `apps/backend/migrations/V__.sql`, numbered after the highest existing file. Migrations only go forward. Never edit one that has been merged, because production has already applied it; write a new one instead. The API applies pending migrations on start (`app/migrate.py`), and the test suite applies all of them to an empty database, so a broken migration fails CI. + +### An HTTP endpoint + +Requests go from `app/routes/` to `app/services/` to `app/dao/`: + +- The route authenticates with `Depends(current_user_id)` from `app/dependencies.py` and passes the caller's id on. +- The service applies the rules, such as who may do what and rate limits. It receives a user id, never a token. +- The DAO holds the SQL. SQL does not appear anywhere else. + +### A room event + +The server sends events to everyone in a room with `RoomChatManager.broadcast` in `app/websocket/room_chat.py`. The web app handles them in `apps/web/src/hooks/UseWebSocket.ts`. There is one room socket per room: `RoomView` opens it and passes its state to the components that need it. + +### A problem + +See [CONTRIBUTING.md](../CONTRIBUTING.md). A problem is one migration and one reference solution, and CI judges the solution against the problem's tests. + +## Without Docker + +The API and the runner can run from a virtualenv against the compose database: + +```bash +cd apps/backend +python3.11 -m venv .venv && .venv/bin/pip install -r requirements.txt +.venv/bin/uvicorn app.main:app --port 8080 --reload +.venv/bin/python -m app.runner +``` + +The runner still needs Docker to judge submissions. `ALLOW_UNSANDBOXED=1` makes it judge in its own process instead, which is fine for your own code and never for anyone else's. + +## When something goes wrong + +- The runner exits with "SANDBOX_IMAGE is not set". Compose sets it; outside compose, set `SANDBOX_IMAGE=python:3.11-slim`. +- Every API call returns 401. `CLERK_ISSUER` in `apps/backend/.env` and the publishable key in `apps/web/.env` must come from the same Clerk instance. +- The editor reconnects once when a room opens. In development React's strict mode opens each socket twice, and the per-user socket caps in `app/websocket/room_chat.py` can refuse the extra one until the first closes. Production builds open each socket once. +- Your local rooms and users are gone after pulling. The Postgres major version changed. The database volume is named after that version (see `apps/backend/docker-compose.yml`), so a new version starts from an empty database instead of failing on the old data files. The old volume is still there; remove it with `docker volume rm ` once you no longer need it. diff --git a/docs/judge-and-sandbox.md b/docs/judge-and-sandbox.md new file mode 100644 index 0000000..1f37ba0 --- /dev/null +++ b/docs/judge-and-sandbox.md @@ -0,0 +1,33 @@ +# Judge and sandbox + +## The judge + +A problem's tests are a JSON list of `{"input", "expected", "hidden"}`. A submission is a Python program: it reads a test's `input` on stdin and prints its answer. A test passes when the program exits cleanly and its stdout, trimmed at both ends, equals the trimmed `expected`. + +Tests run in order and judging stops at the first failure. The verdict is one of `accepted`, `wrong_answer`, `runtime_error` or `time_limit_exceeded`, with the time taken and a result for each test that ran. For a hidden test the result says only whether it passed: its input, expected output and the program's output are never returned, since a failing program's output can echo the test. + +Hidden tests keep the examples short and stop people writing code for the visible cases only. They are not secret: the migrations that add them are in this repository. + +Each problem sets its own time and memory limit per test. Migration V12 bounds what a problem may ask for. + +The judge itself is `app/runner/judge.py`. It knows nothing about the database, so it can be tested and reused on its own. + +## The sandbox + +People submit code they wrote a minute ago, and anyone can sign up, so every submission is treated as hostile. With `SANDBOX_IMAGE` set, the runner (`app/runner/sandbox.py`) judges each submission in a new container from that image, removed afterwards. The container: + +- has no network +- has a read-only root filesystem, with a small writable `/tmp` +- runs as an unprivileged user with every capability dropped and no way to gain privileges +- has a memory cap, with no swap beyond it, and a limit on processes +- is killed if the whole run outlives its time budget + +Containers share the host's kernel, so a single kernel bug could let a program out. In production each container therefore runs under [gVisor](https://gvisor.dev) (`SANDBOX_RUNTIME=runsc`), which answers the program's system calls in its own kernel written in Go. Getting out then takes a gVisor bug and a kernel bug. `infra/install-gvisor.sh` installs it. + +The runner starts containers through the host's Docker socket, which makes the runner as trusted as the host. Run it on a machine that runs nothing but TandemCode. + +Without `SANDBOX_IMAGE`, the runner refuses to start unless `ALLOW_UNSANDBOXED=1` is set. Then it judges in its own process with resource limits only, which the test suite uses and nothing else should. + +## Reporting a problem with it + +A way out of the sandbox, or a way to see or change other people's runs, is a security issue. Report it privately as described in [SECURITY.md](../SECURITY.md). diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..b5c3407 --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,56 @@ +# Self-hosting + +tandemcode.space runs this way, and you can run your own copy the same way. The API, the runner, Postgres and Caddy share one Linux host. The web app is static files on any host that can send every path to `index.html`. + +## What you need + +- A host running Ubuntu 24.04 with at least 2 GB of memory, reachable on ports 22, 80 and 443. tandemcode.space uses an AWS Lightsail instance. Use a machine that runs nothing else, because the runner controls its Docker daemon (see [judge and sandbox](judge-and-sandbox.md)). +- A domain, with an `A` record for the API's hostname pointing at the host. +- A Clerk production instance for that domain. Clerk lists the DNS records it needs. Social sign-in in production needs your own OAuth credentials for each provider. +- A static host for the web app. `apps/web/vercel.json` configures Vercel, including the security headers. `apps/web/nginx.conf` serves the app from nginx with a shorter set of headers, and has no Content Security Policy beyond refusing to be framed. + +## The API host + +```bash +git clone https://github.com/naman0r/tandemcode.git && cd tandemcode +./infra/bootstrap.sh +``` + +`bootstrap.sh` installs Docker, swap, gVisor and a nightly database dump, then writes `infra/.env` with generated database passwords. Log out and back in once so your user can reach Docker. If the script stopped at its gVisor check the first time, run it again after logging back in. It is safe to rerun. + +Fill in the rest of `infra/.env`. `infra/.env.example` explains each setting. Then start everything: + +```bash +./infra/deploy.sh +``` + +Caddy gets a TLS certificate for `API_DOMAIN` on its own once DNS points at the host. `curl https:///health` should print `{"status":"ok"}`. + +## The web app + +Build `apps/web` with two variables set: `VITE_BACKEND_URL`, the API's `https://` origin, and `VITE_CLERK_PUBLISHABLE_KEY`. Both are baked into the bundle and neither is secret. The site's origins must be listed in `CORS_ORIGINS` in `infra/.env`, because the API refuses tokens minted for any other origin. + +## Checking a deployment + +```bash +make smoke SITE=https:// API=https:// +``` + +This checks that the API is up and refuses anonymous calls and foreign origins, and that the site serves the app with its security headers. Then sign in, open a room with a second account in a private window, and run a solution. + +## Shipping changes + +The web app and the API ship separately: + +- The web app. On Vercel, every push to `main` builds and deploys it. +- The API and runner. Run `./infra/deploy.sh` on the host. It pulls `main`, dumps the database, and rebuilds whatever changed. The API applies new migrations as it starts, and migrations cannot be undone, which is why the dump comes first. + +When a change touches both, deploy the API first, so the new web app never talks to an old API. + +## Backups + +`infra/backup.sh` runs nightly from cron and before every deploy. It writes a gzipped `pg_dump` to `/var/backups/tandemcode` and keeps two weeks of them. Those dumps are on the same disk as the database, so also copy the disk off the host; on Lightsail, turn on automatic snapshots. The restore steps are at the top of `infra/backup.sh`. + +## Moving hosts + +Bootstrap the new host, restore the latest dump into it, run `deploy.sh`, and point the API's `A` record at the new address.