fabrica

hanna/fabrica

feat: initial commit

870bb7b · hanna committed on 2026-07-25

Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +1341 −0UnifiedSplit
CLAUDE.md +175 −0
@@ -0,0 +1,175 @@
1# CLAUDE.md
2
3This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
5## Project status: greenfield
6
7The repository currently contains **only `spec.md`** — a 1166-line implementation contract. No
8code, flake, crates, or `Cargo.toml` exist yet. `spec.md` is the source of truth: read it before
9doing anything. Where it says **MUST**, treat it as a hard requirement; **SHOULD** deviations
10must be logged in `docs/decisions.md` (once that file exists).
11
12`fabrica` is a self-hosted git server: a single Rust binary that serves repos over HTTPS (read)
13and SSH (read + write) with a fast, themeable, htmx-driven web UI. Private by default, no sign-up.
14Build order is fixed — see **Implementation phases** below.
15
16## What this project deliberately is NOT
17
18Do not build: issues, pull/merge requests, merge machinery, GitHub-style orgs, web sign-up/self-
19registration, forks, stars, activity feeds, wikis, releases, git-LFS, webhooks, federation. When
20a feature is ambiguous, choose the simpler behaviour. CI is **deferred** — design the seams for it
21(reserve the `runs` nav slot, `/-/runs` routes, `runs`/`jobs` tables, `post-receive` hook entry
22point) but ship them inert behind `ui.show_runs = false`. Do not implement CI until phase 18 is done.
23
24## Commands
25
26None run today (no code). Once the workspace exists, the **quality gate** below must pass after
27every unit of work — no exceptions, no deferrals:
28
29```
30cargo fmt --all -- --check
31cargo check --workspace --all-targets
32cargo clippy --workspace --all-targets -- -D warnings
33cargo test --workspace
34```
35
36A `justfile` must expose `just check` running all four; use it. `nix flake check` is the superset
37(`checks.{clippy,fmt,test,deny,doc}`) and must be green before any phase is called done. Tests use
38`cargo-nextest` in the dev shell; keep the full suite under two minutes. `cargo test` alone must
39suffice with no external services (Postgres tests are feature-gated behind `postgres-tests` /
40`FABRICA_TEST_POSTGRES_URL`; integration tests gate on `git`/`ssh` being present and skip cleanly).
41
42Toolchain: Rust **nightly** via fenix (pinned in `flake.lock`), edition 2024, resolver 3. Nightly
43is a currency preference only — **no `#![feature(...)]` gates**; code must compile on stable.
44Workspace lints forbid `unsafe_code`; deny `clippy::all`, `unwrap_used`, `panic`, `todo`.
45`unwrap_used` may be locally allowed inside `#[cfg(test)]`.
46
47## Architecture
48
49Single root package `fabrica` (`src/main.rs` = arg-parse → dispatch into `cli`, nothing else).
50All library code lives in `crates/` with **unprefixed** names. Dependency direction is strictly
51one-way and enforced:
52
53```
54model ← store / git / auth ← web / api / ssh / cli
55```
56
57`model` depends on nothing in-tree; **no crate may depend on `web`**. Never name a crate `core`,
58`std`, `alloc`, `test`, or `proc_macro` (sysroot collisions).
59
60Crates: `config` (figment TOML+env), `store` (sqlx, SQLite+Postgres), `model` (domain types, no
61I/O), `git` (libgit2 reads + subprocess pack transport), `highlight` (inkjet/tree-sitter),
62`auth` (argon2id, sessions, JWT), `mail` (lettre), `ssh` (russh), `web` (axum + maud + htmx),
63`api` (`/api/v1` JSON), `cli` (clap).
64
65### The central architectural fact: git plumbing is split
66
67`libgit2` (via `git2`) is **client-only for transport** — it has no server-side `upload-pack`/
68`receive-pack`. So:
69
70- **In-process `git2`**: browsing, log, diff, refs/trees/blobs, signature extraction, `init_bare`.
71- **Spawned `git` subprocess**: clone/fetch (`git upload-pack --stateless-rpc`), push
72 (`git receive-pack`), maintenance (`git gc`/`git repack`). One code path:
73 `git::pack::spawn(kind, repo_path, env, stdio)`.
74
75Therefore the `git` binary **MUST** be on `PATH` at runtime — the Nix derivation wraps the binary
76with git on PATH, the Dockerfile verifies `git --version` at build time. Resolve it once at
77startup (config `git.binary`, require ≥ 2.41), surface in `/healthz` and `fabrica doctor`.
78
79Pack transport rules that are easy to get wrong: **authorize before spawning** (the subprocess
80never makes an access decision); **stream both directions, never buffer a pack in memory**; HTTP
81push (`git-receive-pack`) returns a deliberate `403` pointing at the SSH URL; dumb HTTP is
82disabled. A reverse proxy in front **must not buffer** `/git-upload-pack` bodies or clones stall.
83
84### Repos are stored by id, not name
85
86On disk: `{storage.repo_dir}/{id[0..2]}/{id}.git` where `id` is a lowercase ULID. The **database
87is the only name→path authority**, so renames and group moves are metadata-only. The tree is not
88human-browsable by design — `fabrica repo path <user> <name>` resolves it for operators.
89
90### Groups make repo paths variable-length → the `/-/` separator
91
92Group nesting means repo paths have unknown depth. Routes disambiguate with the GitLab `/-/`
93convention: everything before `/-/` is the repo path, everything after is the view
94(`/{owner}/{path..}/-/blob/{ref}/{p..}`). Path resolution: look up `repos` by `(owner_id, path)`;
95on miss look up `groups` and render a group listing; on miss `404`.
96
97### Store portability (SQLite + Postgres, one schema)
98
99Do **not** use sqlx compile-time-checked macros against two dialects. Define a `Store` trait with
100`Sqlite`/`Postgres` impls (or runtime queries over `AnyPool`); every statement stays in the
101portable SQL subset. Timestamps are `BIGINT` unix **milliseconds** UTC (no `DATETIME`/`TIMESTAMPTZ`).
102Ids are `TEXT` ULIDs (26 chars, lexicographically sortable — use for ordering). No `AUTOINCREMENT`/
103`SERIAL`. Case-insensitive uniqueness via a normalized `*_lower` column + unique index, not collation.
104Migrations: `migrations/{sqlite,postgres}/NNNN_name.sql`, selected at runtime via `sqlx::migrate!`.
105
106### Permission model: one function, exhaustively tested
107
108```rust
109pub enum Access { None, Read, Write, Admin }
110pub fn access(viewer: Option<&Viewer>, repo: &Repo) -> Access
111```
112
113Order: admin → owner → explicit collaborator row → public+anonymous-allowed = Read → None.
114`None` on a private repo **MUST render 404, not 403** — never leak existence (this holds for the
115web UI and the API alike). The `access()` test is the security-critical one: enumerate the full
116matrix (viewer kind × visibility × collaborator permission), don't sample it.
117
118### Highlighting: line-addressable, per-side in diffs
119
120`inkjet`/tree-sitter. Two hard requirements drive the design: **every emitted line must be
121independently valid balanced HTML** (spans crossing a newline are split and the capture stack
122re-opened on the next line — see `fix(highlight): reopen capture stack across line breaks`), and
123diffs highlight the **pre- and post-image of each file as whole documents**, never per-hunk, then
124zip lines with hunks. Stable theme-driven class names (`hl-keyword`, `hl-string`, …) mapped from
125tree-sitter captures in one table. Highlighting **MUST NOT** be able to fail a page render — skip
126oversized/binary/minified files and fall back to escaped plain text on any grammar panic.
127Language resolution has a fixed precedence (§8.1): `.gitattributes fabrica-language` > `linguist-
128language` > shebang > modeline > exact filename > extension (longest match) > plain text.
129
130### Web UI: server-rendered, htmx-enhanced, no build step
131
132`axum` + `maud` (compile-time-checked templates) + **htmx 2 vendored into `assets/`** (no CDN, no
133npm, no WASM, no SPA). Hand-written vanilla JS is capped at < 200 lines total in `assets/fabrica.js`
134(mobile drawer, theme picker, clipboard, keyboard shortcuts). **Every interactive element must work
135without JavaScript** — htmx enhances links/forms, it is not the substrate. Handlers detect the
136`HX-Request` header and return a partial, else the full page; write this as one `respond(page,
137partial)` helper so no route drifts.
138
139Theming re-brands with no rebuild: `assets/base.css` is structure-only with **no literal colours**,
140written entirely against custom properties. Themes are single CSS files defining `--fb-*` tokens;
141drop one in `ui.themes_dir` and `SIGHUP` to load it. A theme that sets only the documented `--fb-*`
142tokens MUST yield a complete UI — that is the themeability acceptance test. No web fonts, no network
143requests from any page.
144
145## CLI operates directly on the store — no IPC with the server
146
147Every `fabrica` subcommand touches the store/filesystem directly (SQLite WAL and Postgres both
148tolerate concurrent writers); there is no IPC with a running `serve`. Mutations a live server
149caches (repo rename/delete, user disable) bump a `cache_epoch` row that `serve` polls. `serve` runs
150in the **foreground** by default (correct for systemd/Docker); there is no `stop` subcommand —
151signal the pid. Destructive commands prompt on a TTY and require `--yes` otherwise.
152
153## Working conventions
154
155- **Commits**: Conventional Commits, one logical change each, committed **as work happens** (not
156 batched). Scope is the crate/area: `feat(git):`, `fix(web):`, `build(nix):`. Imperative, lowercase,
157 ≤ 72 chars, no trailing period. **Never commit with failing checks** — a needed `#[allow]` is
158 justified in the commit body.
159- **Errors**: `thiserror` (one enum per crate) in libraries; `anyhow` only at the binary boundary
160 (`main.rs`, CLI). `tracing` with structured fields — request-id span per HTTP request, session
161 span per SSH connection. User-facing error pages are templated/themed, never expose internals
162 (detail → log with the request id, page shows the id).
163- **Docs**: `docs/decisions.md` is a running log (date, decision, alternatives, rationale). Seed it
164 with the decisions the spec already makes (subprocess pack transport, id-based repo dirs, `/-/`
165 separator, epoch-ms timestamps, `serve` with no `stop`, linear search). Update on any deviation.
166
167## Implementation phases (build in this order)
168
169Each phase ends with the full gate green and its own commits; do not start a phase before the
170previous is clean:
171
1721. Scaffold (flake + fenix + crane, empty crates, lints, justfile, MPL-2.0) 2. config 3. store
1734. auth 5. git reads 6. highlight 7. git signatures 8. cli core 9. mail 10. web shell 11. web repo
174views 12. web commit view 13. pack transport 14. ssh 15. search 16. api 17. deployment 18. polish.
175Then, and only then, CI (HCL config via `hcl-rs`, Docker execution via `bollard`).
spec.md +1166 −0
@@ -0,0 +1,1166 @@
1# fabrica — implementation spec
2
3`fabrica` (Latin: *forge*) is a self-hosted git server. Single binary, private by default, no
4sign-up. It serves repositories over HTTPS (read) and SSH (read + write) and provides a fast,
5themeable web UI for browsing code.
6
7This document is the implementation contract. Follow it section by section. Where it says
8**MUST**, treat it as a hard requirement; where it says **SHOULD**, deviate only with a note in
9`docs/decisions.md`.
10
11---
12
13## 1. Goals and non-goals
14
15### Goals
16
17- Host git repositories for a small, fixed set of users (personal / small-team private instance).
18- Browse code, history, diffs, branches, and tags in a UI that is fast on desktop and mobile.
19- Syntax highlighting everywhere code appears, including inside diffs.
20- Show whether each commit is cryptographically signed, via SSH or GPG.
21- Organize repositories under nested, user-owned groups.
22- Re-theme and re-brand without recompiling.
23- One binary, one config file, one data directory.
24
25### Non-goals (do not build these)
26
27- **Issues.** Not planned.
28- **Pull requests / merge requests.** Not planned. No merge machinery, no review flow.
29- **Organizations** in the GitHub sense. Repos belong to a user; groups provide the hierarchy.
30- Web sign-up, public registration, or self-service account creation of any kind.
31- Forks, stars, followers, activity feeds, notifications, wikis, releases-as-a-feature,
32 git-LFS, webhooks, mirroring, federation.
33- Being Forgejo/Gitea/GitLab/GitHub. When a feature is ambiguous, choose the simpler behaviour.
34
35### Deferred (design for, do not implement)
36
37- **CI.** Config in **HCL** (not YAML), Docker as the execution backend for stages and jobs.
38 Reserve the `runs` nav slot, the `/-/runs` route family, the `runs`/`jobs` tables, and the
39 `post-receive` hook entry point. Ship them inert behind `ui.show_runs = false`.
40
41---
42
43## 2. Toolchain, build, and repository layout
44
45### 2.1 Workspace
46
47Root package `fabrica` produces the single binary. All library code lives in `crates/`, with
48**unprefixed crate names**:
49
50```
51fabrica/
52├── flake.nix
53├── flake.lock
54├── Cargo.toml # workspace root, package `fabrica`, src/main.rs only
55├── Cargo.lock
56├── rust-toolchain.toml
57├── src/main.rs # arg parse -> dispatch into `cli`; nothing else
58├── crates/
59│ ├── config/ # TOML + env config load, validation, path resolution
60│ ├── store/ # database: schema, migrations, queries (SQLite + Postgres)
61│ ├── model/ # shared domain types, no I/O
62│ ├── git/ # libgit2 reads, attributes, signatures, pack transport spawn
63│ ├── highlight/ # inkjet/tree-sitter, language resolution, line-aware output
64│ ├── auth/ # argon2id, sessions, JWT, permission checks
65│ ├── mail/ # SMTP + templates
66│ ├── ssh/ # russh server, publickey auth, exec -> pack processes
67│ ├── web/ # axum router, maud templates, htmx partials, assets, theming
68│ ├── api/ # /api/v1 JSON handlers
69│ └── cli/ # clap command tree, all subcommands
70├── assets/ # embedded defaults (base.css, theme.css, htmx.min.js, icons)
71├── migrations/
72│ ├── sqlite/
73│ └── postgres/
74├── docs/
75│ ├── configuration.md
76│ ├── theming.md
77│ ├── deployment.md
78│ └── decisions.md
79├── Dockerfile
80├── compose.yml
81├── fabrica.example.toml
82└── LICENSE # MPL-2.0
83```
84
85Naming constraints: **MUST NOT** name a crate `core`, `std`, `alloc`, `test`, or `proc_macro` —
86these collide with sysroot crates. `git` and `api` are fine.
87
88Dependency direction is strictly one-way: `model` ← `store`/`git`/`auth` ← `web`/`api`/`ssh`/`cli`.
89No crate may depend on `web`. `model` depends on nothing in-tree.
90
91### 2.2 Toolchain
92
93- Rust **nightly** via **fenix**, pinned through `flake.lock`. Nightly is a currency preference,
94 not a feature dependency: the code **MUST NOT** use `#![feature(...)]` gates. It should compile
95 on current stable too; treat any nightly-only construct as a bug.
96- `rust-toolchain.toml` declares `channel = "nightly"` with components `rustfmt`, `clippy`,
97 `rust-src`, `rust-analyzer` for non-Nix users.
98- Edition 2024, resolver 3.
99- Workspace lints in root `Cargo.toml`:
100
101```toml
102[workspace.lints.rust]
103unsafe_code = "forbid"
104missing_docs = "warn"
105
106[workspace.lints.clippy]
107all = { level = "deny", priority = -1 }
108pedantic = "warn"
109unwrap_used = "deny"
110expect_used = "warn"
111panic = "deny"
112todo = "deny"
113```
114
115`unwrap_used` is denied in library code; `#[cfg(test)]` blocks may allow it locally.
116
117### 2.3 Nix flake
118
119Inputs: `nixpkgs` (unstable), `fenix`, `crane`, `flake-utils` (or `flake-parts` — pick one and
120be consistent).
121
122The flake **MUST** expose:
123
124- `packages.default` — the wrapped binary.
125- `packages.fabrica` — alias of the above.
126- `packages.container` — `pkgs.dockerTools.buildLayeredImage`, as a Nix-native alternative to
127 the `Dockerfile`.
128- `devShells.default` — toolchain + `sqlx-cli`, `sqlite`, `postgresql`, `git`, `openssh`,
129 `gnupg`, `cargo-nextest`, `cargo-deny`, `just`.
130- `checks.{clippy,fmt,test,deny,doc}` — so `nix flake check` runs the full gate.
131- `formatter` — `nixpkgs-fmt` or `alejandra`.
132
133Build shape:
134
135```nix
136craneLib = (crane.mkLib pkgs).overrideToolchain fenixToolchain;
137commonArgs = {
138 src = craneLib.cleanCargoSource ./.;
139 strictDeps = true;
140 nativeBuildInputs = [ pkgs.pkg-config pkgs.makeWrapper ];
141 buildInputs = [ pkgs.libgit2 pkgs.zlib pkgs.openssl ];
142 # use system libgit2 rather than the vendored copy
143 LIBGIT2_NO_VENDOR = "1";
144};
145cargoArtifacts = craneLib.buildDepsOnly commonArgs;
146```
147
148The final derivation **MUST** wrap the binary so the git plumbing is always reachable:
149
150```nix
151postInstall = ''
152 wrapProgram $out/bin/fabrica \
153 --prefix PATH : ${lib.makeBinPath [ pkgs.git ]}
154'';
155```
156
157Also expose a **NixOS module** at `nixosModules.default` providing `services.fabrica` with
158`enable`, `settings` (TOML-typed, rendered via `pkgs.formats.toml`), `user`, `group`,
159`dataDir`, `environmentFile` (for secrets), a hardened `systemd.services.fabrica` unit
160(`DynamicUser` off, `StateDirectory=fabrica`, `ProtectSystem=strict`, `NoNewPrivileges`,
161`AmbientCapabilities=CAP_NET_BIND_SERVICE` only if a privileged port is configured), and
162`openFirewall`.
163
164---
165
166## 3. Git layer (`crates/git`)
167
168### 3.1 Division of responsibility — read this carefully
169
170`libgit2` (via `git2`) is the object-model library: refs, trees, blobs, commits, revwalks,
171diffs, attributes, signature extraction. **libgit2 does not implement the server side of the
172pack protocol.** There is no `upload-pack`/`receive-pack` in libgit2 — it is client-only for
173transport. Therefore:
174
175| Concern | Implementation |
176| --- | --- |
177| Browsing, log, diff, blame-free reads | `git2` in-process |
178| Signature extraction | `git2` (`Repository::extract_signature`) |
179| Attribute resolution | own resolver over tree blobs (§3.5) |
180| Clone/fetch (HTTPS) | spawn `git upload-pack --stateless-rpc` |
181| Push (SSH) | spawn `git receive-pack` |
182| Fetch over SSH | spawn `git upload-pack` |
183| Repo creation, gc, repack | `git2` for init; spawn `git gc`/`git repack` for maintenance |
184
185`git` **MUST** be present on `PATH` at runtime. Resolve it once at startup:
186
187- config key `git.binary` (default `"git"`),
188- resolve via `which`, verify `git --version` >= 2.41,
189- fail startup with a clear, actionable error if absent.
190
191Record the discovered path and version in `/healthz` output and in `fabrica doctor`.
192
193### 3.2 On-disk layout
194
195Repositories are stored by **id**, not by name, so renames and group moves never touch the
196filesystem:
197
198```
199{storage.repo_dir}/{id[0..2]}/{id}.git
200```
201
202where `id` is a lowercase ULID. The database is the only name→path authority. Document the
203tradeoff in `docs/decisions.md`: the tree is not human-browsable, in exchange for atomic,
204metadata-only renames. `fabrica repo path <user> <name>` prints the resolved directory for
205operators.
206
207Repo creation:
208
2091. `git2::Repository::init_bare`.
2102. Set `HEAD` to `refs/heads/{default_branch}` (config: `instance.default_branch`, default `main`).
2113. Write config: `core.logAllRefUpdates=true`, `receive.denyNonFastForwards=false`,
212 `gc.auto=0` (maintenance is driven by fabrica, not by pushes).
2134. Install hooks (§3.4).
2145. Insert the DB row in the same transaction; on any failure, remove the directory.
215
216Deletion moves the directory to `{storage.data_dir}/trash/{id}-{timestamp}.git` and deletes the
217row; a `fabrica gc --trash-older-than 30d` command reaps it. `repo del --purge` skips the trash.
218
219### 3.3 Pack transport
220
221Everything below shares one code path: `git::pack::spawn(kind, repo_path, env, stdio)`.
222
223**Common rules**
224
225- Never interpolate a user-supplied path into a shell. Use `tokio::process::Command` with
226 explicit args, no shell.
227- Authorize *before* spawning. The subprocess never makes an access decision.
228- Set on every child: `GIT_DIR={resolved path}`, `GIT_PROTOCOL` passed through when the client
229 requested v2, `GIT_TERMINAL_PROMPT=0`, `GIT_CONFIG_NOSYSTEM=1`, `HOME={data_dir}/tmp`,
230 and the identity vars `FABRICA_USER_ID`, `FABRICA_USER_NAME`, `FABRICA_REPO_ID`,
231 `FABRICA_KEY_ID` (for hook consumption).
232- Enforce `git.max_pack_size_bytes` (default 512 MiB) and `git.transport_timeout_secs`
233 (default 3600); kill the process group on timeout and log it.
234- Clean up: always `kill_on_drop(true)`; reap children.
235
236**HTTPS smart protocol (read-only)**
237
238Implement only the v0/v1/v2 smart paths; dumb HTTP **MUST** be disabled.
239
240```
241GET /{owner}/{path}.git/info/refs?service=git-upload-pack
242POST /{owner}/{path}.git/git-upload-pack
243```
244
245- `info/refs`: spawn `git upload-pack --stateless-rpc --advertise-refs {dir}`, prefix the body
246 with the pkt-line `# service=git-upload-pack\n` + flush, `Content-Type:
247 application/x-git-upload-pack-advertisement`, `Cache-Control: no-cache`.
248- `git-upload-pack`: stream the request body to the child's stdin and the child's stdout back as
249 the response body, `Content-Type: application/x-git-upload-pack-result`. **Stream both
250 directions** — never buffer a pack in memory. Support `Content-Encoding: gzip` on the request.
251- Also accept the same routes without the `.git` suffix.
252- `POST /{owner}/{path}.git/git-receive-pack` and `info/refs?service=git-receive-pack`
253 **MUST** return `403` with a plain-text body explaining that push over HTTPS is disabled and
254 giving the correct SSH remote URL. This is a deliberate, discoverable failure.
255- Private repos require auth: HTTP Basic with username + (password or API token), or a session
256 cookie. Anonymous access to a private repo returns `401` with
257 `WWW-Authenticate: Basic realm="fabrica"`.
258
259**SSH (read + write)**
260
261`crates/ssh` runs a `russh` server.
262
263- Host key at `ssh.host_key_path`; generate an ed25519 key on first start with mode `0600`.
264- Only `publickey` auth. `auth_password` and `auth_none` always reject. Offer ed25519,
265 ecdsa-sha2-nistp256, and rsa-sha2-256/512; refuse `ssh-rsa` (SHA-1) and DSA.
266- On offered key: compute the SHA256 fingerprint, look it up in `keys` where `kind = 'ssh'`.
267 Unknown fingerprint → reject. Match → bind the user to the session, update `last_used_at`.
268- Handle the `exec` channel request only. Parse the command with shell-word splitting and accept
269 exactly:
270 - `git-upload-pack '<path>'`
271 - `git-receive-pack '<path>'`
272 - `git-upload-archive '<path>'`
273 Anything else (including an interactive shell request) → write a friendly banner to stderr
274 (`Hi {user}! fabrica does not provide shell access.`), exit status 1, close.
275- Normalize `<path>`: strip a leading `/` and a trailing `.git`, reject `..` and absolute paths,
276 resolve `owner/group…/repo` through the store.
277- Permission: `upload-pack` needs `read`, `receive-pack` needs `write`. Deny with a clear
278 stderr message and non-zero exit.
279- Pipe channel data ↔ child stdio; forward child stderr to the channel's extended data so
280 hook output reaches the user's terminal. Send `exit-status` on completion.
281- Support `ssh.clone_host` / `ssh.clone_port` for the clone URLs shown in the UI, separate from
282 the bind address, so a reverse proxy or port forward can be reflected accurately.
283
284### 3.4 Hooks
285
286Install `hooks/post-receive` (and a no-op `pre-receive` reserved for future branch protection)
287as a two-line script:
288
289```sh
290#!/bin/sh
291exec fabrica hook post-receive
292```
293
294`fabrica hook post-receive` reads the `<old> <new> <ref>` lines from stdin and:
295
296- updates `repos.pushed_at`, `repos.size_bytes`, and the cached default-branch head,
297- invalidates the repo's caches,
298- enqueues a `runs` row **only** when CI ships (inert for the MVP),
299- exits 0 even on internal error, logging loudly — a hook failure **MUST NOT** reject a push
300 that git already accepted.
301
302The hook path must locate the running binary reliably: write the absolute path of the current
303executable at repo-init time (`std::env::current_exe`), falling back to `git.hook_binary` config.
304
305### 3.5 Attributes (`.gitattributes`)
306
307libgit2's attribute lookup is oriented toward a working tree; bare repos and per-ref accuracy
308make it unreliable here. Implement an own resolver:
309
310- For a given `(repo, tree_oid)`, walk the tree and collect every `.gitattributes` blob with its
311 directory prefix.
312- Parse into ordered rules (pattern, attribute assignments), respecting gitattributes semantics:
313 later rules win, deeper directories win over shallower, `!` negation, `/`-anchored patterns,
314 `**`, and macro-free simple assignment (`key=value`, `key`, `-key`, `!key`).
315- Match with `globset`, honouring the "no slash in pattern → match basename at any depth" rule.
316- Cache resolved rule sets in a `moka` cache keyed by `(repo_id, tree_oid)`.
317- Consumers: language override for highlighting, `binary`/`-text` for "binary file, not shown",
318 `linguist-generated` / `fabrica-generated` for collapsing diffs by default,
319 `linguist-vendored` for excluding from search.
320
321Unit tests **MUST** cover: precedence between nested files, negation, anchored vs floating
322patterns, and `-diff` suppressing diff rendering.
323
324### 3.6 Signature verification
325
326Read the signature with `Repository::extract_signature(oid, None)`, which yields
327`(signature, signed_payload)`. Dispatch on the armor header.
328
329**SSH** (`-----BEGIN SSH SIGNATURE-----`): parse with the `ssh-key` crate's `SshSig`. Verify with
330namespace `"git"` against candidate public keys. Reject if the sig's hash algorithm is not
331sha256/sha512.
332
333**GPG** (`-----BEGIN PGP SIGNATURE-----`): verify with `sequoia-openpgp` against registered
334public keys. Check the signature's creation time against key validity and expiry; treat expired
335or revoked keys as unverified, not verified.
336
337Result type:
338
339```rust
340pub enum SignatureState {
341 Unsigned,
342 /// Valid signature, key registered on this instance.
343 Verified { user_id: Ulid, key_id: Ulid, kind: KeyKind, fingerprint: String },
344 /// Valid signature, key not known to this instance.
345 UnknownKey { kind: KeyKind, fingerprint: String },
346 /// Signature present but cryptographically invalid, malformed, or expired.
347 Invalid { kind: Option<KeyKind>, reason: String },
348}
349```
350
351UI mapping, matching the reference screenshots: `Verified` renders a green **Verified** badge;
352everything else that carries a signature renders an amber **Unverified** badge; `Unsigned`
353renders an amber **Unverified** badge as well. The tooltip / detail line distinguishes the four
354cases precisely ("Signed by an unrecognized key `SHA256:…`", "Signature could not be verified:
355…", "This commit is not signed"). Do not silently collapse `Invalid` into `Unsigned`.
356
357Verification is expensive; cache per commit oid in a bounded `moka` cache and, for list views,
358verify lazily in a bounded-concurrency batch (`ui.signature_batch_concurrency`, default 8).
359Additionally verify tag signatures for annotated tags and show the same badge on the tags page.
360
361### 3.7 Reads
362
363Expose a narrow, testable API — no `git2` types in the public signatures, so `web` never links
364against libgit2 semantics:
365
366```rust
367pub fn resolve_ref(&self, r: &str) -> Result<Resolved>; // branch | tag | oid | HEAD
368pub fn tree_entries(&self, rev: &Resolved, path: &Path) -> Result<Vec<TreeEntry>>;
369pub fn blob(&self, rev: &Resolved, path: &Path) -> Result<Blob>; // + is_binary, size, lfs-ignored
370pub fn commits(&self, rev: &Resolved, path: Option<&Path>, page: Page) -> Result<Vec<CommitSummary>>;
371pub fn commit(&self, oid: Oid) -> Result<CommitDetail>;
372pub fn diff(&self, base: Option<Oid>, head: Oid, opts: DiffOpts) -> Result<FileDiffs>;
373pub fn diff_context(&self, head: Oid, file: &Path, from: u32, to: u32) -> Result<Vec<DiffLine>>;
374pub fn branches(&self) -> Result<Vec<BranchInfo>>; // + ahead/behind vs default
375pub fn tags(&self) -> Result<Vec<TagInfo>>;
376pub fn last_commit_per_entry(&self, rev: &Resolved, path: &Path) -> Result<HashMap<..>>;
377```
378
379Notes:
380
381- Blocking libgit2 calls **MUST** run on `tokio::task::spawn_blocking`, or on a dedicated
382 `rayon` pool with a bounded queue. Never block the async runtime.
383- `Repository` handles are not `Sync`; use a small per-repo pool or open-per-operation. Measure
384 before optimizing; open-per-operation is acceptable for the MVP if documented.
385- Branch ahead/behind uses `graph_ahead_behind` against the default branch (the screenshot shows
386 `10 ahead`).
387- `last_commit_per_entry` powers the file tree's "latest change" column; it is the classic
388 performance trap. Implement as a single revwalk with path filtering, capped by
389 `ui.tree_history_limit` (default 1000 commits), and degrade gracefully to no column when the
390 cap is hit.
391
392---
393
394## 4. Configuration (`crates/config`)
395
396TOML file plus environment overrides via `figment`. Resolution order (later wins): built-in
397defaults → `/etc/fabrica/fabrica.toml` → `$XDG_CONFIG_HOME/fabrica/fabrica.toml` → `--config
398<path>` → `FABRICA__*` env vars. Nested keys use double underscores:
399`FABRICA__SERVER__PORT=8080`.
400
401Any `*_file` key reads its value from a file (for agenix/systemd-creds/Docker secrets) and takes
402precedence over its inline sibling. Secrets **MUST NOT** be logged, echoed by `config show`
403(print `<redacted>`), or included in error messages.
404
405`fabrica.example.toml` — ship this, fully commented:
406
407```toml
408[instance]
409name = "fabrica" # shown in the header and page titles
410url = "https://git.example.com"
411description = "A small, private forge."
412default_branch = "main"
413allow_anonymous = true # anonymous browse + clone of public repos
414
415[server]
416address = "0.0.0.0"
417port = 8080
418behind_proxy = false # trust X-Forwarded-For / X-Forwarded-Proto
419graceful_shutdown_secs = 20
420max_body_bytes = 1048576 # non-pack routes only
421
422[ssh]
423enabled = true
424address = "0.0.0.0"
425port = 2222
426host_key_path = "/var/lib/fabrica/ssh/host_ed25519"
427clone_host = "git.example.com" # what the UI shows, may differ from bind
428clone_port = 22
429
430[storage]
431data_dir = "/var/lib/fabrica"
432repo_dir = "/var/lib/fabrica/repos"
433
434[database]
435url = "sqlite:///var/lib/fabrica/fabrica.db"
436max_connections = 16
437# SQLite is opened with journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON,
438# busy_timeout=5000. Postgres URLs (postgres://…) are detected automatically.
439
440[auth]
441secret_file = "/var/lib/fabrica/secret" # HS256 key, generated on first run, 0600
442session_ttl_days = 30
443token_ttl_days = 90
444cookie_name = "fabrica_session"
445cookie_secure = true
446argon2_m_cost = 19456
447argon2_t_cost = 2
448argon2_p_cost = 1
449
450[mail]
451backend = "smtp" # smtp | stdout | none
452from = "fabrica@example.com"
453host = "smtp.example.com"
454port = 587
455username = "fabrica"
456password_file = "/run/secrets/fabrica-smtp"
457encryption = "starttls" # starttls | tls | none
458timeout_secs = 15
459
460[ui]
461theme = "dark" # file stem in themes_dir, or an embedded theme name
462themes_dir = "/var/lib/fabrica/themes"
463assets_dir = "/var/lib/fabrica/assets"
464allow_theme_choice = true # show the theme picker; persists in a cookie
465show_runs = false # CI nav slot, inert until the CI ships
466diff_context_lines = 3
467max_highlight_bytes = 1048576
468max_diff_bytes = 5242880
469tree_history_limit = 1000
470
471[git]
472binary = "git"
473transport_timeout_secs = 3600
474max_pack_size_bytes = 536870912
475
476[log]
477level = "info" # trace|debug|info|warn|error, RUST_LOG also honoured
478format = "pretty" # pretty | json
479```
480
481Validation at startup **MUST** be strict and fail fast with actionable messages: unknown keys
482are an error (catch typos), `instance.url` must parse and must not have a trailing slash,
483directories must exist or be creatable, `mail.backend = "smtp"` requires host/from,
484`auth.cookie_secure = true` with a non-HTTPS `instance.url` is a warning.
485
486`fabrica config check` validates and exits; `fabrica config show` prints the effective merged
487config with secrets redacted.
488
489---
490
491## 5. Data model (`crates/store`)
492
493`sqlx` with both backends. Do **not** use compile-time-checked macros against two dialects —
494define a `Store` trait with `Sqlite` and `Postgres` implementations behind it, or use runtime
495queries over `AnyPool`; keep every statement inside the portable SQL subset.
496
497Portability rules, enforced by review:
498
499- Timestamps are `BIGINT` unix **milliseconds**, UTC. No `DATETIME`, no `TIMESTAMPTZ`.
500- Ids are `TEXT` ULIDs (26 chars, lexicographically sortable — use them for ordering).
501- Booleans are `INTEGER` 0/1 in SQLite, `BOOLEAN` in Postgres; the trait maps them.
502- No `AUTOINCREMENT`, no `SERIAL`, no `RETURNING` outside a helper that both dialects support
503 (both do support `RETURNING` — using it is fine).
504- Case-insensitive uniqueness on usernames and emails is enforced by storing a normalized
505 `*_lower` column with a unique index, not by collation.
506
507Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`, applied with `sqlx::migrate!`
508selected at runtime. `fabrica migrate` runs them; `serve` runs them automatically unless
509`--no-migrate`.
510
511### Schema
512
513```sql
514users (
515 id TEXT PRIMARY KEY,
516 username TEXT NOT NULL, username_lower TEXT NOT NULL UNIQUE,
517 email TEXT NOT NULL, email_lower TEXT NOT NULL UNIQUE,
518 display_name TEXT,
519 password_hash TEXT, -- NULL until the invite is completed
520 must_change_password INTEGER NOT NULL DEFAULT 0,
521 is_admin INTEGER NOT NULL DEFAULT 0,
522 disabled_at BIGINT,
523 created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL
524)
525
526invites ( -- password-setup / activation links
527 id TEXT PRIMARY KEY,
528 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
529 token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the emailed token
530 purpose TEXT NOT NULL, -- 'activate' | 'reset'
531 expires_at BIGINT NOT NULL, used_at BIGINT,
532 created_at BIGINT NOT NULL
533)
534
535sessions (
536 id TEXT PRIMARY KEY, -- SHA-256 of the cookie value
537 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
538 user_agent TEXT, ip TEXT,
539 created_at BIGINT NOT NULL, last_seen_at BIGINT NOT NULL, expires_at BIGINT NOT NULL
540)
541
542api_tokens (
543 id TEXT PRIMARY KEY, -- the JWT `jti`
544 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
545 name TEXT NOT NULL,
546 scopes TEXT NOT NULL, -- comma-separated
547 expires_at BIGINT, revoked_at BIGINT, last_used_at BIGINT,
548 created_at BIGINT NOT NULL
549)
550
551keys (
552 id TEXT PRIMARY KEY,
553 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
554 kind TEXT NOT NULL, -- 'ssh' | 'gpg'
555 name TEXT,
556 fingerprint TEXT NOT NULL, -- SHA256:… for ssh, full hex fp for gpg
557 public_key TEXT NOT NULL, -- authorized_keys line or ASCII-armored pgp block
558 ordinal INTEGER NOT NULL, -- stable 1-based index per (user, kind) for `key del`
559 last_used_at BIGINT,
560 created_at BIGINT NOT NULL,
561 UNIQUE (kind, fingerprint),
562 UNIQUE (user_id, kind, ordinal)
563)
564
565groups (
566 id TEXT PRIMARY KEY,
567 owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
568 parent_id TEXT REFERENCES groups(id) ON DELETE CASCADE,
569 name TEXT NOT NULL,
570 path TEXT NOT NULL, -- materialized 'group/sub/leaf'
571 description TEXT,
572 created_at BIGINT NOT NULL,
573 UNIQUE (owner_id, path)
574)
575
576repos (
577 id TEXT PRIMARY KEY,
578 owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
579 group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
580 name TEXT NOT NULL,
581 path TEXT NOT NULL, -- materialized 'group/sub/repo' (== name when ungrouped)
582 description TEXT,
583 is_private INTEGER NOT NULL DEFAULT 1,
584 default_branch TEXT NOT NULL,
585 archived_at BIGINT,
586 size_bytes BIGINT NOT NULL DEFAULT 0,
587 pushed_at BIGINT,
588 created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL,
589 UNIQUE (owner_id, path)
590)
591
592repo_collaborators (
593 repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
594 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
595 permission TEXT NOT NULL, -- 'read' | 'write' | 'admin'
596 created_at BIGINT NOT NULL,
597 PRIMARY KEY (repo_id, user_id)
598)
599
600-- reserved, inert until the CI ships
601runs (id TEXT PRIMARY KEY, repo_id TEXT NOT NULL, ref TEXT NOT NULL, commit_sha TEXT NOT NULL,
602 status TEXT NOT NULL, started_at BIGINT, finished_at BIGINT, created_at BIGINT NOT NULL)
603jobs (id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
604 stage TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL,
605 log_path TEXT, started_at BIGINT, finished_at BIGINT)
606```
607
608Indexes: `repos(owner_id, path)`, `repos(is_private, pushed_at DESC)`, `groups(owner_id, parent_id)`,
609`keys(user_id, kind)`, `sessions(user_id)`, `sessions(expires_at)`, `api_tokens(user_id)`.
610
611Name validation (single place in `model`, used by CLI, API, and web): usernames and repo/group
612names match `^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$`, must not be `.`/`..`, must not end in `.git`
613or `.`, and are rejected against a reserved list: `api`, `assets`, `login`, `logout`, `search`,
614`settings`, `admin`, `static`, `healthz`, `invite`, `-`.
615
616---
617
618## 6. Auth and permissions (`crates/auth`)
619
620- **Passwords**: `argon2` crate, Argon2id, params from config. Hashes stored in PHC string
621 format. Verification is constant-time; on a missing user, still perform a dummy hash to avoid
622 timing disclosure.
623- **Sessions**: 32 bytes from `OsRng`, base64url-encoded in the cookie; the DB stores only the
624 SHA-256. Cookie is `HttpOnly`, `SameSite=Lax`, `Secure` per config, `Path=/`. Rotate the id on
625 login and on password change; delete all sessions for the user on password change.
626- **CSRF**: double-submit token in a non-HttpOnly cookie plus an `X-CSRF-Token` header injected
627 by htmx (`hx-headers` on `<body>`); required on every non-GET web route. The API is exempt
628 (bearer tokens only) but **MUST** reject cookie-authenticated non-GET requests without the
629 header.
630- **API tokens**: JWT, HS256, signed with the instance secret. Claims: `sub` (user id), `jti`
631 (row id in `api_tokens`), `scopes`, `iat`, `exp`. Every request validates the signature *and*
632 checks the `jti` row for `revoked_at IS NULL` — this is what makes revocation real. Update
633 `last_used_at` at most once a minute per token.
634- **Scopes**: `repo:read`, `repo:write`, `repo:admin`, `user:read`, `user:write`, `admin`.
635- **Rate limiting**: `tower_governor` on `/login`, `/invite/*`, and Basic-auth pack routes; a
636 per-username exponential backoff on failed logins recorded in memory.
637
638Permission resolution — one function, used everywhere, unit tested exhaustively:
639
640```rust
641pub enum Access { None, Read, Write, Admin }
642pub fn access(viewer: Option<&Viewer>, repo: &Repo) -> Access
643```
644
645Rules, in order:
646
6471. `viewer.is_admin` → `Admin`.
6482. `viewer.id == repo.owner_id` → `Admin`.
6493. explicit `repo_collaborators` row → that permission.
6504. `!repo.is_private && instance.allow_anonymous` → `Read`.
6515. otherwise `None`.
652
653Anonymous viewers can only ever reach `Read`, and only on public repos. Every mutating route
654asserts at least `Write`; creation routes assert an authenticated viewer. `None` on a private
655repo **MUST** render `404`, not `403` — do not leak existence.
656
657---
658
659## 7. Mail (`crates/mail`)
660
661Required for the MVP: `user add` without `--pass` emails the user an activation link.
662
663- `lettre` with the `tokio1-rustls` transport. Backends: `smtp`, `stdout` (development — print
664 the rendered mail and the link), `none` (reject flows that need mail with a clear error).
665- Templates: plain-text primary, minimal HTML alternative, both rendered from the same data with
666 `instance.name` and `instance.url` interpolated. Ship `activate` and `reset`.
667- Activation link: `{instance.url}/invite/{token}`, token = 32 random bytes base64url, TTL 72h
668 (config `auth.invite_ttl_hours`), single-use, stored hashed.
669- Sending is **best-effort and out-of-band**: never block a CLI command or HTTP response on SMTP.
670 Spawn the send, log failures loudly, and always print the activation URL to stdout in the CLI
671 so an operator can deliver it manually when SMTP is down.
672- Unit tests use the `stdout` backend and assert the rendered body contains a well-formed link.
673
674---
675
676## 8. Highlighting (`crates/highlight`)
677
678`inkjet` (tree-sitter) for highlighting. This crate has two hard requirements that drive its
679design: **line-addressable output** (for blob views with anchors) and **per-side highlighting
680inside diffs**.
681
682Implementation:
683
6841. Resolve the language (§8.1) → `inkjet::Language`.
6852. Highlight the full text once, producing a flat sequence of `(byte_range, capture_stack)`.
6863. Convert to `Vec<HighlightedLine>`, where each line is `Vec<Span { class, text }>`. Spans that
687 cross a newline are **split**, and the open capture stack is re-opened on the next line. This
688 is the crux: every emitted line must be independently valid HTML with balanced tags.
6894. Cache by `(blob_oid, language)` in a bounded `moka` cache.
690
691For diffs, highlight the **pre-image and post-image of each file as whole documents** — never
692per-hunk, or context-dependent grammars produce garbage — then index the resulting lines by line
693number and zip them with the diff hunks. Added/removed backgrounds are applied to the row, and
694highlight spans live inside it, exactly as in the reference screenshots.
695
696Class names are stable and theme-driven: `hl-keyword`, `hl-function`, `hl-type`, `hl-string`,
697`hl-number`, `hl-comment`, `hl-constant`, `hl-variable`, `hl-operator`, `hl-punctuation`,
698`hl-attribute`, `hl-tag`. Map inkjet's capture names onto this fixed set in one table so themes
699never need to know tree-sitter internals. Document the full list in `docs/theming.md`.
700
701Guards: skip files over `ui.max_highlight_bytes`, skip files detected as binary (NUL in the
702first 8 KiB), skip minified files (any line > 5000 bytes), and fall back to escaped plain text on
703any grammar panic — highlighting **MUST NOT** be able to fail a page render. Enforce a per-file
704timeout and fall back on expiry.
705
706### 8.1 Language resolution order
707
7081. `.gitattributes` `fabrica-language=<name>` (own attribute, highest precedence).
7092. `.gitattributes` `linguist-language=<name>` (GitHub compatibility).
7103. `.gitattributes` `-diff` / `binary` → no highlighting, render as binary.
7114. Shebang line (`#!/usr/bin/env python3` → python).
7125. Vim/Emacs modeline in the first or last 5 lines.
7136. Exact filename (`Dockerfile`, `Makefile`, `flake.lock`, `Cargo.lock`, `PKGBUILD`).
7147. Extension, longest match first (`.tar.gz` before `.gz`, `.d.ts` before `.ts`).
7158. Plain text.
716
717Name matching is case-insensitive with an alias table (`rs`/`rust`, `py`/`python`,
718`ts`/`typescript`, `nix`, `gleam`, `hcl`/`terraform`, …). An unknown language name in
719`.gitattributes` logs a debug line and falls through, rather than erroring.
720
721Unit tests: one per resolution step, plus a test asserting `fabrica-language` beats
722`linguist-language` and both beat the extension.
723
724---
725
726## 9. Web UI (`crates/web`)
727
728### 9.1 Stack
729
730- `axum` + `tower-http` (compression, tracing, request id, timeout).
731- `maud` for templates — compile-time-checked, no runtime template files, and it composes
732 naturally into htmx partials.
733- **htmx 2** for reactivity, vendored into `assets/` (no CDN). A small amount of hand-written
734 vanilla JS (< 200 lines total, in `assets/fabrica.js`) for: the mobile nav drawer, the theme
735 picker, clipboard buttons, and keyboard shortcuts. **No SPA, no WASM, no build step, no npm.**
736- Every interactive element **MUST** work without JavaScript. htmx enhances; links and forms are
737 the substrate. `hx-boost` is not used for full-page navigation.
738
739### 9.2 Routing
740
741Group nesting makes repo paths variable-length, which collides with sub-resources. Resolve it
742with a **`/-/` separator** (the GitLab convention): everything before `/-/` is the repo path,
743everything after is the view.
744
745```
746/ home: your repos + public repos
747/login /logout /invite/{token}
748/search?q=&scope= global search
749/settings profile, password
750/settings/keys ssh + gpg keys
751/settings/tokens API tokens
752/admin/users admin only
753/healthz /version
754/assets/* overridable assets
755/{owner} user page: groups + repos
756/{owner}/{path..} repo home (or group listing if path resolves to a group)
757/{owner}/{path..}.git/* pack transport (§3.3)
758/{owner}/{path..}/-/tree/{ref}/{p..}
759/{owner}/{path..}/-/blob/{ref}/{p..}
760/{owner}/{path..}/-/raw/{ref}/{p..}
761/{owner}/{path..}/-/commits/{ref}
762/{owner}/{path..}/-/commit/{sha}
763/{owner}/{path..}/-/commit/{sha}/context (partial: expanded diff context)
764/{owner}/{path..}/-/branches
765/{owner}/{path..}/-/tags
766/{owner}/{path..}/-/search?q=
767/{owner}/{path..}/-/settings
768/{owner}/{path..}/-/runs (inert; 404 unless ui.show_runs)
769```
770
771Path resolution: given `{owner}` and the segments before `/-/`, look up `repos` by
772`(owner_id, path)`; on miss, look up `groups` by `(owner_id, path)` and render a group listing;
773on miss, `404`. One store query each, both indexed.
774
775### 9.3 Layout
776
777Match the reference screenshots.
778
779**Chrome**: a slim top bar — sidebar toggle at the far left, `{owner}/{repo}` slug next to it,
780tabs centred (`Code`, `Search`, `Branches`, `Tags`, and `Runs` when enabled), home icon at the
781right. The left sidebar is an off-canvas drawer holding instance name, organization-free nav
782(Home, your repos), the theme picker, Settings, and Log out.
783
784**Code view**: two panes. Left is the file tree (`Files` header with a `Commits` toggle button on
785the right); right is the content pane — README rendered on the repo root, blob contents on a
786file, or the commit list when `Commits` is active. A branch/tag picker pill sits above both.
787Tree rows carry filetype icons; directories expand in place via htmx rather than navigating.
788
789**Commit list**: flat rows — subject, then a `Verified`/`Unverified` badge, then a second line of
790short sha · author · date. Infinite scroll via `hx-trigger="revealed"` on the last row.
791
792**Commit page**: title + badge, short sha, `{author} committed on {datetime}`. A collapsible
793`Files Changed` section with `N files changed`, `+adds`/`−dels`, and a `Unified`/`Split` toggle.
794A changed-files tree in the left sidebar that anchors to each file's diff. Each file collapses
795independently, shows its own `+/−` counts, and offers `expand N hidden lines` rows with up/down
796directional expanders.
797
798**Diff context expansion** hits `/-/commit/{sha}/context?file=&from=&to=&side=`, which returns a
799rendered partial of highlighted context lines and swaps the expander row via `hx-swap="outerHTML"`.
800The expander tracks the current window in `data-` attributes so repeated expansion walks outward.
801Expanding beyond the file bounds removes the expander.
802
803**Blob view**: line numbers as a separate non-selectable column, anchors `#L12` and ranges
804`#L12-L20` (click, then shift-click) with the range highlighted and reflected into the URL, raw
805and copy buttons, and a byte/line count header. Images render inline; other binaries show a
806"binary file not shown" notice with a download link.
807
808**Branches**: name, `N ahead` vs default (green), default badge, last-updated author and date —
809per the screenshot. **Tags**: name, short sha, signature badge, updated author and date.
810
811### 9.4 Reactivity patterns
812
813| Interaction | Mechanism |
814| --- | --- |
815| Directory expand in tree | `hx-get` returns `<ul>` children, swapped `innerHTML` |
816| Blob / README / commit-list pane swap | `hx-get` + `hx-target="#pane"` + `hx-push-url="true"` |
817| Commit list pagination | `hx-trigger="revealed"` on the sentinel row |
818| Diff context expansion | `hx-swap="outerHTML"` on the expander row |
819| Unified ↔ Split | `hx-get` with `?view=split`, re-renders the diff region, persists in a cookie |
820| Search | `hx-trigger="keyup changed delay:300ms"` into a results region |
821| Branch/tag picker | `hx-get` filtered list into a popover |
822| Theme switch | client-side attribute swap + cookie, no request |
823
824Handlers detect `HX-Request` and return the partial; the same handler without the header returns
825the full page. Write this as one helper (`fn respond(page, partial)`) so no route can drift.
826
827### 9.5 Theming and assets
828
829- `assets/base.css` — layout and structure, embedded via `rust-embed`. Written entirely against
830 custom properties; contains **no literal colours**.
831- Themes are single CSS files defining custom properties under `:root` (and optionally
832 `@media (prefers-color-scheme: light)`). Ship `dark` (default, matching the screenshots) and
833 `light` embedded.
834- At startup, scan `ui.themes_dir` for `*.css`; each becomes a selectable theme named by its file
835 stem. Disk themes override embedded ones with the same name. Rescan on `SIGHUP` and expose
836 `fabrica theme list`.
837- `ui.assets_dir` overrides any embedded asset by path (`logo.svg`, `favicon.ico`,
838 `custom.css` — the last is injected after the theme if present). Resolution is
839 disk-then-embedded, with path traversal rejected.
840- Theme selection: `ui.theme` is the instance default; if `ui.allow_theme_choice`, a picker
841 writes a `fabrica_theme` cookie which wins per-visitor. Emit
842 `<html data-theme="{name}" style="color-scheme: {dark|light}">`.
843- Token naming, documented in `docs/theming.md`: `--fb-bg`, `--fb-bg-raised`, `--fb-bg-inset`,
844 `--fb-border`, `--fb-fg`, `--fb-fg-muted`, `--fb-accent`, `--fb-success`, `--fb-warning`,
845 `--fb-danger`, `--fb-diff-add-bg`, `--fb-diff-del-bg`, `--fb-diff-add-fg`, `--fb-diff-del-fg`,
846 plus the `--fb-hl-*` family mirroring §8's class list. A theme that sets only these
847 **MUST** produce a complete, coherent UI — that is the acceptance test for themeability.
848- Fonts: system UI stack for prose, system monospace stack for code. No web fonts, no network
849 requests from any page.
850
851### 9.6 Responsive
852
853Single breakpoint at 48rem. Below it: the file tree becomes a drawer over the content pane, the
854tab bar scrolls horizontally, the commit-page sidebar collapses into a dropdown, diffs force
855unified regardless of the toggle, and touch targets are ≥ 44px. Test at 380px wide.
856
857### 9.7 Rendering markdown
858
859`comrak` with GFM extensions (tables, strikethrough, task lists, autolinks, footnotes), output
860sanitized with `ammonia`. Heading anchors, relative link and image rewriting to
861`/-/raw/{ref}/…`, and highlighted fenced code blocks via `crates/highlight`. Raw HTML in
862markdown is stripped, not escaped-and-shown. README lookup order: `README.md`, `README`,
863`readme.md`, `README.txt`.
864
865---
866
867## 10. Search
868
869Two scopes, one engine, no index for the MVP.
870
871- **In-repo** (`/{owner}/{path}/-/search?q=`): walk the tree at the default branch (or `?ref=`),
872 skip binaries, files over 1 MiB, and `linguist-vendored`/`linguist-generated` paths; match
873 content with `grep-searcher` + `grep-regex`, and paths with a substring/fuzzy match. Return
874 grouped results — file path, then matching lines with 1 line of context, highlighted.
875- **Global** (`/search?q=`): match repo names, group names, usernames, and descriptions first,
876 then run the in-repo content search across every repo the viewer can read, with a bounded
877 worker pool (`search.concurrency`, default 4), a total time budget
878 (`search.timeout_secs`, default 10), and a result cap (`search.max_results`, default 200).
879 When the budget is exhausted, return partial results with an explicit "search timed out,
880 showing partial results" notice — never silently truncate.
881- **Qualifiers**, parsed with a tiny hand-written parser (unit tested): `repo:owner/path`,
882 `user:name`, `path:glob`, `lang:name`, `case:yes`, `regex:yes`. Bare terms are literal and
883 case-insensitive by default.
884- Document in `docs/decisions.md` that this is a linear scan appropriate to a private instance,
885 and that a `tantivy` index is the intended path if it stops being fast enough.
886
887---
888
889## 11. API (`crates/api`)
890
891`/api/v1`, JSON, `Bearer` JWT. Purpose: automation parity with the CLI, not a public platform.
892
893```
894GET /api/v1/version
895GET /api/v1/user # current token's user
896GET /api/v1/users # admin
897POST /api/v1/users # admin
898DELETE /api/v1/users/{name}?purge= # admin
899GET /api/v1/users/{name}/keys
900POST /api/v1/users/{name}/keys
901DELETE /api/v1/users/{name}/keys/{ordinal}
902GET /api/v1/repos # visible to the token
903POST /api/v1/repos
904GET /api/v1/repos/{owner}/{path..}
905PATCH /api/v1/repos/{owner}/{path..}
906DELETE /api/v1/repos/{owner}/{path..}?purge=
907GET /api/v1/repos/{owner}/{path..}/branches
908GET /api/v1/repos/{owner}/{path..}/tags
909GET /api/v1/repos/{owner}/{path..}/commits?ref=&page=
910GET /api/v1/repos/{owner}/{path..}/commits/{sha}
911GET /api/v1/repos/{owner}/{path..}/tree/{ref}/{p..}
912GET /api/v1/repos/{owner}/{path..}/raw/{ref}/{p..}
913GET /api/v1/groups/{owner}
914POST /api/v1/groups
915DELETE /api/v1/groups/{owner}/{path..}
916GET /api/v1/search?q=&scope=
917```
918
919Conventions: `snake_case` fields; errors as
920`{"error": {"code": "not_found", "message": "…", "details": {}}}` with correct status codes;
921cursor pagination via `?page=&per_page=` (max 100) plus `X-Total-Count`; `404` (not `403`) for
922unauthorized private resources; all timestamps RFC 3339 strings on the wire even though they are
923epoch-ms at rest.
924
925---
926
927## 12. CLI (`crates/cli`)
928
929`clap` derive, colour-aware, `--config`, `--log-level`, `--json` (machine-readable output for
930every list command). All commands operate **directly** on the store and filesystem — no IPC with
931a running server is required, since SQLite WAL and Postgres both tolerate concurrent writers.
932Mutations that a live server caches (repo rename, delete, user disable) bump a `cache_epoch` row
933that `serve` polls every few seconds.
934
935```
936fabrica serve [--detach] [--no-migrate]
937
938fabrica user add <name> <email> [--pass <password>] [--admin] [--display-name <s>]
939fabrica user del <name> [--purge]
940fabrica user list
941fabrica user passwd <name> [--pass <password>]
942fabrica user disable <name> | enable <name>
943
944fabrica key add --type <ssh|gpg> <user> <public-key|@path|->
945fabrica key del <user> <index>
946fabrica key list <user>
947
948fabrica repo add <user> <name> [--private|--public] [--description <s>] [--default-branch <b>]
949fabrica repo del <user> <name> [--purge]
950fabrica repo list [--user <name>]
951fabrica repo rename <user> <name> <new-name>
952fabrica repo path <user> <name>
953
954fabrica group add <user> <path>
955fabrica group del <user> <path>
956fabrica group list <user>
957
958fabrica token add <user> <name> [--scopes <list>] [--expires <duration>]
959fabrica token del <user> <index>
960fabrica token list <user>
961
962fabrica theme list
963fabrica config check | show
964fabrica migrate
965fabrica doctor
966fabrica gc [--trash-older-than <duration>]
967fabrica hook post-receive # internal, hidden from help
968```
969
970Behaviour notes:
971
972- `serve` runs in the **foreground** by default — correct for systemd and Docker — handling
973 `SIGTERM`/`SIGINT` with graceful shutdown (stop accepting, drain in-flight requests up to
974 `server.graceful_shutdown_secs`, wait on active pack subprocesses, close pools). `--detach`
975 double-forks and writes `{data_dir}/fabrica.pid`; there is no `stop` subcommand — signal the
976 pid. Document this deviation from the original `server start`/`server stop` sketch in
977 `docs/decisions.md`.
978- `user add` without `--pass`: create the user with `password_hash = NULL`, mint an invite,
979 email it, **and** print the activation URL to stdout.
980- `key add` accepts a literal key, `@path`, or `-` for stdin; parses and validates it before
981 insert (ssh via `ssh-key`, gpg via `sequoia-openpgp`); rejects duplicates by fingerprint with a
982 message naming the existing owner.
983- `key del <user> <index>` uses the 1-based `ordinal` shown by `key list`. Ordinals are stable —
984 deleting one does **not** renumber the others; new keys take `max(ordinal) + 1`. State this in
985 the help text.
986- `repo add <user> <name>` accepts a group path in `name` (`fabrica repo add hanna
987 backend/api`), creating missing intermediate groups.
988- `user del` without `--purge` refuses when the user owns repos, listing them; `--purge` deletes
989 repos, keys, tokens, and sessions.
990- Every destructive command prompts for confirmation on a TTY and requires `--yes` when not
991 attached to one.
992
993---
994
995## 13. Deployment artifacts
996
997### Dockerfile
998
999Multi-stage, no Nix in the image:
1000
1001- Builder: `rust:slim` (or `rustlang/rust:nightly-slim`), install `pkg-config`, `libssl-dev`,
1002 `cmake`, `git`; `cargo build --release --features vendored`. The `vendored` feature turns on
1003 `git2/vendored-libgit2` so the runtime image needs no libgit2.
1004- Cache dependency layers first (copy manifests, build a dummy target, then copy sources) or use
1005 `cargo-chef`.
1006- Runtime: `debian:stable-slim` with `git`, `ca-certificates`, `tini`. Non-root user `fabrica`
1007 (uid 10001). `VOLUME /var/lib/fabrica`. `EXPOSE 8080 2222`.
1008 `HEALTHCHECK CMD ["fabrica","doctor","--quiet"]` or a curl of `/healthz`.
1009 `ENTRYPOINT ["/usr/bin/tini","--","fabrica"]`, `CMD ["serve"]`.
1010- Verify `git --version` in the final image at build time so a missing plumbing binary is a build
1011 failure, not a runtime surprise.
1012
1013### compose.yml
1014
1015Two services: `fabrica` (image or `build: .`), and `postgres` behind a `postgres` profile so the
1016default path is pure SQLite. Named volumes for `data` and `repos`. Config supplied by bind-mounting
1017`fabrica.toml` and overriding a couple of keys via `FABRICA__*` env vars, to demonstrate both
1018mechanisms. Map SSH as `2222:2222` with a comment explaining `ssh.clone_port`. Include a commented
1019reverse-proxy block noting that the proxy **MUST NOT** buffer request or response bodies on
1020`/git-upload-pack` (`proxy_buffering off`, `proxy_request_buffering off`) or clones will stall.
1021
1022---
1023
1024## 14. Testing
1025
1026Every crate carries unit tests; **no functionality ships untested**. `cargo test` alone must be
1027sufficient (no external services required by default).
1028
1029- **`git`**: build fixture repos programmatically in `tempfile::TempDir` — commits, branches,
1030 tags, merges, renames, binary files, a file with CRLF, a submodule entry. Fixtures for
1031 signatures: generate an ed25519 key at test time, sign with `ssh-keygen -Y sign`, and
1032 ship a small static GPG-signed fixture. Assert all four `SignatureState` variants.
1033- **`highlight`**: snapshot tests (`insta`) over a fixture file per language. A dedicated test
1034 asserts a multi-line string literal produces balanced spans on every line. A test asserts a
1035 grammar failure degrades to plain text.
1036- **Attributes**: table-driven tests over the precedence rules in §3.5.
1037- **`store`**: run the full suite against SQLite in-memory *and* file-backed; Postgres tests
1038 behind `#[cfg_attr(not(feature = "postgres-tests"), ignore)]`, driven by
1039 `FABRICA_TEST_POSTGRES_URL`, and exercised in the dev shell. A migration test asserts both
1040 dialects' migrations produce equivalent schemas.
1041- **`auth`**: exhaustive matrix over `access()` — every viewer kind × visibility × collaborator
1042 permission. This is the security-critical test; enumerate it, don't sample it.
1043- **`web`**: `axum::Router` + `tower::ServiceExt::oneshot` for handler tests. Assert that
1044 `HX-Request` yields a partial and its absence a full document, that private repos 404 for
1045 anonymous viewers, and that CSRF rejection works. `insta` snapshots for key partials.
1046- **`cli`**: `assert_cmd` + `predicates` against a temp data dir.
1047- **Integration** (`tests/`): boot the server on port 0 and run real git against it —
1048 `git clone` over HTTP, `git push`/`git clone` over SSH using a generated key, a push that
1049 triggers the post-receive hook, and an HTTP push attempt asserting the 403 with a helpful body.
1050 Gate these on `git` and `ssh` being present, skipping with a clear message otherwise.
1051- `cargo-nextest` in the dev shell; keep the full suite under two minutes.
1052
1053---
1054
1055## 15. Working conventions
1056
1057### 15.1 Quality gate
1058
1059After **every** unit of work, all four must pass — no exceptions, no deferrals:
1060
1061```
1062cargo fmt --all -- --check
1063cargo check --workspace --all-targets
1064cargo clippy --workspace --all-targets -- -D warnings
1065cargo test --workspace
1066```
1067
1068`nix flake check` is the superset and must pass before any phase is called done. Add a `justfile`
1069with `just check` running all four, and use it.
1070
1071### 15.2 Commits
1072
1073**Conventional Commits**, one logical change per commit, committed **as work happens** rather
1074than batched at the end.
1075
1076- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `chore`, `style`.
1077- Scope is the crate or area: `feat(git):`, `fix(web):`, `build(nix):`, `docs(theming):`.
1078- Imperative mood, lowercase subject, no trailing period, ≤ 72 chars.
1079- Body explains *why* when non-obvious; footers `BREAKING CHANGE:` and `Refs:` as needed.
1080- **Never commit with failing checks.** If a commit needs a `#[allow]`, justify it in the body.
1081
1082Examples:
1083
1084```
1085feat(git): resolve gitattributes from tree blobs
1086feat(ssh): accept git-upload-pack over exec channels
1087fix(highlight): reopen capture stack across line breaks
1088build(nix): wrap the binary with git on PATH
1089test(auth): enumerate the access matrix
1090```
1091
1092### 15.3 Error handling
1093
1094`thiserror` for library errors, one enum per crate; `anyhow` only at the binary boundary
1095(`main.rs`, CLI commands). `tracing` for logging with structured fields; a request-id span on
1096every HTTP request and a session span on every SSH connection. User-facing error pages are
1097templated, themed, and never expose internals — the detail goes to the log with the request id,
1098and the page shows the id.
1099
1100### 15.4 Docs
1101
1102`docs/decisions.md` is a running log: date, decision, alternatives, rationale. Seed it with the
1103decisions this spec already makes — pack transport via subprocess, id-based repo directories,
1104`/-/` route separator, epoch-ms timestamps, `serve` with no `stop`, linear search. Update it
1105whenever you deviate from this spec.
1106
1107---
1108
1109## 16. Implementation phases
1110
1111Each phase ends with the full gate green and its own commits. Do not start a phase before the
1112previous one is clean.
1113
11141. **Scaffold** — flake (fenix + crane), workspace with empty crates, `rust-toolchain.toml`,
1115 lints, `justfile`, MPL-2.0 `LICENSE` and per-file headers, CI-less `nix flake check` passing.
1116 `build: initialize workspace and nix flake`
11172. **config** — TOML + env loading, validation, `config check`/`show`, example file, docs.
11183. **store** — schema, both migration sets, `Store` trait, both backends, name validation, tests.
11194. **auth** — argon2id, sessions, JWT, `access()`, the full permission matrix test.
11205. **git reads** — open/init bare, refs, trees, blobs, log, diff, context expansion, branch
1121 ahead/behind, fixture harness.
11226. **highlight** — inkjet integration, line-aware output, language resolution, attributes
1123 resolver, snapshot tests.
11247. **git signatures** — SSH and GPG verification against registered keys, all four states.
11258. **cli core** — clap tree, `user`/`key`/`repo`/`group`/`token` commands against the store,
1126 `doctor`, `migrate`.
11279. **mail** — lettre, templates, invite tokens, `stdout` backend, wire into `user add`.
112810. **web shell** — axum, maud layout, top bar, drawer, theming pipeline, asset overrides,
1129 embedded + disk themes, login/logout/invite, error pages, `/healthz`.
113011. **web repo views** — repo home + README, tree, blob with anchors, raw, commit list with
1131 badges, branches, tags. htmx partials throughout.
113212. **web commit view** — file diffs, changed-files tree, per-file collapse, unified **and**
1133 split, highlighted diff rows, context expansion endpoint.
113413. **pack transport** — `git` discovery, subprocess wrapper, smart HTTP read routes, the
1135 deliberate 403 on HTTP push, hooks installed at init, `hook post-receive`.
113614. **ssh** — russh server, host key generation, publickey auth against `keys`, exec dispatch to
1137 pack processes, permission enforcement, banner for shell attempts.
113815. **search** — in-repo, global, qualifier parser, budgets and partial-result reporting.
113916. **api** — `/api/v1` surface, JWT middleware, scopes, error shape.
114017. **deployment** — Dockerfile, compose.yml, `packages.container`, NixOS module, deployment docs.
114118. **polish** — mobile pass at 380px, caching (`moka` where measured), graceful shutdown,
1142 accessibility (focus rings, skip link, ARIA on the drawer and tabs, keyboard nav), a
1143 second theme proving `docs/theming.md` is complete.
1144
1145Then, and only then, the CI: HCL config parsed with `hcl-rs`, Docker execution via `bollard`,
1146stages and jobs, log streaming into the reserved `runs` view.
1147
1148---
1149
1150## 17. Definition of done for the MVP
1151
1152- `nix build` produces a single wrapped binary; `nix flake check` is green.
1153- `fabrica user add`, `key add`, `repo add` work; the emailed invite completes activation.
1154- `git clone https://…` works for public repos anonymously and private repos with Basic auth.
1155- `git push` over SSH works with a registered key and is rejected for an unregistered one.
1156- `git push` over HTTPS fails with a 403 that tells the user the SSH URL.
1157- The web UI browses trees, blobs, history, branches, and tags with highlighting, and renders
1158 commit diffs unified and split with working context expansion and highlighted rows.
1159- Signature badges are correct across all four states.
1160- Nested groups resolve in the UI, in clone URLs, and in the CLI.
1161- Dropping a CSS file in `themes_dir` re-themes the whole UI with no rebuild and no restart
1162 beyond a `SIGHUP`.
1163- Changing `instance.name` rebrands the UI everywhere.
1164- The UI is usable at 380px wide.
1165- Docker Compose brings up a working instance from the example config.
1166- Every crate has tests; all four cargo checks pass; every commit is conventional.