# fabrica — implementation spec `fabrica` (Latin: *forge*) is a self-hosted git server. Single binary, private by default, no sign-up. It serves repositories over HTTPS (read) and SSH (read + write) and provides a fast, themeable web UI for browsing code. This document is the implementation contract. Follow it section by section. Where it says **MUST**, treat it as a hard requirement; where it says **SHOULD**, deviate only with a note in `docs/decisions.md`. --- ## 1. Goals and non-goals ### Goals - Host git repositories for users on a self-hosted instance, personal or small-team. - Browse code, history, diffs, branches, and tags in a UI that is fast on desktop and mobile. - Syntax highlighting everywhere code appears, including inside diffs and rendered markdown. - Show whether each commit is cryptographically signed, via SSH or GPG. - Organize repositories under nested, user-owned groups. - **Collaboration.** Issues and pull requests with GitHub-style **scoped labels** (`kind: value`, e.g. `type: backend`), assignees, comments, and open/closed state; PRs include branch comparison, diff, review, and server-side **merge machinery** (merge commit, squash, rebase). Issues and PRs are **toggleable per repository**. - **Three-level visibility** (public / internal / private), GitLab-style, with a per-instance default; explicit collaborators with read/write/admin. - **Accounts.** Optional web **self-registration** (toggleable, with optional hCaptcha / reCAPTCHA) alongside admin invites; a full account settings area (change username, email, password; manage SSH/GPG keys, API tokens, and profile). - Re-theme and re-brand without recompiling. - One binary, one config file, one data directory. The aim is a **complete forge minus CI** — feature-comparable to Forgejo/Gitea for the areas above. When a feature is ambiguous, choose the simpler behaviour. ### Non-goals (do not build these) - **Organizations** in the GitHub sense. Repos belong to a user; groups provide the hierarchy. - Forks, stars, followers, activity feeds beyond the dashboard, notifications, wikis, releases-as-a-feature, git-LFS, webhooks, mirroring, federation. (Not planned for now; may be revisited, but out of the current "complete minus CI" scope.) ### Deferred (design for, do not implement) - **CI.** Config in **HCL** (not YAML), Docker as the execution backend for stages and jobs. Reserve the `runs` nav slot, the `/-/runs` route family, the `runs`/`jobs` tables, and the `post-receive` hook entry point. Ship them inert behind `ui.show_runs = false`. --- ## 2. Toolchain, build, and repository layout ### 2.1 Workspace Root package `fabrica` produces the single binary. All library code lives in `crates/`, with **unprefixed crate names**: ``` fabrica/ ├── flake.nix ├── flake.lock ├── Cargo.toml # workspace root, package `fabrica`, src/main.rs only ├── Cargo.lock ├── rust-toolchain.toml ├── src/main.rs # arg parse -> dispatch into `cli`; nothing else ├── crates/ │ ├── config/ # TOML + env config load, validation, path resolution │ ├── store/ # database: schema, migrations, queries (SQLite + Postgres) │ ├── model/ # shared domain types, no I/O │ ├── git/ # libgit2 reads, attributes, signatures, pack transport spawn │ ├── highlight/ # inkjet/tree-sitter, language resolution, line-aware output │ ├── auth/ # argon2id, sessions, JWT, permission checks │ ├── mail/ # SMTP + templates │ ├── ssh/ # russh server, publickey auth, exec -> pack processes │ ├── web/ # axum router, maud templates, htmx partials, assets, theming │ ├── api/ # /api/v1 JSON handlers │ └── cli/ # clap command tree, all subcommands ├── assets/ # embedded defaults (base.css, theme.css, htmx.min.js, icons) ├── migrations/ │ ├── sqlite/ │ └── postgres/ ├── docs/ │ ├── configuration.md │ ├── theming.md │ ├── deployment.md │ └── decisions.md ├── Dockerfile ├── compose.yml ├── fabrica.example.toml └── LICENSE # MPL-2.0 ``` Naming constraints: **MUST NOT** name a crate `core`, `std`, `alloc`, `test`, or `proc_macro` — these collide with sysroot crates. `git` and `api` are fine. Dependency direction is strictly one-way: `model` ← `store`/`git`/`auth` ← `web`/`api`/`ssh`/`cli`. No crate may depend on `web`. `model` depends on nothing in-tree. ### 2.2 Toolchain - Rust **nightly** via **fenix**, pinned through `flake.lock`. Nightly is a currency preference, not a feature dependency: the code **MUST NOT** use `#![feature(...)]` gates. It should compile on current stable too; treat any nightly-only construct as a bug. - `rust-toolchain.toml` declares `channel = "nightly"` with components `rustfmt`, `clippy`, `rust-src`, `rust-analyzer` for non-Nix users. - Edition 2024, resolver 3. - Workspace lints in root `Cargo.toml`: ```toml [workspace.lints.rust] unsafe_code = "forbid" missing_docs = "warn" [workspace.lints.clippy] all = { level = "deny", priority = -1 } pedantic = "warn" unwrap_used = "deny" expect_used = "warn" panic = "deny" todo = "deny" ``` `unwrap_used` is denied in library code; `#[cfg(test)]` blocks may allow it locally. ### 2.3 Nix flake Inputs: `nixpkgs` (unstable), `fenix`, `crane`, `flake-utils` (or `flake-parts` — pick one and be consistent). The flake **MUST** expose: - `packages.default` — the wrapped binary. - `packages.fabrica` — alias of the above. - `packages.container` — `pkgs.dockerTools.buildLayeredImage`, as a Nix-native alternative to the `Dockerfile`. - `devShells.default` — toolchain + `sqlx-cli`, `sqlite`, `postgresql`, `git`, `openssh`, `gnupg`, `cargo-nextest`, `cargo-deny`, `just`. - `checks.{clippy,fmt,test,deny,doc}` — so `nix flake check` runs the full gate. - `formatter` — `nixpkgs-fmt` or `alejandra`. Build shape: ```nix craneLib = (crane.mkLib pkgs).overrideToolchain fenixToolchain; commonArgs = { src = craneLib.cleanCargoSource ./.; strictDeps = true; nativeBuildInputs = [ pkgs.pkg-config pkgs.makeWrapper ]; buildInputs = [ pkgs.libgit2 pkgs.zlib pkgs.openssl ]; # use system libgit2 rather than the vendored copy LIBGIT2_NO_VENDOR = "1"; }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; ``` The final derivation **MUST** wrap the binary so the git plumbing is always reachable: ```nix postInstall = '' wrapProgram $out/bin/fabrica \ --prefix PATH : ${lib.makeBinPath [ pkgs.git ]} ''; ``` Also expose a **NixOS module** at `nixosModules.default` providing `services.fabrica` with `enable`, `settings` (TOML-typed, rendered via `pkgs.formats.toml`), `user`, `group`, `dataDir`, `environmentFile` (for secrets), a hardened `systemd.services.fabrica` unit (`DynamicUser` off, `StateDirectory=fabrica`, `ProtectSystem=strict`, `NoNewPrivileges`, `AmbientCapabilities=CAP_NET_BIND_SERVICE` only if a privileged port is configured), and `openFirewall`. --- ## 3. Git layer (`crates/git`) ### 3.1 Division of responsibility — read this carefully `libgit2` (via `git2`) is the object-model library: refs, trees, blobs, commits, revwalks, diffs, attributes, signature extraction. **libgit2 does not implement the server side of the pack protocol.** There is no `upload-pack`/`receive-pack` in libgit2 — it is client-only for transport. Therefore: | Concern | Implementation | | --- | --- | | Browsing, log, diff, blame-free reads | `git2` in-process | | Signature extraction | `git2` (`Repository::extract_signature`) | | Attribute resolution | own resolver over tree blobs (§3.5) | | Clone/fetch (HTTPS) | spawn `git upload-pack --stateless-rpc` | | Push (SSH) | spawn `git receive-pack` | | Fetch over SSH | spawn `git upload-pack` | | Repo creation, gc, repack | `git2` for init; spawn `git gc`/`git repack` for maintenance | `git` **MUST** be present on `PATH` at runtime. Resolve it once at startup: - config key `git.binary` (default `"git"`), - resolve via `which`, verify `git --version` >= 2.41, - fail startup with a clear, actionable error if absent. Record the discovered path and version in `/healthz` output and in `fabrica doctor`. ### 3.2 On-disk layout Repositories are stored by **id**, not by name, so renames and group moves never touch the filesystem: ``` {storage.repo_dir}/{id[0..2]}/{id}.git ``` where `id` is a lowercase ULID. The database is the only name→path authority. Document the tradeoff in `docs/decisions.md`: the tree is not human-browsable, in exchange for atomic, metadata-only renames. `fabrica repo path ` prints the resolved directory for operators. Repo creation: 1. `git2::Repository::init_bare`. 2. Set `HEAD` to `refs/heads/{default_branch}` (config: `instance.default_branch`, default `main`). 3. Write config: `core.logAllRefUpdates=true`, `receive.denyNonFastForwards=false`, `gc.auto=0` (maintenance is driven by fabrica, not by pushes). 4. Install hooks (§3.4). 5. Insert the DB row in the same transaction; on any failure, remove the directory. Deletion moves the directory to `{storage.data_dir}/trash/{id}-{timestamp}.git` and deletes the row; a `fabrica gc --trash-older-than 30d` command reaps it. `repo del --purge` skips the trash. ### 3.3 Pack transport Everything below shares one code path: `git::pack::spawn(kind, repo_path, env, stdio)`. **Common rules** - Never interpolate a user-supplied path into a shell. Use `tokio::process::Command` with explicit args, no shell. - Authorize *before* spawning. The subprocess never makes an access decision. - Set on every child: `GIT_DIR={resolved path}`, `GIT_PROTOCOL` passed through when the client requested v2, `GIT_TERMINAL_PROMPT=0`, `GIT_CONFIG_NOSYSTEM=1`, `HOME={data_dir}/tmp`, and the identity vars `FABRICA_USER_ID`, `FABRICA_USER_NAME`, `FABRICA_REPO_ID`, `FABRICA_KEY_ID` (for hook consumption). - Enforce `git.max_pack_size_bytes` (default 512 MiB) and `git.transport_timeout_secs` (default 3600); kill the process group on timeout and log it. - Clean up: always `kill_on_drop(true)`; reap children. **HTTPS smart protocol (read-only)** Implement only the v0/v1/v2 smart paths; dumb HTTP **MUST** be disabled. ``` GET /{owner}/{path}.git/info/refs?service=git-upload-pack POST /{owner}/{path}.git/git-upload-pack ``` - `info/refs`: spawn `git upload-pack --stateless-rpc --advertise-refs {dir}`, prefix the body with the pkt-line `# service=git-upload-pack\n` + flush, `Content-Type: application/x-git-upload-pack-advertisement`, `Cache-Control: no-cache`. - `git-upload-pack`: stream the request body to the child's stdin and the child's stdout back as the response body, `Content-Type: application/x-git-upload-pack-result`. **Stream both directions** — never buffer a pack in memory. Support `Content-Encoding: gzip` on the request. - Also accept the same routes without the `.git` suffix. - `POST /{owner}/{path}.git/git-receive-pack` and `info/refs?service=git-receive-pack` **MUST** return `403` with a plain-text body explaining that push over HTTPS is disabled and giving the correct SSH remote URL. This is a deliberate, discoverable failure. - Private repos require auth: HTTP Basic with username + (password or API token), or a session cookie. Anonymous access to a private repo returns `401` with `WWW-Authenticate: Basic realm="fabrica"`. **SSH (read + write)** `crates/ssh` runs a `russh` server. - Host key at `ssh.host_key_path`; generate an ed25519 key on first start with mode `0600`. - Only `publickey` auth. `auth_password` and `auth_none` always reject. Offer ed25519, ecdsa-sha2-nistp256, and rsa-sha2-256/512; refuse `ssh-rsa` (SHA-1) and DSA. - On offered key: compute the SHA256 fingerprint, look it up in `keys` where `kind = 'ssh'`. Unknown fingerprint → reject. Match → bind the user to the session, update `last_used_at`. - Handle the `exec` channel request only. Parse the command with shell-word splitting and accept exactly: - `git-upload-pack ''` - `git-receive-pack ''` - `git-upload-archive ''` Anything else (including an interactive shell request) → write a friendly banner to stderr (`Hi {user}! fabrica does not provide shell access.`), exit status 1, close. - Normalize ``: strip a leading `/` and a trailing `.git`, reject `..` and absolute paths, resolve `owner/group…/repo` through the store. - Permission: `upload-pack` needs `read`, `receive-pack` needs `write`. Deny with a clear stderr message and non-zero exit. - Pipe channel data ↔ child stdio; forward child stderr to the channel's extended data so hook output reaches the user's terminal. Send `exit-status` on completion. - Support `ssh.clone_host` / `ssh.clone_port` for the clone URLs shown in the UI, separate from the bind address, so a reverse proxy or port forward can be reflected accurately. ### 3.4 Hooks Install `hooks/post-receive` (and a no-op `pre-receive` reserved for future branch protection) as a two-line script: ```sh #!/bin/sh exec fabrica hook post-receive ``` `fabrica hook post-receive` reads the ` ` lines from stdin and: - updates `repos.pushed_at`, `repos.size_bytes`, and the cached default-branch head, - invalidates the repo's caches, - enqueues a `runs` row **only** when CI ships (inert for the MVP), - exits 0 even on internal error, logging loudly — a hook failure **MUST NOT** reject a push that git already accepted. The hook path must locate the running binary reliably: write the absolute path of the current executable at repo-init time (`std::env::current_exe`), falling back to `git.hook_binary` config. ### 3.5 Attributes (`.gitattributes`) libgit2's attribute lookup is oriented toward a working tree; bare repos and per-ref accuracy make it unreliable here. Implement an own resolver: - For a given `(repo, tree_oid)`, walk the tree and collect every `.gitattributes` blob with its directory prefix. - Parse into ordered rules (pattern, attribute assignments), respecting gitattributes semantics: later rules win, deeper directories win over shallower, `!` negation, `/`-anchored patterns, `**`, and macro-free simple assignment (`key=value`, `key`, `-key`, `!key`). - Match with `globset`, honouring the "no slash in pattern → match basename at any depth" rule. - Cache resolved rule sets in a `moka` cache keyed by `(repo_id, tree_oid)`. - Consumers: language override for highlighting, `binary`/`-text` for "binary file, not shown", `linguist-generated` / `fabrica-generated` for collapsing diffs by default, `linguist-vendored` for excluding from search. Unit tests **MUST** cover: precedence between nested files, negation, anchored vs floating patterns, and `-diff` suppressing diff rendering. ### 3.6 Signature verification Read the signature with `Repository::extract_signature(oid, None)`, which yields `(signature, signed_payload)`. Dispatch on the armor header. **SSH** (`-----BEGIN SSH SIGNATURE-----`): parse with the `ssh-key` crate's `SshSig`. Verify with namespace `"git"` against candidate public keys. Reject if the sig's hash algorithm is not sha256/sha512. **GPG** (`-----BEGIN PGP SIGNATURE-----`): verify with `sequoia-openpgp` against registered public keys. Check the signature's creation time against key validity and expiry; treat expired or revoked keys as unverified, not verified. Result type: ```rust pub enum SignatureState { Unsigned, /// Valid signature, key registered on this instance. Verified { user_id: Ulid, key_id: Ulid, kind: KeyKind, fingerprint: String }, /// Valid signature, key not known to this instance. UnknownKey { kind: KeyKind, fingerprint: String }, /// Signature present but cryptographically invalid, malformed, or expired. Invalid { kind: Option, reason: String }, } ``` UI mapping, matching the reference screenshots: `Verified` renders a green **Verified** badge; everything else that carries a signature renders an amber **Unverified** badge; `Unsigned` renders an amber **Unverified** badge as well. The tooltip / detail line distinguishes the four cases precisely ("Signed by an unrecognized key `SHA256:…`", "Signature could not be verified: …", "This commit is not signed"). Do not silently collapse `Invalid` into `Unsigned`. Verification is expensive; cache per commit oid in a bounded `moka` cache and, for list views, verify lazily in a bounded-concurrency batch (`ui.signature_batch_concurrency`, default 8). Additionally verify tag signatures for annotated tags and show the same badge on the tags page. ### 3.7 Reads Expose a narrow, testable API — no `git2` types in the public signatures, so `web` never links against libgit2 semantics: ```rust pub fn resolve_ref(&self, r: &str) -> Result; // branch | tag | oid | HEAD pub fn tree_entries(&self, rev: &Resolved, path: &Path) -> Result>; pub fn blob(&self, rev: &Resolved, path: &Path) -> Result; // + is_binary, size, lfs-ignored pub fn commits(&self, rev: &Resolved, path: Option<&Path>, page: Page) -> Result>; pub fn commit(&self, oid: Oid) -> Result; pub fn diff(&self, base: Option, head: Oid, opts: DiffOpts) -> Result; pub fn diff_context(&self, head: Oid, file: &Path, from: u32, to: u32) -> Result>; pub fn branches(&self) -> Result>; // + ahead/behind vs default pub fn tags(&self) -> Result>; pub fn last_commit_per_entry(&self, rev: &Resolved, path: &Path) -> Result>; ``` Notes: - Blocking libgit2 calls **MUST** run on `tokio::task::spawn_blocking`, or on a dedicated `rayon` pool with a bounded queue. Never block the async runtime. - `Repository` handles are not `Sync`; use a small per-repo pool or open-per-operation. Measure before optimizing; open-per-operation is acceptable for the MVP if documented. - Branch ahead/behind uses `graph_ahead_behind` against the default branch (the screenshot shows `10 ahead`). - `last_commit_per_entry` powers the file tree's "latest change" column; it is the classic performance trap. Implement as a single revwalk with path filtering, capped by `ui.tree_history_limit` (default 1000 commits), and degrade gracefully to no column when the cap is hit. --- ## 4. Configuration (`crates/config`) TOML file plus environment overrides via `figment`. Resolution order (later wins): built-in defaults → `/etc/fabrica/fabrica.toml` → `$XDG_CONFIG_HOME/fabrica/fabrica.toml` → `--config ` → `FABRICA__*` env vars. Nested keys use double underscores: `FABRICA__SERVER__PORT=8080`. Any `*_file` key reads its value from a file (for agenix/systemd-creds/Docker secrets) and takes precedence over its inline sibling. Secrets **MUST NOT** be logged, echoed by `config show` (print ``), or included in error messages. `fabrica.example.toml` — ship this, fully commented: ```toml [instance] name = "fabrica" # shown in the header and page titles url = "https://git.example.com" description = "A small, private forge." default_branch = "main" allow_anonymous = true # anonymous browse + clone of public repos [server] address = "0.0.0.0" port = 8080 behind_proxy = false # trust X-Forwarded-For / X-Forwarded-Proto graceful_shutdown_secs = 20 max_body_bytes = 1048576 # non-pack routes only [ssh] enabled = true address = "0.0.0.0" port = 2222 host_key_path = "/var/lib/fabrica/ssh/host_ed25519" clone_host = "git.example.com" # what the UI shows, may differ from bind clone_port = 22 [storage] data_dir = "/var/lib/fabrica" repo_dir = "/var/lib/fabrica/repos" [database] url = "sqlite:///var/lib/fabrica/fabrica.db" max_connections = 16 # SQLite is opened with journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON, # busy_timeout=5000. Postgres URLs (postgres://…) are detected automatically. [auth] secret_file = "/var/lib/fabrica/secret" # HS256 key, generated on first run, 0600 session_ttl_days = 30 token_ttl_days = 90 cookie_name = "fabrica_session" cookie_secure = true argon2_m_cost = 19456 argon2_t_cost = 2 argon2_p_cost = 1 [mail] backend = "smtp" # smtp | stdout | none from = "fabrica@example.com" host = "smtp.example.com" port = 587 username = "fabrica" password_file = "/run/secrets/fabrica-smtp" encryption = "starttls" # starttls | tls | none timeout_secs = 15 [ui] theme = "dark" # file stem in themes_dir, or an embedded theme name themes_dir = "/var/lib/fabrica/themes" assets_dir = "/var/lib/fabrica/assets" allow_theme_choice = true # show the theme picker; persists in a cookie show_runs = false # CI nav slot, inert until the CI ships diff_context_lines = 3 max_highlight_bytes = 1048576 max_diff_bytes = 5242880 tree_history_limit = 1000 [git] binary = "git" transport_timeout_secs = 3600 max_pack_size_bytes = 536870912 [log] level = "info" # trace|debug|info|warn|error, RUST_LOG also honoured format = "pretty" # pretty | json ``` Validation at startup **MUST** be strict and fail fast with actionable messages: unknown keys are an error (catch typos), `instance.url` must parse and must not have a trailing slash, directories must exist or be creatable, `mail.backend = "smtp"` requires host/from, `auth.cookie_secure = true` with a non-HTTPS `instance.url` is a warning. `fabrica config check` validates and exits; `fabrica config show` prints the effective merged config with secrets redacted. --- ## 5. Data model (`crates/store`) `sqlx` with both backends. Do **not** use compile-time-checked macros against two dialects — define a `Store` trait with `Sqlite` and `Postgres` implementations behind it, or use runtime queries over `AnyPool`; keep every statement inside the portable SQL subset. Portability rules, enforced by review: - Timestamps are `BIGINT` unix **milliseconds**, UTC. No `DATETIME`, no `TIMESTAMPTZ`. - Ids are `TEXT` ULIDs (26 chars, lexicographically sortable — use them for ordering). - Booleans are `INTEGER` 0/1 in SQLite, `BOOLEAN` in Postgres; the trait maps them. - No `AUTOINCREMENT`, no `SERIAL`, no `RETURNING` outside a helper that both dialects support (both do support `RETURNING` — using it is fine). - Case-insensitive uniqueness on usernames and emails is enforced by storing a normalized `*_lower` column with a unique index, not by collation. Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`, applied with `sqlx::migrate!` selected at runtime. `fabrica migrate` runs them; `serve` runs them automatically unless `--no-migrate`. ### Schema ```sql users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, username_lower TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_lower TEXT NOT NULL UNIQUE, display_name TEXT, password_hash TEXT, -- NULL until the invite is completed must_change_password INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0, disabled_at BIGINT, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ) invites ( -- password-setup / activation links id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the emailed token purpose TEXT NOT NULL, -- 'activate' | 'reset' expires_at BIGINT NOT NULL, used_at BIGINT, created_at BIGINT NOT NULL ) sessions ( id TEXT PRIMARY KEY, -- SHA-256 of the cookie value user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_agent TEXT, ip TEXT, created_at BIGINT NOT NULL, last_seen_at BIGINT NOT NULL, expires_at BIGINT NOT NULL ) api_tokens ( id TEXT PRIMARY KEY, -- the JWT `jti` user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, scopes TEXT NOT NULL, -- comma-separated expires_at BIGINT, revoked_at BIGINT, last_used_at BIGINT, created_at BIGINT NOT NULL ) keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, kind TEXT NOT NULL, -- 'ssh' | 'gpg' name TEXT, fingerprint TEXT NOT NULL, -- SHA256:… for ssh, full hex fp for gpg public_key TEXT NOT NULL, -- authorized_keys line or ASCII-armored pgp block ordinal INTEGER NOT NULL, -- stable 1-based index per (user, kind) for `key del` last_used_at BIGINT, created_at BIGINT NOT NULL, UNIQUE (kind, fingerprint), UNIQUE (user_id, kind, ordinal) ) groups ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, parent_id TEXT REFERENCES groups(id) ON DELETE CASCADE, name TEXT NOT NULL, path TEXT NOT NULL, -- materialized 'group/sub/leaf' description TEXT, created_at BIGINT NOT NULL, UNIQUE (owner_id, path) ) repos ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, group_id TEXT REFERENCES groups(id) ON DELETE SET NULL, name TEXT NOT NULL, path TEXT NOT NULL, -- materialized 'group/sub/repo' (== name when ungrouped) description TEXT, is_private INTEGER NOT NULL DEFAULT 1, default_branch TEXT NOT NULL, archived_at BIGINT, size_bytes BIGINT NOT NULL DEFAULT 0, pushed_at BIGINT, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, UNIQUE (owner_id, path) ) repo_collaborators ( repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, permission TEXT NOT NULL, -- 'read' | 'write' | 'admin' created_at BIGINT NOT NULL, PRIMARY KEY (repo_id, user_id) ) -- reserved, inert until the CI ships runs (id TEXT PRIMARY KEY, repo_id TEXT NOT NULL, ref TEXT NOT NULL, commit_sha TEXT NOT NULL, status TEXT NOT NULL, started_at BIGINT, finished_at BIGINT, created_at BIGINT NOT NULL) jobs (id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, stage TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, log_path TEXT, started_at BIGINT, finished_at BIGINT) ``` Indexes: `repos(owner_id, path)`, `repos(is_private, pushed_at DESC)`, `groups(owner_id, parent_id)`, `keys(user_id, kind)`, `sessions(user_id)`, `sessions(expires_at)`, `api_tokens(user_id)`. Name validation (single place in `model`, used by CLI, API, and web): usernames and repo/group names match `^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$`, must not be `.`/`..`, must not end in `.git` or `.`, and are rejected against a reserved list: `api`, `assets`, `login`, `logout`, `search`, `settings`, `admin`, `static`, `healthz`, `invite`, `-`. --- ## 6. Auth and permissions (`crates/auth`) - **Passwords**: `argon2` crate, Argon2id, params from config. Hashes stored in PHC string format. Verification is constant-time; on a missing user, still perform a dummy hash to avoid timing disclosure. - **Sessions**: 32 bytes from `OsRng`, base64url-encoded in the cookie; the DB stores only the SHA-256. Cookie is `HttpOnly`, `SameSite=Lax`, `Secure` per config, `Path=/`. Rotate the id on login and on password change; delete all sessions for the user on password change. - **CSRF**: double-submit token in a non-HttpOnly cookie plus an `X-CSRF-Token` header injected by htmx (`hx-headers` on ``); required on every non-GET web route. The API is exempt (bearer tokens only) but **MUST** reject cookie-authenticated non-GET requests without the header. - **API tokens**: JWT, HS256, signed with the instance secret. Claims: `sub` (user id), `jti` (row id in `api_tokens`), `scopes`, `iat`, `exp`. Every request validates the signature *and* checks the `jti` row for `revoked_at IS NULL` — this is what makes revocation real. Update `last_used_at` at most once a minute per token. - **Scopes**: `repo:read`, `repo:write`, `repo:admin`, `user:read`, `user:write`, `admin`. - **Rate limiting**: `tower_governor` on `/login`, `/invite/*`, and Basic-auth pack routes; a per-username exponential backoff on failed logins recorded in memory. Permission resolution — one function, used everywhere, unit tested exhaustively: ```rust pub enum Access { None, Read, Write, Admin } pub fn access(viewer: Option<&Viewer>, repo: &Repo) -> Access ``` Rules, in order: 1. `viewer.is_admin` → `Admin`. 2. `viewer.id == repo.owner_id` → `Admin`. 3. explicit `repo_collaborators` row → that permission. 4. `!repo.is_private && instance.allow_anonymous` → `Read`. 5. otherwise `None`. Anonymous viewers can only ever reach `Read`, and only on public repos. Every mutating route asserts at least `Write`; creation routes assert an authenticated viewer. `None` on a private repo **MUST** render `404`, not `403` — do not leak existence. --- ## 7. Mail (`crates/mail`) Required for the MVP: `user add` without `--pass` emails the user an activation link. - `lettre` with the `tokio1-rustls` transport. Backends: `smtp`, `stdout` (development — print the rendered mail and the link), `none` (reject flows that need mail with a clear error). - Templates: plain-text primary, minimal HTML alternative, both rendered from the same data with `instance.name` and `instance.url` interpolated. Ship `activate` and `reset`. - Activation link: `{instance.url}/invite/{token}`, token = 32 random bytes base64url, TTL 72h (config `auth.invite_ttl_hours`), single-use, stored hashed. - Sending is **best-effort and out-of-band**: never block a CLI command or HTTP response on SMTP. Spawn the send, log failures loudly, and always print the activation URL to stdout in the CLI so an operator can deliver it manually when SMTP is down. - Unit tests use the `stdout` backend and assert the rendered body contains a well-formed link. --- ## 8. Highlighting (`crates/highlight`) `inkjet` (tree-sitter) for highlighting. This crate has two hard requirements that drive its design: **line-addressable output** (for blob views with anchors) and **per-side highlighting inside diffs**. Implementation: 1. Resolve the language (§8.1) → `inkjet::Language`. 2. Highlight the full text once, producing a flat sequence of `(byte_range, capture_stack)`. 3. Convert to `Vec`, where each line is `Vec`. Spans that cross a newline are **split**, and the open capture stack is re-opened on the next line. This is the crux: every emitted line must be independently valid HTML with balanced tags. 4. Cache by `(blob_oid, language)` in a bounded `moka` cache. For diffs, highlight the **pre-image and post-image of each file as whole documents** — never per-hunk, or context-dependent grammars produce garbage — then index the resulting lines by line number and zip them with the diff hunks. Added/removed backgrounds are applied to the row, and highlight spans live inside it, exactly as in the reference screenshots. Class names are stable and theme-driven: `hl-keyword`, `hl-function`, `hl-type`, `hl-string`, `hl-number`, `hl-comment`, `hl-constant`, `hl-variable`, `hl-operator`, `hl-punctuation`, `hl-attribute`, `hl-tag`. Map inkjet's capture names onto this fixed set in one table so themes never need to know tree-sitter internals. Document the full list in `docs/theming.md`. Guards: skip files over `ui.max_highlight_bytes`, skip files detected as binary (NUL in the first 8 KiB), skip minified files (any line > 5000 bytes), and fall back to escaped plain text on any grammar panic — highlighting **MUST NOT** be able to fail a page render. Enforce a per-file timeout and fall back on expiry. ### 8.1 Language resolution order 1. `.gitattributes` `fabrica-language=` (own attribute, highest precedence). 2. `.gitattributes` `linguist-language=` (GitHub compatibility). 3. `.gitattributes` `-diff` / `binary` → no highlighting, render as binary. 4. Shebang line (`#!/usr/bin/env python3` → python). 5. Vim/Emacs modeline in the first or last 5 lines. 6. Exact filename (`Dockerfile`, `Makefile`, `flake.lock`, `Cargo.lock`, `PKGBUILD`). 7. Extension, longest match first (`.tar.gz` before `.gz`, `.d.ts` before `.ts`). 8. Plain text. Name matching is case-insensitive with an alias table (`rs`/`rust`, `py`/`python`, `ts`/`typescript`, `nix`, `gleam`, `hcl`/`terraform`, …). An unknown language name in `.gitattributes` logs a debug line and falls through, rather than erroring. Unit tests: one per resolution step, plus a test asserting `fabrica-language` beats `linguist-language` and both beat the extension. --- ## 9. Web UI (`crates/web`) ### 9.1 Stack - `axum` + `tower-http` (compression, tracing, request id, timeout). - `maud` for templates — compile-time-checked, no runtime template files, and it composes naturally into htmx partials. - **htmx 2** for reactivity, vendored into `assets/` (no CDN). A small amount of hand-written vanilla JS (< 200 lines total, in `assets/fabrica.js`) for: the mobile nav drawer, the theme picker, clipboard buttons, and keyboard shortcuts. **No SPA, no WASM, no build step, no npm.** - Every interactive element **MUST** work without JavaScript. htmx enhances; links and forms are the substrate. `hx-boost` is not used for full-page navigation. ### 9.2 Routing Group nesting makes repo paths variable-length, which collides with sub-resources. Resolve it with a **`/-/` separator** (the GitLab convention): everything before `/-/` is the repo path, everything after is the view. ``` / home: your repos + public repos /login /logout /invite/{token} /search?q=&scope= global search /settings profile, password /settings/keys ssh + gpg keys /settings/tokens API tokens /admin/users admin only /healthz /version /assets/* overridable assets /{owner} user page: groups + repos /{owner}/{path..} repo home (or group listing if path resolves to a group) /{owner}/{path..}.git/* pack transport (§3.3) /{owner}/{path..}/-/tree/{ref}/{p..} /{owner}/{path..}/-/blob/{ref}/{p..} /{owner}/{path..}/-/raw/{ref}/{p..} /{owner}/{path..}/-/commits/{ref} /{owner}/{path..}/-/commit/{sha} /{owner}/{path..}/-/commit/{sha}/context (partial: expanded diff context) /{owner}/{path..}/-/branches /{owner}/{path..}/-/tags /{owner}/{path..}/-/search?q= /{owner}/{path..}/-/settings /{owner}/{path..}/-/runs (inert; 404 unless ui.show_runs) ``` Path resolution: given `{owner}` and the segments before `/-/`, look up `repos` by `(owner_id, path)`; on miss, look up `groups` by `(owner_id, path)` and render a group listing; on miss, `404`. One store query each, both indexed. ### 9.3 Layout Match the reference screenshots. **Chrome**: a slim top bar — sidebar toggle at the far left, `{owner}/{repo}` slug next to it, tabs centred (`Code`, `Search`, `Branches`, `Tags`, and `Runs` when enabled), home icon at the right. The left sidebar is an off-canvas drawer holding instance name, organization-free nav (Home, your repos), the theme picker, Settings, and Log out. **Code view**: two panes. Left is the file tree (`Files` header with a `Commits` toggle button on the right); right is the content pane — README rendered on the repo root, blob contents on a file, or the commit list when `Commits` is active. A branch/tag picker pill sits above both. Tree rows carry filetype icons; directories expand in place via htmx rather than navigating. **Commit list**: flat rows — subject, then a `Verified`/`Unverified` badge, then a second line of short sha · author · date. Infinite scroll via `hx-trigger="revealed"` on the last row. **Commit page**: title + badge, short sha, `{author} committed on {datetime}`. A collapsible `Files Changed` section with `N files changed`, `+adds`/`−dels`, and a `Unified`/`Split` toggle. A changed-files tree in the left sidebar that anchors to each file's diff. Each file collapses independently, shows its own `+/−` counts, and offers `expand N hidden lines` rows with up/down directional expanders. **Diff context expansion** hits `/-/commit/{sha}/context?file=&from=&to=&side=`, which returns a rendered partial of highlighted context lines and swaps the expander row via `hx-swap="outerHTML"`. The expander tracks the current window in `data-` attributes so repeated expansion walks outward. Expanding beyond the file bounds removes the expander. **Blob view**: line numbers as a separate non-selectable column, anchors `#L12` and ranges `#L12-L20` (click, then shift-click) with the range highlighted and reflected into the URL, raw and copy buttons, and a byte/line count header. Images render inline; other binaries show a "binary file not shown" notice with a download link. **Branches**: name, `N ahead` vs default (green), default badge, last-updated author and date — per the screenshot. **Tags**: name, short sha, signature badge, updated author and date. ### 9.4 Reactivity patterns | Interaction | Mechanism | | --- | --- | | Directory expand in tree | `hx-get` returns `
    ` children, swapped `innerHTML` | | Blob / README / commit-list pane swap | `hx-get` + `hx-target="#pane"` + `hx-push-url="true"` | | Commit list pagination | `hx-trigger="revealed"` on the sentinel row | | Diff context expansion | `hx-swap="outerHTML"` on the expander row | | Unified ↔ Split | `hx-get` with `?view=split`, re-renders the diff region, persists in a cookie | | Search | `hx-trigger="keyup changed delay:300ms"` into a results region | | Branch/tag picker | `hx-get` filtered list into a popover | | Theme switch | client-side attribute swap + cookie, no request | Handlers detect `HX-Request` and return the partial; the same handler without the header returns the full page. Write this as one helper (`fn respond(page, partial)`) so no route can drift. ### 9.5 Theming and assets - `assets/base.css` — layout and structure, embedded via `rust-embed`. Written entirely against custom properties; contains **no literal colours**. - Themes are single CSS files defining custom properties under `:root` (and optionally `@media (prefers-color-scheme: light)`). Ship `dark` (default, matching the screenshots) and `light` embedded. - At startup, scan `ui.themes_dir` for `*.css`; each becomes a selectable theme named by its file stem. Disk themes override embedded ones with the same name. Rescan on `SIGHUP` and expose `fabrica theme list`. - `ui.assets_dir` overrides any embedded asset by path (`logo.svg`, `favicon.ico`, `custom.css` — the last is injected after the theme if present). Resolution is disk-then-embedded, with path traversal rejected. - Theme selection: `ui.theme` is the instance default; if `ui.allow_theme_choice`, a picker writes a `fabrica_theme` cookie which wins per-visitor. Emit ``. - Token naming, documented in `docs/theming.md`: `--fb-bg`, `--fb-bg-raised`, `--fb-bg-inset`, `--fb-border`, `--fb-fg`, `--fb-fg-muted`, `--fb-accent`, `--fb-success`, `--fb-warning`, `--fb-danger`, `--fb-diff-add-bg`, `--fb-diff-del-bg`, `--fb-diff-add-fg`, `--fb-diff-del-fg`, plus the `--fb-hl-*` family mirroring §8's class list. A theme that sets only these **MUST** produce a complete, coherent UI — that is the acceptance test for themeability. - Fonts: system UI stack for prose, system monospace stack for code. No web fonts, no network requests from any page. ### 9.6 Responsive Single breakpoint at 48rem. Below it: the file tree becomes a drawer over the content pane, the tab bar scrolls horizontally, the commit-page sidebar collapses into a dropdown, diffs force unified regardless of the toggle, and touch targets are ≥ 44px. Test at 380px wide. ### 9.7 Rendering markdown `comrak` with GFM extensions (tables, strikethrough, task lists, autolinks, footnotes), output sanitized with `ammonia`. Heading anchors, relative link and image rewriting to `/-/raw/{ref}/…`, and highlighted fenced code blocks via `crates/highlight`. Raw HTML in markdown is stripped, not escaped-and-shown. README lookup order: `README.md`, `README`, `readme.md`, `README.txt`. --- ## 10. Search Two scopes, one engine, no index for the MVP. - **In-repo** (`/{owner}/{path}/-/search?q=`): walk the tree at the default branch (or `?ref=`), skip binaries, files over 1 MiB, and `linguist-vendored`/`linguist-generated` paths; match content with `grep-searcher` + `grep-regex`, and paths with a substring/fuzzy match. Return grouped results — file path, then matching lines with 1 line of context, highlighted. - **Global** (`/search?q=`): match repo names, group names, usernames, and descriptions first, then run the in-repo content search across every repo the viewer can read, with a bounded worker pool (`search.concurrency`, default 4), a total time budget (`search.timeout_secs`, default 10), and a result cap (`search.max_results`, default 200). When the budget is exhausted, return partial results with an explicit "search timed out, showing partial results" notice — never silently truncate. - **Qualifiers**, parsed with a tiny hand-written parser (unit tested): `repo:owner/path`, `user:name`, `path:glob`, `lang:name`, `case:yes`, `regex:yes`. Bare terms are literal and case-insensitive by default. - Document in `docs/decisions.md` that this is a linear scan appropriate to a private instance, and that a `tantivy` index is the intended path if it stops being fast enough. --- ## 11. API (`crates/api`) `/api/v1`, JSON, `Bearer` JWT. Purpose: automation parity with the CLI, not a public platform. ``` GET /api/v1/version GET /api/v1/user # current token's user GET /api/v1/users # admin POST /api/v1/users # admin DELETE /api/v1/users/{name}?purge= # admin GET /api/v1/users/{name}/keys POST /api/v1/users/{name}/keys DELETE /api/v1/users/{name}/keys/{ordinal} GET /api/v1/repos # visible to the token POST /api/v1/repos GET /api/v1/repos/{owner}/{path..} PATCH /api/v1/repos/{owner}/{path..} DELETE /api/v1/repos/{owner}/{path..}?purge= GET /api/v1/repos/{owner}/{path..}/branches GET /api/v1/repos/{owner}/{path..}/tags GET /api/v1/repos/{owner}/{path..}/commits?ref=&page= GET /api/v1/repos/{owner}/{path..}/commits/{sha} GET /api/v1/repos/{owner}/{path..}/tree/{ref}/{p..} GET /api/v1/repos/{owner}/{path..}/raw/{ref}/{p..} GET /api/v1/groups/{owner} POST /api/v1/groups DELETE /api/v1/groups/{owner}/{path..} GET /api/v1/search?q=&scope= ``` Conventions: `snake_case` fields; errors as `{"error": {"code": "not_found", "message": "…", "details": {}}}` with correct status codes; cursor pagination via `?page=&per_page=` (max 100) plus `X-Total-Count`; `404` (not `403`) for unauthorized private resources; all timestamps RFC 3339 strings on the wire even though they are epoch-ms at rest. --- ## 12. CLI (`crates/cli`) `clap` derive, colour-aware, `--config`, `--log-level`, `--json` (machine-readable output for every list command). All commands operate **directly** on the store and filesystem — no IPC with a running server is required, since SQLite WAL and Postgres both tolerate concurrent writers. Mutations that a live server caches (repo rename, delete, user disable) bump a `cache_epoch` row that `serve` polls every few seconds. ``` fabrica serve [--detach] [--no-migrate] fabrica user add [--pass ] [--admin] [--display-name ] fabrica user del [--purge] fabrica user list fabrica user passwd [--pass ] fabrica user disable | enable fabrica key add --type fabrica key del fabrica key list fabrica repo add [--private|--public] [--description ] [--default-branch ] fabrica repo del [--purge] fabrica repo list [--user ] fabrica repo rename fabrica repo path fabrica group add fabrica group del fabrica group list fabrica token add [--scopes ] [--expires ] fabrica token del fabrica token list fabrica theme list fabrica config check | show fabrica migrate fabrica doctor fabrica gc [--trash-older-than ] fabrica hook post-receive # internal, hidden from help ``` Behaviour notes: - `serve` runs in the **foreground** by default — correct for systemd and Docker — handling `SIGTERM`/`SIGINT` with graceful shutdown (stop accepting, drain in-flight requests up to `server.graceful_shutdown_secs`, wait on active pack subprocesses, close pools). `--detach` double-forks and writes `{data_dir}/fabrica.pid`; there is no `stop` subcommand — signal the pid. Document this deviation from the original `server start`/`server stop` sketch in `docs/decisions.md`. - `user add` without `--pass`: create the user with `password_hash = NULL`, mint an invite, email it, **and** print the activation URL to stdout. - `key add` accepts a literal key, `@path`, or `-` for stdin; parses and validates it before insert (ssh via `ssh-key`, gpg via `sequoia-openpgp`); rejects duplicates by fingerprint with a message naming the existing owner. - `key del ` uses the 1-based `ordinal` shown by `key list`. Ordinals are stable — deleting one does **not** renumber the others; new keys take `max(ordinal) + 1`. State this in the help text. - `repo add ` accepts a group path in `name` (`fabrica repo add hanna backend/api`), creating missing intermediate groups. - `user del` without `--purge` refuses when the user owns repos, listing them; `--purge` deletes repos, keys, tokens, and sessions. - Every destructive command prompts for confirmation on a TTY and requires `--yes` when not attached to one. --- ## 13. Deployment artifacts ### Dockerfile Multi-stage, no Nix in the image: - Builder: `rust:slim` (or `rustlang/rust:nightly-slim`), install `pkg-config`, `libssl-dev`, `cmake`, `git`; `cargo build --release --features vendored`. The `vendored` feature turns on `git2/vendored-libgit2` so the runtime image needs no libgit2. - Cache dependency layers first (copy manifests, build a dummy target, then copy sources) or use `cargo-chef`. - Runtime: `debian:stable-slim` with `git`, `ca-certificates`, `tini`. Non-root user `fabrica` (uid 10001). `VOLUME /var/lib/fabrica`. `EXPOSE 8080 2222`. `HEALTHCHECK CMD ["fabrica","doctor","--quiet"]` or a curl of `/healthz`. `ENTRYPOINT ["/usr/bin/tini","--","fabrica"]`, `CMD ["serve"]`. - Verify `git --version` in the final image at build time so a missing plumbing binary is a build failure, not a runtime surprise. ### compose.yml Two services: `fabrica` (image or `build: .`), and `postgres` behind a `postgres` profile so the default path is pure SQLite. Named volumes for `data` and `repos`. Config supplied by bind-mounting `fabrica.toml` and overriding a couple of keys via `FABRICA__*` env vars, to demonstrate both mechanisms. Map SSH as `2222:2222` with a comment explaining `ssh.clone_port`. Include a commented reverse-proxy block noting that the proxy **MUST NOT** buffer request or response bodies on `/git-upload-pack` (`proxy_buffering off`, `proxy_request_buffering off`) or clones will stall. --- ## 14. Testing Every crate carries unit tests; **no functionality ships untested**. `cargo test` alone must be sufficient (no external services required by default). - **`git`**: build fixture repos programmatically in `tempfile::TempDir` — commits, branches, tags, merges, renames, binary files, a file with CRLF, a submodule entry. Fixtures for signatures: generate an ed25519 key at test time, sign with `ssh-keygen -Y sign`, and ship a small static GPG-signed fixture. Assert all four `SignatureState` variants. - **`highlight`**: snapshot tests (`insta`) over a fixture file per language. A dedicated test asserts a multi-line string literal produces balanced spans on every line. A test asserts a grammar failure degrades to plain text. - **Attributes**: table-driven tests over the precedence rules in §3.5. - **`store`**: run the full suite against SQLite in-memory *and* file-backed; Postgres tests behind `#[cfg_attr(not(feature = "postgres-tests"), ignore)]`, driven by `FABRICA_TEST_POSTGRES_URL`, and exercised in the dev shell. A migration test asserts both dialects' migrations produce equivalent schemas. - **`auth`**: exhaustive matrix over `access()` — every viewer kind × visibility × collaborator permission. This is the security-critical test; enumerate it, don't sample it. - **`web`**: `axum::Router` + `tower::ServiceExt::oneshot` for handler tests. Assert that `HX-Request` yields a partial and its absence a full document, that private repos 404 for anonymous viewers, and that CSRF rejection works. `insta` snapshots for key partials. - **`cli`**: `assert_cmd` + `predicates` against a temp data dir. - **Integration** (`tests/`): boot the server on port 0 and run real git against it — `git clone` over HTTP, `git push`/`git clone` over SSH using a generated key, a push that triggers the post-receive hook, and an HTTP push attempt asserting the 403 with a helpful body. Gate these on `git` and `ssh` being present, skipping with a clear message otherwise. - `cargo-nextest` in the dev shell; keep the full suite under two minutes. --- ## 15. Working conventions ### 15.1 Quality gate After **every** unit of work, all four must pass — no exceptions, no deferrals: ``` cargo fmt --all -- --check cargo check --workspace --all-targets cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` `nix flake check` is the superset and must pass before any phase is called done. Add a `justfile` with `just check` running all four, and use it. ### 15.2 Commits **Conventional Commits**, one logical change per commit, committed **as work happens** rather than batched at the end. - Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `chore`, `style`. - Scope is the crate or area: `feat(git):`, `fix(web):`, `build(nix):`, `docs(theming):`. - Imperative mood, lowercase subject, no trailing period, ≤ 72 chars. - Body explains *why* when non-obvious; footers `BREAKING CHANGE:` and `Refs:` as needed. - **Never commit with failing checks.** If a commit needs a `#[allow]`, justify it in the body. Examples: ``` feat(git): resolve gitattributes from tree blobs feat(ssh): accept git-upload-pack over exec channels fix(highlight): reopen capture stack across line breaks build(nix): wrap the binary with git on PATH test(auth): enumerate the access matrix ``` ### 15.3 Error handling `thiserror` for library errors, one enum per crate; `anyhow` only at the binary boundary (`main.rs`, CLI commands). `tracing` for logging with structured fields; a request-id span on every HTTP request and a session span on every SSH connection. User-facing error pages are templated, themed, and never expose internals — the detail goes to the log with the request id, and the page shows the id. ### 15.4 Docs `docs/decisions.md` is a running log: date, decision, alternatives, rationale. Seed it with the decisions this spec already makes — pack transport via subprocess, id-based repo directories, `/-/` route separator, epoch-ms timestamps, `serve` with no `stop`, linear search. Update it whenever you deviate from this spec. --- ## 16. Implementation phases Each phase ends with the full gate green and its own commits. Do not start a phase before the previous one is clean. 1. **Scaffold** — flake (fenix + crane), workspace with empty crates, `rust-toolchain.toml`, lints, `justfile`, MPL-2.0 `LICENSE` and per-file headers, CI-less `nix flake check` passing. `build: initialize workspace and nix flake` 2. **config** — TOML + env loading, validation, `config check`/`show`, example file, docs. 3. **store** — schema, both migration sets, `Store` trait, both backends, name validation, tests. 4. **auth** — argon2id, sessions, JWT, `access()`, the full permission matrix test. 5. **git reads** — open/init bare, refs, trees, blobs, log, diff, context expansion, branch ahead/behind, fixture harness. 6. **highlight** — inkjet integration, line-aware output, language resolution, attributes resolver, snapshot tests. 7. **git signatures** — SSH and GPG verification against registered keys, all four states. 8. **cli core** — clap tree, `user`/`key`/`repo`/`group`/`token` commands against the store, `doctor`, `migrate`. 9. **mail** — lettre, templates, invite tokens, `stdout` backend, wire into `user add`. 10. **web shell** — axum, maud layout, top bar, drawer, theming pipeline, asset overrides, embedded + disk themes, login/logout/invite, error pages, `/healthz`. 11. **web repo views** — repo home + README, tree, blob with anchors, raw, commit list with badges, branches, tags. htmx partials throughout. 12. **web commit view** — file diffs, changed-files tree, per-file collapse, unified **and** split, highlighted diff rows, context expansion endpoint. 13. **pack transport** — `git` discovery, subprocess wrapper, smart HTTP read routes, the deliberate 403 on HTTP push, hooks installed at init, `hook post-receive`. 14. **ssh** — russh server, host key generation, publickey auth against `keys`, exec dispatch to pack processes, permission enforcement, banner for shell attempts. 15. **search** — in-repo, global, qualifier parser, budgets and partial-result reporting. 16. **api** — `/api/v1` surface, JWT middleware, scopes, error shape. 17. **deployment** — Dockerfile, compose.yml, `packages.container`, NixOS module, deployment docs. 18. **polish** — mobile pass at 380px, caching (`moka` where measured), graceful shutdown, accessibility (focus rings, skip link, ARIA on the drawer and tabs, keyboard nav), a second theme proving `docs/theming.md` is complete. Then, and only then, the CI: HCL config parsed with `hcl-rs`, Docker execution via `bollard`, stages and jobs, log streaming into the reserved `runs` view. --- ## 17. Definition of done for the MVP - `nix build` produces a single wrapped binary; `nix flake check` is green. - `fabrica user add`, `key add`, `repo add` work; the emailed invite completes activation. - `git clone https://…` works for public repos anonymously and private repos with Basic auth. - `git push` over SSH works with a registered key and is rejected for an unregistered one. - `git push` over HTTPS fails with a 403 that tells the user the SSH URL. - The web UI browses trees, blobs, history, branches, and tags with highlighting, and renders commit diffs unified and split with working context expansion and highlighted rows. - Signature badges are correct across all four states. - Nested groups resolve in the UI, in clone URLs, and in the CLI. - Dropping a CSS file in `themes_dir` re-themes the whole UI with no rebuild and no restart beyond a `SIGHUP`. - Changing `instance.name` rebrands the UI everywhere. - The UI is usable at 380px wide. - Docker Compose brings up a working instance from the example config. - Every crate has tests; all four cargo checks pass; every commit is conventional. --- ## 18. Collaboration: issues, pull requests, labels Issues and pull requests are enabled **per repository** (`repos.issues_enabled`, `repos.pulls_enabled`, both default true). When disabled, the nav tab and routes for that feature 404 for the repo. Access follows the existing `auth::access` verdict: any viewer with `Read` may open issues and PRs and comment; `Write` (or the author) may edit/close; `Admin` may manage labels and force-merge. ### 18.1 Scoped labels Labels are per-repo, GitHub-style, and may be **scoped**: a label named `type: backend` has scope `type` and value `backend`. Scoped labels are **mutually exclusive within their scope** on a single issue/PR (assigning `type: frontend` removes `type: backend`). Unscoped labels (`bug`, `good first issue`) stack freely. A label carries a name, an optional description, and a colour (`#rrggbb`); the scope is parsed from the first `": "` in the name. Schema: `labels (id, repo_id, name, name_lower, color, description, created_at)` with a unique index on `(repo_id, name_lower)`; join table `issue_labels (issue_id, label_id)`. ### 18.2 Issues An issue belongs to a repo, has an author, a monotonically-per-repo **number** (`#N`), a title, a markdown body, an open/closed state, optional assignee, labels, and threaded comments (markdown). Numbers are allocated from a per-repo counter (`repos.next_iid` bumped in the same transaction) shared with pull requests, so `#N` is unique across both. Schema: `issues (id, repo_id, number, author_id, title, body, state, is_pull, assignee_id, created_at, updated_at, closed_at)`; `comments (id, issue_id, author_id, body, created_at, updated_at)`. Pull requests are rows in `issues` with `is_pull = 1` plus a `pull_requests` side table (see below), so numbering, labels, and comments are shared. Routes: `/-/issues`, `/-/issues/new`, `/-/issues/{n}`; POST endpoints for create, comment, label toggle, assignee, and state change (all CSRF-guarded, gated on `Write`/author). ### 18.3 Pull requests and merge machinery A pull request compares a **head** ref (branch) against a **base** ref in the same repo (cross-repo forks are a non-goal). It shows the commit list and the combined diff (reusing the diff renderer), supports review comments, and can be **merged server-side** by a `Write`+ collaborator when mergeable, via one of three strategies: **merge commit**, **squash**, or **rebase**. Merge is performed with the spawned `git` subprocess (`git merge`/`git merge --squash`/`git rebase` into a work area, then update the base ref), never libgit2 — consistent with §3.1. A PR is auto-closed when its head is merged; conflicts block the merge with a clear message. Schema: `pull_requests (issue_id, base_ref, head_ref, merged_at, merged_by, merge_sha, merge_strategy)`. Routes: `/-/pulls`, `/-/pulls/new?base=…&head=…`, `/-/pulls/{n}` (with `/commits`, `/files` sub-views); POST for create, comment, review, and merge. ## 19. Accounts, self-registration, and settings ### 19.1 Self-registration Optional, off by default, controlled by `instance.allow_registration` (bool). When on, `/register` offers a sign-up form (username, email, password); on success the account is created directly (no invite) and signed in. An optional **captcha** guards the form: `captcha.provider` (`none` | `hcaptcha` | `recaptcha`), `captcha.site_key`, `captcha.secret_key`; when a provider is set, the widget is rendered and the token is server-verified against the provider before the account is created. Admin invites remain available regardless. ### 19.2 Account settings `/settings` expands into sections: **Profile** (display name, pronouns, bio, location, links, avatar — already built); **Account** (change username, email, password — password change re-verifies the current password and invalidates other sessions); **SSH & GPG keys** (list/add/remove, mirroring the CLI `key` commands); **API tokens** (list/create/revoke, using the existing `api_tokens` table; the JWT is shown once on creation). Every mutation is CSRF-guarded and self-service (a user edits only their own account).