Skip to content

Contributing

Contributions are welcome. This guide covers local development setup for both the backend and frontend.


Tool Version Install
Rust 1.91.1+ (stable) https://rustup.rs/
Node.js 22 (project and CI job runtime) https://nodejs.org/
pnpm 10.28.2 (root package and CI) corepack enable
Docker + Compose v2 latest https://docs.docker.com/get-docker/

Terminal window
git clone https://github.com/kutupbt/kutup.git
cd kutup
cp .env.example .env
# Fill in required values — see README for the configuration table

2. Start infrastructure (database + storage)

Section titled “2. Start infrastructure (database + storage)”

The easiest approach is to run the full stack and then replace only the service you’re working on:

Terminal window
docker compose up -d --build

For faster iteration, you can run just the infrastructure services and run the backend/frontend natively:

Terminal window
docker compose up -d postgres seaweedfs-master seaweedfs-volume seaweedfs-filer seaweedfs-s3 seaweedfs-init

The backend is a Rust application — crates/kutup-server (Axum + sqlx + aws-sdk-s3) in the root Cargo workspace. It shares the E2EE primitives with the CLI via crates/kutup-crypto.

Terminal window
# Export env vars (or use a tool like direnv)
export DATABASE_URL="postgres://kutup:<POSTGRES_PASSWORD>@localhost:5432/kutup?sslmode=disable"
export JWT_SECRET="<your-jwt-secret-32+chars>"
export S3_ENDPOINT="http://localhost:8333"
export S3_ACCESS_KEY="kutup"
export S3_SECRET_KEY="<your-s3-secret>"
export S3_BUCKET="kutup-files"
export S3_REGION="us-east-1"
export APP_ENV="development"
cargo run -p kutup-server # or: cargo build --release -p kutup-server

The backend starts on http://localhost:3000. The binary also has an orphan-sweep subcommand (cargo run -p kutup-server -- orphan-sweep [--delete]) for GC’ing orphaned S3 blobs.

You need to expose the SeaweedFS S3 port to the host. Add ports: ["8333:8333"] to the seaweedfs-s3 service in docker-compose.yml temporarily for local dev.

Migrations live in crates/kutup-server/migrations/ (<N>_<name>.up.sql / .down.sql — sqlx’s reversible format) and are embedded into the binary at compile time via sqlx::migrate!(), then applied automatically on startup.

To add a migration, create the pair by hand (or with the sqlx CLI):

Terminal window
cargo install sqlx-cli --no-default-features --features postgres # one-time
sqlx migrate add -r <migration_name> --source crates/kutup-server/migrations

Write the forward migration in .up.sql and the rollback in .down.sql. Because migrations are embedded at compile time, rebuild the server after adding one.

The server generates its OpenAPI document with utoipa and serves the machine-readable JSON at GET /api-docs/openapi.json. Every HTTP operation has a path annotation, and a coverage test keeps the generated route set aligned with the Axum router. An interactive Swagger UI is deferred (see docs/roadmap.md).

Terminal window
cargo test # all crates
cargo test -p kutup-crypto # crypto byte-parity vectors
cargo clippy --all-targets -- -D warnings # lints (gate)
cargo fmt --check # formatting (gate)
python3 scripts/test-check-docs.py # checker regression tests
python3 scripts/check-docs.py # Markdown links and referenced paths
docker run --rm -v "$PWD:/repo" -w /repo \
rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 \
-no-color # GitHub workflow lint
./scripts/audit-unified-federation.sh # no feature-owned federation stack
./scripts/test-chat-backup-integration.sh # real Postgres/SeaweedFS backup lifecycle
./scripts/test-chat-federation.sh # full two-server API + browser security/recovery gate
./scripts/dev-chat-federation-up.sh # leave an MLS-enabled two-server stack running

The backup integration script owns a disposable Compose project and covers the HTTP lifecycle, fixed-cutoff mailbox/media retention, account purge, object cleanup, and exact Chat-byte release. It tears down its Postgres and SeaweedFS state even on failure.

The federation harness uses its own Compose project, two tmpfs Postgres databases, and host ports 39081/39082. Despite its historical filename, it is the unified Chat + Drive gate: Drive establishes the first peer pin, Chat must reuse that same identity and policy, and the suite checks the shared admin evidence/retry/audit control plane. It also covers the Drive share lifecycle, Chat delivery and durable retry, the global emergency stop, all four admission modes and directional domain rules independently for both features, and disabled feature capabilities. Its Playwright phase also covers Direct and MLS security, media, clean two-account browser-loss recovery, lazy protected-media restore, and fresh post-restore protocol state. It tears the topology down on exit and does not touch the ordinary development stack. Set KUTUP_FEDERATION_SKIP_BUILD=1 only when reusing an image already built from the current checkout.

Run these local gates before requesting or rerunning GitHub CI. CI is final confirmation, not the first debugging environment. See tests/e2e/README.md for the single-server recovery command, TLS prerequisite, zero-retry policy, and sanitized failure artifacts.

Markdown-only and docs/**-only pull requests/pushes run the lightweight Documentation workflow instead of the Rust, WASM, frontend, integration, and browser matrix. It validates local links, referenced script/workflow/spec paths, changed-file whitespace, and Compose parsing. A mixed documentation-and-code change still runs the complete CI workflow as well as documentation checks.

Changes confined to .github/workflows/** run the separate lightweight Workflow validation workflow. It exercises actions/checkout@v7, pnpm/action-setup@v6, actions/setup-node@v7, and actions/upload-artifact@v7 with the project’s Node 22 and pnpm 10.28.2, then runs the digest-pinned actionlint image. These action majors use the Node 24 GitHub Actions runtime; that implementation detail does not change the Node 22 version used to build and test Kutup. Workflow-only changes skip the complete CI matrix, while a mixed workflow-and-application change still runs it.

For manual browser testing, use ./scripts/dev-chat-federation-up.sh instead of invoking docker-compose.chat-federation.yml directly. The helper starts the same real two-server topology, completes the authenticated admin bootstrap, and changes both Chat federation policies from their fail-closed allowlist default to open. It is repeatable while its tmpfs databases remain running. The script prints both URLs and the development-only admin credentials. Override KUTUP_FEDERATION_PROJECT, KUTUP_FED_A_PORT, and KUTUP_FED_B_PORT to run an additional isolated topology.

The intentionally breaking Chat and Drive cutovers have separate up/down isolation fixtures. Point KUTUP_TEST_DB at a disposable PostgreSQL database; each test creates and removes its own randomized schema while proving local product rows survive:

Terminal window
KUTUP_TEST_DB=postgres://... cargo test -p kutup-server \
--test federation_phase_c_migration_live -- --nocapture
KUTUP_TEST_DB=postgres://... cargo test -p kutup-server \
--test federation_phase_d_migration_live -- --nocapture

The frontend is a React 18 + TypeScript app in frontend/, built with Vite.

Terminal window
cd frontend
pnpm install
pnpm dev

Vite starts on http://localhost:5173. The vite.config.ts includes a proxy rule that forwards /api requests to the backend at http://localhost:3000, so you can develop against a running backend without CORS issues.

Terminal window
pnpm build

Output goes to frontend/dist/, which is then served by the frontend Nginx container.

The project uses strict TypeScript ("strict": true in tsconfig.json). All new code must type-check cleanly. Run the type checker:

Terminal window
pnpm tsc --noEmit

kutup/
├── Cargo.toml # Root Cargo workspace (backend + CLI + crypto)
├── Dockerfile.server # Build image for the Rust kutup-server
├── crates/
│ ├── kutup-server/ # Backend API (Axum + sqlx + aws-sdk-s3)
│ │ ├── src/main.rs # Server setup, route registration, layers, subcommands
│ │ ├── src/handlers/ # HTTP handlers (one file per domain)
│ │ ├── src/{jwt,totp,ssrf,ratelimit,middleware}.rs # auth, rate limiting, SSRF guard
│ │ ├── src/{storage,jobs,hub}.rs # S3 client, background jobs, collab room hub
│ │ ├── src/{models,error,config,db,openapi}.rs
│ │ └── migrations/ # SQL migrations (embedded via sqlx::migrate!())
│ ├── kutup-cli/ # The `kutup` CLI (clap)
│ │ └── src/{commands,api,session,syncengine,transfer}/ # commands, HTTP client, session store, sync
│ ├── kutup-crypto/ # Canonical Kutup-owned E2EE formats
│ │ ├── src/{kdf,account_envelope,drive_envelope,drive_object,named_share,stream,envelope}.rs
│ │ └── tests/vectors/ # Checked-in byte-parity vectors
│ ├── kutup-crypto-wasm/ # Browser bindings for canonical crypto
│ ├── kutup-chat-proto/ # Typed Chat wire DTOs and validation
│ ├── kutup-federation-proto/ # Shared authenticated federation types
│ ├── kutup-chat-core/ # Standalone libsignal/OpenMLS engine; native + WASM
│ └── kutup-client-ffi/ # Standalone UniFFI Swift/Kotlin boundary
├── frontend/
│ ├── src/
│ │ ├── api/client.ts # Axios instance with auth interceptors
│ │ ├── crypto/ # Thin Rust/WASM format adapters + primitive-only stream adapter
│ │ ├── collab/ # Drive editor envelope and WebSocket layer
│ │ ├── chat/ # Direct/MLS services, backup, media, IndexedDB state
│ │ ├── components/editors/
│ │ │ ├── TextCollabEditor.tsx # Notes / code (CodeMirror 6 + Yjs)
│ │ │ ├── office/OfficeEditor.tsx # .docx/.xlsx/.pptx (OnlyOffice bridge)
│ │ │ └── whiteboard/WhiteboardEditor.tsx # .excalidraw (Excalidraw + last-write-wins)
│ │ ├── pages/ # Drive, Chat, editor, settings, admin, auth routes
│ │ ├── store/ # Redux slices (auth state)
│ │ └── workers/ # Web Worker for Rust/WASM Argon2id KDF
│ ├── public/onlyoffice/ # Bridge source; verified assets overlay during Docker builds
│ └── vite.config.ts # Dev server proxy config
├── src-tauri/ # Tauri desktop shell; experimental mobile targets remain available
│ ├── src/lib.rs # Plugin setup + OS-keychain vault commands (vault_set/get/delete)
│ ├── tauri.conf.json # Bundle id (dev.kutup.client), mainBinaryName (kutup-client), targets, scopes
│ └── capabilities/ # Tauri permission capabilities (default.json + desktop.json)
├── nginx/nginx.conf # Production Nginx config
├── docs/ # Start at docs/README.md
├── tests/e2e/ # Single- and two-server Playwright gates
├── fuzz/ # Standalone Chat/security cargo-fuzz package
└── docker-compose.yml

Periodic admin task that walks SeaweedFS for blobs whose containing files.id row no longer exists (PUT-then-crash leftovers, residual snapshot blobs from before quota tracking, etc.) and deletes them.

Subcommand on the existing kutup-server binary — same Docker image, same env vars, same DB pool.

Always start with a dry-run. Default behaviour reports orphans without touching them.

Terminal window
# Dry-run (default). Lists orphans + summary; no deletions.
docker compose exec backend ./kutup-server orphan-sweep
# Tighter age window for testing — anything older than 1h is fair game.
docker compose exec backend ./kutup-server orphan-sweep --age-floor=1h
# After verifying the dry-run output looks right, actually delete.
docker compose exec backend ./kutup-server orphan-sweep --delete

Flags:

Flag Default Notes
--delete false Without this, the command is a dry-run.
--age-floor 24h Skip blobs younger than this. The 24h default absorbs in-flight uploads; lower it only for testing.
--page-sleep 200ms Sleep between S3 LIST pages.
--prefix files/ S3 key prefix to walk.

Reading the summary log:

orphan-sweep summary: pages=N keys=N orphans=N skipped-age=N skipped-shape=N deleted=N bytes-reclaimed=N mode=dry-run|delete
  • skipped-age should be > 0 on a healthy bucket (the in-flight upload window). If it’s 0 every run, the age floor isn’t engaging — investigate before relying on the result.
  • skipped-shape counts keys outside the files/<UUID>/... shape; the sweep never deletes these.
  • bytes-reclaimed is the projected (dry-run) or actual (--delete) byte savings.

The sweep does not persist progress — a crash mid-run means rerunning from scratch. Acceptable at current scale; revisit if the bucket grows past ~500K objects.


  • Axum handlers organized by domain (crates/kutup-server/src/handlers/ — auth, collections, files, shares, federation, admin, …). Each file opens with a //! doc comment.
  • Use sqlx runtime queries (sqlx::query/query_as) — no compile-time-checked macros (no live DB at build), no ORM.
  • All cryptographic operations are the client’s responsibility; the backend must never attempt to decrypt anything. Shared primitives live in crates/kutup-crypto.
  • SSRF validation (crates/kutup-server/src/ssrf.rs) must be applied to all user-supplied URLs before making outbound requests (federation).
  • Gate every change with cargo clippy --all-targets -- -D warnings + cargo fmt + cargo test.
  • Strict mode is enforced. No any types.
  • Authored frontend files use .ts and .tsx only; do not add .js or .jsx.
  • All cryptographic operations go in src/crypto/. Components and pages must not call libsodium directly.
  • KDF (Argon2id) runs in src/workers/kdf.worker.ts to avoid blocking the main thread.
  • State management uses Redux Toolkit slices. Keep slices thin — business logic goes in thunks or service functions.
  • API calls go through src/api/client.ts, which handles token injection and refresh.
  • Use semantic theme roles and the responsive ownership boundaries documented in frontend.md. A viewport change must not remount services or trigger a mutation.

  1. Fork the repository and create a feature branch from master.
  2. Make focused, well-described commits. Each commit should be buildable and leave tests passing.
  3. Open a pull request against master. Describe why the change is needed, not just what it does.
  4. For security-related changes (cryptography, authentication, federation), include a brief explanation of the security model impact.

For bug reports and feature requests, open a GitHub issue.