# Steid

A personal gitforge, in Rust. It hosts your repositories, and it is meant to become the
place your work as a whole lives — code, writing, projects — under one identity you
control.

Steid is **portfolio-first**. Gitea and Forgejo are GitHub scaled down; their unit is the
repository and the profile is a directory listing bolted to the side. Steid inverts that:
the profile page at `/{handle}` is the product, and git repositories are one kind of
thing that appears on it. That framing wins every tie-break in the design.

It runs as a single binary with a SQLite database, shells out to `git` for everything
git-shaped, and is small enough to host on the cheapest VPS you can find.

## What works today

Honestly and only:

- **Identity and sessions.** One user. The instance is claimed on first run with a setup
  token, then you sign in with a password. There is no registration — see
  [limitations](#current-limitations).
- **The profile page** at `/{handle}`, listing what you have.
- **Repositories.** Created through the browser, stored as bare repos on disk and
  records in SQLite. Public or private.
- **Clone and push over HTTPS**, through `git http-backend`, authenticated with personal
  access tokens over HTTP Basic. Private repositories require a token to clone; public
  ones are anonymous.
- **Personal access tokens** — issue, list, revoke, at `/{handle}/settings/tokens`.
- **Browsing** — file tree, file contents, and commit log, at
  `/{handle}/repos/{name}/tree/{rev}` and `/log`.
- **A JSON API** — `/api/me`, `/api/users/{handle}`, `/api/users/{handle}/repos`. Not a
  separate product; every use case gets a second surface where that makes sense, which is
  what keeps the use-case layer honest.

Writing (markdown posts), multi-user, organizations, issues, pull requests and SSH
transport are **not built yet**. The plan for them is in [`plans/ROADMAP.md`](plans/ROADMAP.md).

## Requirements

- Linux, x86_64 or aarch64, with systemd.
- **`git` on `PATH`.** Not optional: Steid runs `git init --bare` to create a repository
  and `git http-backend` to serve every clone and push. One `apt-get install git`.
- **A TLS-terminating reverse proxy, and a domain name for it.** Also not optional.
  Steid speaks plain HTTP — the web framework it is built on (Topcoat 0.5) has no TLS
  support at all: no rustls, no ACME, no HTTPS listener. And because git authenticates
  with HTTP Basic, an instance served over plain HTTP leaks your access token to every
  hop on the network, on every push. The installer sets Caddy up for you.
- About 100 MB of disk before any repositories, plus whatever you push.

## Install

### One command

```sh
curl -fsSL https://REPLACE-ME.example.com/steid/install.sh | sh -s -- --domain git.example.com
```

Point the domain's DNS at the machine first, and make sure ports 80 and 443 are open —
Caddy needs both to obtain a certificate.

That does the following, and nothing else:

1. installs `git`, `curl` and `ca-certificates`
2. downloads the release tarball for your architecture and **verifies its SHA-256**
3. unpacks it to `/opt/steid` — the binary with its `assets/` directory beside it
4. creates a `steid` system user and `/var/lib/steid` for state, owned by it
5. writes `/etc/steid/steid.env`, and a systemd unit that listens on `127.0.0.1:3000`
6. installs Caddy and writes a Caddyfile that terminates TLS for your domain and proxies
   to that loopback port
7. starts both

Re-run it with a newer `--version` to upgrade. It is idempotent: it replaces the binary
and assets, leaves `/var/lib/steid` alone, and will not overwrite `/etc/steid/steid.env`
once it exists.

Options: `--version`, `--port`, `--flavour musl|gnu`, and `--no-caddy` if you are
bringing your own proxy. `sh install.sh --help` lists them.

**On piping a URL into a shell.** It is a real trust decision, and "it's convenient" is
not an answer to it. Download and read it first if you would rather:

```sh
curl -fsSL https://REPLACE-ME.example.com/steid/install.sh -o install.sh
less install.sh
sudo sh install.sh --domain git.example.com
```

Or do it by hand — the manual path below is the same steps, written out.

### Manually

Every command as root, on Debian or Ubuntu. Adjust paths and package manager to taste;
nothing here is specific to a distribution except `apt-get`.

```sh
# 1. Prerequisites. git is mandatory — Steid shells out to it for everything.
apt-get update
apt-get install -y git curl ca-certificates

# 2. Fetch and verify the release for your architecture.
VERSION=0.1.0
TARGET=x86_64-unknown-linux-musl        # or aarch64-unknown-linux-musl
BASE=https://REPLACE-ME.example.com/steid/releases/download
curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz"
curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz.sha256"
sha256sum -c "steid-$VERSION-$TARGET.tar.gz.sha256"
tar -xzf "steid-$VERSION-$TARGET.tar.gz"

# 3. Install. `assets/` MUST end up beside the binary: at startup Steid walks up
#    from its own executable looking for `assets/manifest.toml`, and exits if it
#    is not there. Moving the binary on its own gives you a program that will not
#    boot.
mkdir -p /opt/steid
cp "steid-$VERSION-$TARGET/steid" /opt/steid/steid
cp -r "steid-$VERSION-$TARGET/assets" /opt/steid/assets
chmod 755 /opt/steid/steid

# 4. A user to run as, and one directory for all state.
useradd --system --home-dir /var/lib/steid --shell /usr/sbin/nologin steid
mkdir -p /var/lib/steid/repos
chown -R steid:steid /var/lib/steid
chmod 750 /var/lib/steid

# 5. Configuration.
mkdir -p /etc/steid
cat > /etc/steid/steid.env <<'EOF'
STEID_DATABASE_URL=sqlite:/var/lib/steid/steid.db?mode=rwc
STEID_DATA_DIR=/var/lib/steid/repos
HOST=127.0.0.1
PORT=3000
EOF
chmod 640 /etc/steid/steid.env
chown root:steid /etc/steid/steid.env

# 6. The service. deploy/steid.service in this repo is the file to copy.
cp deploy/steid.service /etc/systemd/system/steid.service
systemctl daemon-reload
systemctl enable --now steid

# 7. The proxy. deploy/Caddyfile is a working starting point — change the
#    hostname on its first line to yours.
apt-get install -y caddy      # or follow https://caddyserver.com/docs/install
cp deploy/Caddyfile /etc/caddy/Caddyfile
$EDITOR /etc/caddy/Caddyfile
systemctl reload caddy
```

There is **no migration step**. Steid runs its migrations itself on every boot.

## First run: claiming the instance

A fresh instance has no owner. On startup it prints a setup token to its log:

```
  This steid has no owner yet. Claim it at /auth/setup with:

      <token>
```

Two things about that token that will bite you otherwise:

- It is printed **only while the instance is unclaimed**. Once you have claimed it, it is
  never shown again — because it no longer exists.
- It lives **in memory only**. Every restart mints a new one, and the one in an older log
  line is dead.

So read it from the current run's journal:

```sh
journalctl -u steid --no-pager | tail -n 30
```

Then open `https://git.example.com/auth/setup`, paste it in, and choose your handle and
password. After that, `/auth/login`.

### Or set the token yourself

If reading a log line during the first minute of a scripted install is awkward, set the
token instead and skip the race:

```sh
STEID_SETUP_TOKEN=$(head -c 32 /dev/urandom | base64)
```

It is only consulted while the instance is unclaimed, it is never printed, and Steid
**refuses to start** if it is weak — at least 32 characters, no whitespace, and at least
eight distinct characters, so `abababab…` is rejected rather than accepted.

## Using it

1. Create a repository at `/{handle}/repos/new`. Public or private.
2. Issue a personal access token at `/{handle}/settings/tokens`. It is shown once.
3. Push:

```sh
git remote add origin https://git.example.com/me/repos/my-project.git
git push -u origin main
```

Git will ask for a username and password. The token goes in the **password** field; the
username is ignored (a token pasted as the username with an empty password also works,
because people do that).

You only type it once. Git's credential helper stores it and answers every later push —
which is the whole reason pushing to GitHub feels like it needs no credentials. Use the
one that keeps secrets in your operating system's keystore:

```sh
git config --global credential.helper osxkeychain   # macOS (usually already set)
git config --global credential.helper manager       # Windows
git config --global credential.helper libsecret     # Linux
```

**Not `credential.helper store`.** It writes the token in clear text to
`~/.git-credentials`, and a token is a password that never expires. Steid keeps only a
hash of it precisely so that a stolen database contains nothing anyone can present;
storing the plaintext on your laptop hands back what that was protecting.

**Do not put the token in the remote URL** (`https://user:token@host/...`) either. It
goes into `.git/config` in clear text and shows up in `git remote -v`, in shell history,
and in any log that records the URL.

Cloning a public repository needs no credentials at all.

## Configuration

Environment variables, read from `/etc/steid/steid.env` by the systemd unit.

| Variable | Default | What it is |
|---|---|---|
| `STEID_DATABASE_URL` | `sqlite:steid.db?mode=rwc` | SQLite connection string. The directory must be writable, not just the file — SQLite writes `-wal` and `-shm` siblings. |
| `STEID_DATA_DIR` | `./data` | Where bare repositories live, as `{data_dir}/{handle}/{name}.git`. |
| `STEID_INSECURE_COOKIES` | unset (false) | **Never set this in production.** It strips `Secure` from the session cookie so it survives plain-HTTP localhost, and exists only for local development. |
| `HOST` | `127.0.0.1` in the shipped unit | Bind address. Read by the framework, so deliberately *not* `STEID_`-prefixed. Keep it on loopback; the proxy is the way in. |
| `PORT` | `3000` | Likewise not `STEID_`-prefixed. |

Restart after editing: `systemctl restart steid`.

## Backup and restore

The entire backup surface is **two paths**:

- `/var/lib/steid/steid.db` — the SQLite database (users, repositories, tokens, sessions)
- `/var/lib/steid/repos` — the bare git repositories

Which is to say: back up `/var/lib/steid`. `rsync` is enough.

```sh
systemctl stop steid
rsync -a /var/lib/steid/ backup-host:/backups/steid/
systemctl start steid
```

Stopping first is the honest version: SQLite in WAL mode leaves `-wal` and `-shm` files,
and copying them while a write is in flight can capture a torn state. If you would rather
not stop the service, snapshot the database properly and copy the repositories live —
they are only written during a push:

```sh
sqlite3 /var/lib/steid/steid.db ".backup '/tmp/steid-backup.db'"
rsync -a /tmp/steid-backup.db /var/lib/steid/repos backup-host:/backups/steid/
```

To restore: install Steid as above, stop it, drop both paths back into `/var/lib/steid`,
`chown -R steid:steid /var/lib/steid`, start it. Migrations run on boot, so a database
from an older version is brought forward automatically.

## Building from source

```sh
cargo install topcoat-cli --version 0.5.0 --locked
topcoat asset bundle --release
```

**`cargo build --release` on its own is not enough.** It produces a binary that will not
boot: `AssetBundle::load()` walks up from the executable looking for
`assets/manifest.toml`, and `build.rs` does not write one. `topcoat asset bundle` runs
`cargo build` itself and then writes the bundle to `target/assets`. The binary at
`target/release/steid` needs that directory beside it.

Requires rustc ≥ 1.95 — Topcoat 0.5 demands it, and on an older toolchain `cargo add
topcoat` silently resolves to an empty `v0.0.0` placeholder instead of failing.

For development, `topcoat dev` does the bundling for you, and a local `.env` with
`STEID_INSECURE_COOKIES=true` is needed for the session cookie to survive plain-HTTP
localhost.

### Cutting a release

```sh
./release.sh --target x86_64-unknown-linux-musl
```

Produces `dist/steid-<version>-<target>.tar.gz` and a `.sha256` beside it. The tarball
extracts to a self-contained directory: the binary, `assets/`, and this README. Building
a Linux artefact on macOS needs a container (the script uses Docker) or a cross
toolchain; `--native` builds for the host instead, which is useful for checking the
artefact layout and not for releasing.

A container image also exists — see [`Dockerfile`](Dockerfile) — but the supported
artefact is the plain binary.

## Current limitations

Deliberately blunt. Steid is early.

- **Single user.** One account, claimed on first run. No registration, no invites, no
  organizations yet.
- **No TLS of its own.** A reverse proxy is mandatory, not recommended — see
  [requirements](#requirements).
- **No encryption at rest.** The database and repositories are plain files. Anyone with
  the disk has everything. Token *values* are hashed, and passwords are Argon2, but
  repository contents are not encrypted.
- **Rate limiting covers `/auth/login` and `/auth/setup` only** — 10 attempts a minute
  per client, with a global backstop. Token authentication on the git routes is *not*
  limited; a token is 256 bits, so guessing is not the concern there, but unbounded
  hashing on an open endpoint still is.
- **Rate limiting keys on forwarded headers, because Topcoat 0.5 does not expose the
  peer address at all.** Behind a reverse proxy — the supported deployment — that works.
  Exposed directly to the internet with no proxy, a caller can vary the header and get a
  fresh budget each time, leaving only the global cap. Run it behind the proxy.
- **Tokens never expire** and carry no "last used" timestamp, which makes it hard to know
  which are safe to revoke.
- **No CI/CD**, no issues, no pull requests, no code review, no SSH transport, no
  webhooks, no federation.
- **No writing yet** — posts and markdown are the next milestone, and they are the point
  of the whole thing.
- **Light mode is untested.** It is defined; nobody has looked at it.
- **Not battle-tested.** It has not run under load, has not been audited, and has been
  deployed by approximately one person. Do not put anything irreplaceable in it that is
  not also somewhere else.

More, in unflattering detail, in [`plans/current.md`](plans/current.md).

## Project layout

`plans/` is the source of truth for intent, not the code:

| File | Holds |
|---|---|
| [`plans/ROADMAP.md`](plans/ROADMAP.md) | vision, stack, the milestone ladder |
| [`plans/current.md`](plans/current.md) | the active milestone, and every known gap |
| [`plans/progress.md`](plans/progress.md) | what shipped, and the decisions worth not rediscovering |
| [`plans/architecture.md`](plans/architecture.md) | layer rules and conventions |
| [`plans/decisions/`](plans/decisions/) | ADRs |

The code is a single crate in three layers — `domain`, `application`, `infrastructure` —
with dependencies pointing inward. Every use case takes an `Actor` and authorizes before
any side effect.
