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+
3+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+## Project status: greenfield
6+
7+The repository currently contains **only `spec.md`** — a 1166-line implementation contract. No
8+code, flake, crates, or `Cargo.toml` exist yet. `spec.md` is the source of truth: read it before
9+doing anything. Where it says **MUST**, treat it as a hard requirement; **SHOULD** deviations
10+must 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)
13+and SSH (read + write) with a fast, themeable, htmx-driven web UI. Private by default, no sign-up.
14+Build order is fixed — see **Implementation phases** below.
15+
16+## What this project deliberately is NOT
17+
18+Do not build: issues, pull/merge requests, merge machinery, GitHub-style orgs, web sign-up/self-
19+registration, forks, stars, activity feeds, wikis, releases, git-LFS, webhooks, federation. When
20+a 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
22+point) but ship them inert behind `ui.show_runs = false`. Do not implement CI until phase 18 is done.
23+
24+## Commands
25+
26+None run today (no code). Once the workspace exists, the **quality gate** below must pass after
27+every unit of work — no exceptions, no deferrals:
28+
29+```
30+cargo fmt --all -- --check
31+cargo check --workspace --all-targets
32+cargo clippy --workspace --all-targets -- -D warnings
33+cargo test --workspace
34+```
35+
36+A `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
39+suffice 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+
42+Toolchain: Rust **nightly** via fenix (pinned in `flake.lock`), edition 2024, resolver 3. Nightly
43+is a currency preference only — **no `#![feature(...)]` gates**; code must compile on stable.
44+Workspace 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+
49+Single root package `fabrica` (`src/main.rs` = arg-parse → dispatch into `cli`, nothing else).
50+All library code lives in `crates/` with **unprefixed** names. Dependency direction is strictly
51+one-way and enforced:
52+
53+```
54+model ← 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+
60+Crates: `config` (figment TOML+env), `store` (sqlx, SQLite+Postgres), `model` (domain types, no
61+I/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+
75+Therefore the `git` binary **MUST** be on `PATH` at runtime — the Nix derivation wraps the binary
76+with git on PATH, the Dockerfile verifies `git --version` at build time. Resolve it once at
77+startup (config `git.binary`, require ≥ 2.41), surface in `/healthz` and `fabrica doctor`.
78+
79+Pack transport rules that are easy to get wrong: **authorize before spawning** (the subprocess
80+never makes an access decision); **stream both directions, never buffer a pack in memory**; HTTP
81+push (`git-receive-pack`) returns a deliberate `403` pointing at the SSH URL; dumb HTTP is
82+disabled. 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+
86+On disk: `{storage.repo_dir}/{id[0..2]}/{id}.git` where `id` is a lowercase ULID. The **database
87+is the only name→path authority**, so renames and group moves are metadata-only. The tree is not
88+human-browsable by design — `fabrica repo path <user> <name>` resolves it for operators.
89+
90+### Groups make repo paths variable-length → the `/-/` separator
91+
92+Group nesting means repo paths have unknown depth. Routes disambiguate with the GitLab `/-/`
93+convention: 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)`;
95+on miss look up `groups` and render a group listing; on miss `404`.
96+
97+### Store portability (SQLite + Postgres, one schema)
98+
99+Do **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
101+portable SQL subset. Timestamps are `BIGINT` unix **milliseconds** UTC (no `DATETIME`/`TIMESTAMPTZ`).
102+Ids 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.
104+Migrations: `migrations/{sqlite,postgres}/NNNN_name.sql`, selected at runtime via `sqlx::migrate!`.
105+
106+### Permission model: one function, exhaustively tested
107+
108+```rust
109+pub enum Access { None, Read, Write, Admin }
110+pub fn access(viewer: Option<&Viewer>, repo: &Repo) -> Access
111+```
112+
113+Order: 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
115+web UI and the API alike). The `access()` test is the security-critical one: enumerate the full
116+matrix (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
121+independently valid balanced HTML** (spans crossing a newline are split and the capture stack
122+re-opened on the next line — see `fix(highlight): reopen capture stack across line breaks`), and
123+diffs highlight the **pre- and post-image of each file as whole documents**, never per-hunk, then
124+zip lines with hunks. Stable theme-driven class names (`hl-keyword`, `hl-string`, …) mapped from
125+tree-sitter captures in one table. Highlighting **MUST NOT** be able to fail a page render — skip
126+oversized/binary/minified files and fall back to escaped plain text on any grammar panic.
127+Language resolution has a fixed precedence (§8.1): `.gitattributes fabrica-language` > `linguist-
128+language` > 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
133+npm, 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
135+without 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,
137+partial)` helper so no route drifts.
138+
139+Theming re-brands with no rebuild: `assets/base.css` is structure-only with **no literal colours**,
140+written entirely against custom properties. Themes are single CSS files defining `--fb-*` tokens;
141+drop one in `ui.themes_dir` and `SIGHUP` to load it. A theme that sets only the documented `--fb-*`
142+tokens MUST yield a complete UI — that is the themeability acceptance test. No web fonts, no network
143+requests from any page.
144+
145+## CLI operates directly on the store — no IPC with the server
146+
147+Every `fabrica` subcommand touches the store/filesystem directly (SQLite WAL and Postgres both
148+tolerate concurrent writers); there is no IPC with a running `serve`. Mutations a live server
149+caches (repo rename/delete, user disable) bump a `cache_epoch` row that `serve` polls. `serve` runs
150+in the **foreground** by default (correct for systemd/Docker); there is no `stop` subcommand —
151+signal 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+
169+Each phase ends with the full gate green and its own commits; do not start a phase before the
170+previous is clean:
171+
172+1. Scaffold (flake + fenix + crane, empty crates, lints, justfile, MPL-2.0) 2. config 3. store
173+4. auth 5. git reads 6. highlight 7. git signatures 8. cli core 9. mail 10. web shell 11. web repo
174+views 12. web commit view 13. pack transport 14. ssh 15. search 16. api 17. deployment 18. polish.
175+Then, 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
4+sign-up. It serves repositories over HTTPS (read) and SSH (read + write) and provides a fast,
5+themeable web UI for browsing code.
6+
7+This 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+
47+Root package `fabrica` produces the single binary. All library code lives in `crates/`, with
48+**unprefixed crate names**:
49+
50+```
51+fabrica/
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+
85+Naming constraints: **MUST NOT** name a crate `core`, `std`, `alloc`, `test`, or `proc_macro` —
86+these collide with sysroot crates. `git` and `api` are fine.
87+
88+Dependency direction is strictly one-way: `model` ← `store`/`git`/`auth` ← `web`/`api`/`ssh`/`cli`.
89+No 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]
103+unsafe_code = "forbid"
104+missing_docs = "warn"
105+
106+[workspace.lints.clippy]
107+all = { level = "deny", priority = -1 }
108+pedantic = "warn"
109+unwrap_used = "deny"
110+expect_used = "warn"
111+panic = "deny"
112+todo = "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+
119+Inputs: `nixpkgs` (unstable), `fenix`, `crane`, `flake-utils` (or `flake-parts` — pick one and
120+be consistent).
121+
122+The 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+
133+Build shape:
134+
135+```nix
136+craneLib = (crane.mkLib pkgs).overrideToolchain fenixToolchain;
137+commonArgs = {
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+};
145+cargoArtifacts = craneLib.buildDepsOnly commonArgs;
146+```
147+
148+The final derivation **MUST** wrap the binary so the git plumbing is always reachable:
149+
150+```nix
151+postInstall = ''
152+ wrapProgram $out/bin/fabrica \
153+ --prefix PATH : ${lib.makeBinPath [ pkgs.git ]}
154+'';
155+```
156+
157+Also 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,
171+diffs, attributes, signature extraction. **libgit2 does not implement the server side of the
172+pack protocol.** There is no `upload-pack`/`receive-pack` in libgit2 — it is client-only for
173+transport. 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+
191+Record the discovered path and version in `/healthz` output and in `fabrica doctor`.
192+
193+### 3.2 On-disk layout
194+
195+Repositories are stored by **id**, not by name, so renames and group moves never touch the
196+filesystem:
197+
198+```
199+{storage.repo_dir}/{id[0..2]}/{id}.git
200+```
201+
202+where `id` is a lowercase ULID. The database is the only name→path authority. Document the
203+tradeoff in `docs/decisions.md`: the tree is not human-browsable, in exchange for atomic,
204+metadata-only renames. `fabrica repo path <user> <name>` prints the resolved directory for
205+operators.
206+
207+Repo creation:
208+
209+1. `git2::Repository::init_bare`.
210+2. Set `HEAD` to `refs/heads/{default_branch}` (config: `instance.default_branch`, default `main`).
211+3. Write config: `core.logAllRefUpdates=true`, `receive.denyNonFastForwards=false`,
212+ `gc.auto=0` (maintenance is driven by fabrica, not by pushes).
213+4. Install hooks (§3.4).
214+5. Insert the DB row in the same transaction; on any failure, remove the directory.
215+
216+Deletion moves the directory to `{storage.data_dir}/trash/{id}-{timestamp}.git` and deletes the
217+row; a `fabrica gc --trash-older-than 30d` command reaps it. `repo del --purge` skips the trash.
218+
219+### 3.3 Pack transport
220+
221+Everything 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+
238+Implement only the v0/v1/v2 smart paths; dumb HTTP **MUST** be disabled.
239+
240+```
241+GET /{owner}/{path}.git/info/refs?service=git-upload-pack
242+POST /{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+
286+Install `hooks/post-receive` (and a no-op `pre-receive` reserved for future branch protection)
287+as a two-line script:
288+
289+```sh
290+#!/bin/sh
291+exec 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+
302+The hook path must locate the running binary reliably: write the absolute path of the current
303+executable at repo-init time (`std::env::current_exe`), falling back to `git.hook_binary` config.
304+
305+### 3.5 Attributes (`.gitattributes`)
306+
307+libgit2's attribute lookup is oriented toward a working tree; bare repos and per-ref accuracy
308+make 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+
321+Unit tests **MUST** cover: precedence between nested files, negation, anchored vs floating
322+patterns, and `-diff` suppressing diff rendering.
323+
324+### 3.6 Signature verification
325+
326+Read 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
330+namespace `"git"` against candidate public keys. Reject if the sig's hash algorithm is not
331+sha256/sha512.
332+
333+**GPG** (`-----BEGIN PGP SIGNATURE-----`): verify with `sequoia-openpgp` against registered
334+public keys. Check the signature's creation time against key validity and expiry; treat expired
335+or revoked keys as unverified, not verified.
336+
337+Result type:
338+
339+```rust
340+pub 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+
351+UI mapping, matching the reference screenshots: `Verified` renders a green **Verified** badge;
352+everything else that carries a signature renders an amber **Unverified** badge; `Unsigned`
353+renders an amber **Unverified** badge as well. The tooltip / detail line distinguishes the four
354+cases 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+
357+Verification is expensive; cache per commit oid in a bounded `moka` cache and, for list views,
358+verify lazily in a bounded-concurrency batch (`ui.signature_batch_concurrency`, default 8).
359+Additionally verify tag signatures for annotated tags and show the same badge on the tags page.
360+
361+### 3.7 Reads
362+
363+Expose a narrow, testable API — no `git2` types in the public signatures, so `web` never links
364+against libgit2 semantics:
365+
366+```rust
367+pub fn resolve_ref(&self, r: &str) -> Result<Resolved>; // branch | tag | oid | HEAD
368+pub fn tree_entries(&self, rev: &Resolved, path: &Path) -> Result<Vec<TreeEntry>>;
369+pub fn blob(&self, rev: &Resolved, path: &Path) -> Result<Blob>; // + is_binary, size, lfs-ignored
370+pub fn commits(&self, rev: &Resolved, path: Option<&Path>, page: Page) -> Result<Vec<CommitSummary>>;
371+pub fn commit(&self, oid: Oid) -> Result<CommitDetail>;
372+pub fn diff(&self, base: Option<Oid>, head: Oid, opts: DiffOpts) -> Result<FileDiffs>;
373+pub fn diff_context(&self, head: Oid, file: &Path, from: u32, to: u32) -> Result<Vec<DiffLine>>;
374+pub fn branches(&self) -> Result<Vec<BranchInfo>>; // + ahead/behind vs default
375+pub fn tags(&self) -> Result<Vec<TagInfo>>;
376+pub fn last_commit_per_entry(&self, rev: &Resolved, path: &Path) -> Result<HashMap<..>>;
377+```
378+
379+Notes:
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+
396+TOML file plus environment overrides via `figment`. Resolution order (later wins): built-in
397+defaults → `/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+
401+Any `*_file` key reads its value from a file (for agenix/systemd-creds/Docker secrets) and takes
402+precedence 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]
409+name = "fabrica" # shown in the header and page titles
410+url = "https://git.example.com"
411+description = "A small, private forge."
412+default_branch = "main"
413+allow_anonymous = true # anonymous browse + clone of public repos
414+
415+[server]
416+address = "0.0.0.0"
417+port = 8080
418+behind_proxy = false # trust X-Forwarded-For / X-Forwarded-Proto
419+graceful_shutdown_secs = 20
420+max_body_bytes = 1048576 # non-pack routes only
421+
422+[ssh]
423+enabled = true
424+address = "0.0.0.0"
425+port = 2222
426+host_key_path = "/var/lib/fabrica/ssh/host_ed25519"
427+clone_host = "git.example.com" # what the UI shows, may differ from bind
428+clone_port = 22
429+
430+[storage]
431+data_dir = "/var/lib/fabrica"
432+repo_dir = "/var/lib/fabrica/repos"
433+
434+[database]
435+url = "sqlite:///var/lib/fabrica/fabrica.db"
436+max_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]
441+secret_file = "/var/lib/fabrica/secret" # HS256 key, generated on first run, 0600
442+session_ttl_days = 30
443+token_ttl_days = 90
444+cookie_name = "fabrica_session"
445+cookie_secure = true
446+argon2_m_cost = 19456
447+argon2_t_cost = 2
448+argon2_p_cost = 1
449+
450+[mail]
451+backend = "smtp" # smtp | stdout | none
452+from = "fabrica@example.com"
453+host = "smtp.example.com"
454+port = 587
455+username = "fabrica"
456+password_file = "/run/secrets/fabrica-smtp"
457+encryption = "starttls" # starttls | tls | none
458+timeout_secs = 15
459+
460+[ui]
461+theme = "dark" # file stem in themes_dir, or an embedded theme name
462+themes_dir = "/var/lib/fabrica/themes"
463+assets_dir = "/var/lib/fabrica/assets"
464+allow_theme_choice = true # show the theme picker; persists in a cookie
465+show_runs = false # CI nav slot, inert until the CI ships
466+diff_context_lines = 3
467+max_highlight_bytes = 1048576
468+max_diff_bytes = 5242880
469+tree_history_limit = 1000
470+
471+[git]
472+binary = "git"
473+transport_timeout_secs = 3600
474+max_pack_size_bytes = 536870912
475+
476+[log]
477+level = "info" # trace|debug|info|warn|error, RUST_LOG also honoured
478+format = "pretty" # pretty | json
479+```
480+
481+Validation at startup **MUST** be strict and fail fast with actionable messages: unknown keys
482+are an error (catch typos), `instance.url` must parse and must not have a trailing slash,
483+directories 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
487+config 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 —
494+define a `Store` trait with `Sqlite` and `Postgres` implementations behind it, or use runtime
495+queries over `AnyPool`; keep every statement inside the portable SQL subset.
496+
497+Portability 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+
507+Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`, applied with `sqlx::migrate!`
508+selected at runtime. `fabrica migrate` runs them; `serve` runs them automatically unless
509+`--no-migrate`.
510+
511+### Schema
512+
513+```sql
514+users (
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+
526+invites ( -- 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+
535+sessions (
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+
542+api_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+
551+keys (
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+
565+groups (
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+
576+repos (
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+
592+repo_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
601+runs (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)
603+jobs (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+
608+Indexes: `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+
611+Name validation (single place in `model`, used by CLI, API, and web): usernames and repo/group
612+names match `^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$`, must not be `.`/`..`, must not end in `.git`
613+or `.`, 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+
638+Permission resolution — one function, used everywhere, unit tested exhaustively:
639+
640+```rust
641+pub enum Access { None, Read, Write, Admin }
642+pub fn access(viewer: Option<&Viewer>, repo: &Repo) -> Access
643+```
644+
645+Rules, in order:
646+
647+1. `viewer.is_admin` → `Admin`.
648+2. `viewer.id == repo.owner_id` → `Admin`.
649+3. explicit `repo_collaborators` row → that permission.
650+4. `!repo.is_private && instance.allow_anonymous` → `Read`.
651+5. otherwise `None`.
652+
653+Anonymous viewers can only ever reach `Read`, and only on public repos. Every mutating route
654+asserts at least `Write`; creation routes assert an authenticated viewer. `None` on a private
655+repo **MUST** render `404`, not `403` — do not leak existence.
656+
657+---
658+
659+## 7. Mail (`crates/mail`)
660+
661+Required 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
679+design: **line-addressable output** (for blob views with anchors) and **per-side highlighting
680+inside diffs**.
681+
682+Implementation:
683+
684+1. Resolve the language (§8.1) → `inkjet::Language`.
685+2. Highlight the full text once, producing a flat sequence of `(byte_range, capture_stack)`.
686+3. 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.
689+4. Cache by `(blob_oid, language)` in a bounded `moka` cache.
690+
691+For diffs, highlight the **pre-image and post-image of each file as whole documents** — never
692+per-hunk, or context-dependent grammars produce garbage — then index the resulting lines by line
693+number and zip them with the diff hunks. Added/removed backgrounds are applied to the row, and
694+highlight spans live inside it, exactly as in the reference screenshots.
695+
696+Class 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
699+never need to know tree-sitter internals. Document the full list in `docs/theming.md`.
700+
701+Guards: skip files over `ui.max_highlight_bytes`, skip files detected as binary (NUL in the
702+first 8 KiB), skip minified files (any line > 5000 bytes), and fall back to escaped plain text on
703+any grammar panic — highlighting **MUST NOT** be able to fail a page render. Enforce a per-file
704+timeout and fall back on expiry.
705+
706+### 8.1 Language resolution order
707+
708+1. `.gitattributes` `fabrica-language=<name>` (own attribute, highest precedence).
709+2. `.gitattributes` `linguist-language=<name>` (GitHub compatibility).
710+3. `.gitattributes` `-diff` / `binary` → no highlighting, render as binary.
711+4. Shebang line (`#!/usr/bin/env python3` → python).
712+5. Vim/Emacs modeline in the first or last 5 lines.
713+6. Exact filename (`Dockerfile`, `Makefile`, `flake.lock`, `Cargo.lock`, `PKGBUILD`).
714+7. Extension, longest match first (`.tar.gz` before `.gz`, `.d.ts` before `.ts`).
715+8. Plain text.
716+
717+Name 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+
721+Unit 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+
741+Group nesting makes repo paths variable-length, which collides with sub-resources. Resolve it
742+with a **`/-/` separator** (the GitLab convention): everything before `/-/` is the repo path,
743+everything 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+
771+Path 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;
773+on miss, `404`. One store query each, both indexed.
774+
775+### 9.3 Layout
776+
777+Match the reference screenshots.
778+
779+**Chrome**: a slim top bar — sidebar toggle at the far left, `{owner}/{repo}` slug next to it,
780+tabs centred (`Code`, `Search`, `Branches`, `Tags`, and `Runs` when enabled), home icon at the
781+right. 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
785+the right); right is the content pane — README rendered on the repo root, blob contents on a
786+file, or the commit list when `Commits` is active. A branch/tag picker pill sits above both.
787+Tree 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
790+short 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.
794+A changed-files tree in the left sidebar that anchors to each file's diff. Each file collapses
795+independently, shows its own `+/−` counts, and offers `expand N hidden lines` rows with up/down
796+directional expanders.
797+
798+**Diff context expansion** hits `/-/commit/{sha}/context?file=&from=&to=&side=`, which returns a
799+rendered partial of highlighted context lines and swaps the expander row via `hx-swap="outerHTML"`.
800+The expander tracks the current window in `data-` attributes so repeated expansion walks outward.
801+Expanding 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
805+and 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 —
809+per 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+
824+Handlers detect `HX-Request` and return the partial; the same handler without the header returns
825+the 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+
853+Single breakpoint at 48rem. Below it: the file tree becomes a drawer over the content pane, the
854+tab bar scrolls horizontally, the commit-page sidebar collapses into a dropdown, diffs force
855+unified 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
860+sanitized with `ammonia`. Heading anchors, relative link and image rewriting to
861+`/-/raw/{ref}/…`, and highlighted fenced code blocks via `crates/highlight`. Raw HTML in
862+markdown is stripped, not escaped-and-shown. README lookup order: `README.md`, `README`,
863+`readme.md`, `README.txt`.
864+
865+---
866+
867+## 10. Search
868+
869+Two 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+```
894+GET /api/v1/version
895+GET /api/v1/user # current token's user
896+GET /api/v1/users # admin
897+POST /api/v1/users # admin
898+DELETE /api/v1/users/{name}?purge= # admin
899+GET /api/v1/users/{name}/keys
900+POST /api/v1/users/{name}/keys
901+DELETE /api/v1/users/{name}/keys/{ordinal}
902+GET /api/v1/repos # visible to the token
903+POST /api/v1/repos
904+GET /api/v1/repos/{owner}/{path..}
905+PATCH /api/v1/repos/{owner}/{path..}
906+DELETE /api/v1/repos/{owner}/{path..}?purge=
907+GET /api/v1/repos/{owner}/{path..}/branches
908+GET /api/v1/repos/{owner}/{path..}/tags
909+GET /api/v1/repos/{owner}/{path..}/commits?ref=&page=
910+GET /api/v1/repos/{owner}/{path..}/commits/{sha}
911+GET /api/v1/repos/{owner}/{path..}/tree/{ref}/{p..}
912+GET /api/v1/repos/{owner}/{path..}/raw/{ref}/{p..}
913+GET /api/v1/groups/{owner}
914+POST /api/v1/groups
915+DELETE /api/v1/groups/{owner}/{path..}
916+GET /api/v1/search?q=&scope=
917+```
918+
919+Conventions: `snake_case` fields; errors as
920+`{"error": {"code": "not_found", "message": "…", "details": {}}}` with correct status codes;
921+cursor pagination via `?page=&per_page=` (max 100) plus `X-Total-Count`; `404` (not `403`) for
922+unauthorized private resources; all timestamps RFC 3339 strings on the wire even though they are
923+epoch-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
930+every list command). All commands operate **directly** on the store and filesystem — no IPC with
931+a running server is required, since SQLite WAL and Postgres both tolerate concurrent writers.
932+Mutations that a live server caches (repo rename, delete, user disable) bump a `cache_epoch` row
933+that `serve` polls every few seconds.
934+
935+```
936+fabrica serve [--detach] [--no-migrate]
937+
938+fabrica user add <name> <email> [--pass <password>] [--admin] [--display-name <s>]
939+fabrica user del <name> [--purge]
940+fabrica user list
941+fabrica user passwd <name> [--pass <password>]
942+fabrica user disable <name> | enable <name>
943+
944+fabrica key add --type <ssh|gpg> <user> <public-key|@path|->
945+fabrica key del <user> <index>
946+fabrica key list <user>
947+
948+fabrica repo add <user> <name> [--private|--public] [--description <s>] [--default-branch <b>]
949+fabrica repo del <user> <name> [--purge]
950+fabrica repo list [--user <name>]
951+fabrica repo rename <user> <name> <new-name>
952+fabrica repo path <user> <name>
953+
954+fabrica group add <user> <path>
955+fabrica group del <user> <path>
956+fabrica group list <user>
957+
958+fabrica token add <user> <name> [--scopes <list>] [--expires <duration>]
959+fabrica token del <user> <index>
960+fabrica token list <user>
961+
962+fabrica theme list
963+fabrica config check | show
964+fabrica migrate
965+fabrica doctor
966+fabrica gc [--trash-older-than <duration>]
967+fabrica hook post-receive # internal, hidden from help
968+```
969+
970+Behaviour 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+
999+Multi-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+
1015+Two services: `fabrica` (image or `build: .`), and `postgres` behind a `postgres` profile so the
1016+default 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
1018+mechanisms. Map SSH as `2222:2222` with a comment explaining `ssh.clone_port`. Include a commented
1019+reverse-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+
1026+Every crate carries unit tests; **no functionality ships untested**. `cargo test` alone must be
1027+sufficient (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+
1059+After **every** unit of work, all four must pass — no exceptions, no deferrals:
1060+
1061+```
1062+cargo fmt --all -- --check
1063+cargo check --workspace --all-targets
1064+cargo clippy --workspace --all-targets -- -D warnings
1065+cargo test --workspace
1066+```
1067+
1068+`nix flake check` is the superset and must pass before any phase is called done. Add a `justfile`
1069+with `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
1074+than 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+
1082+Examples:
1083+
1084+```
1085+feat(git): resolve gitattributes from tree blobs
1086+feat(ssh): accept git-upload-pack over exec channels
1087+fix(highlight): reopen capture stack across line breaks
1088+build(nix): wrap the binary with git on PATH
1089+test(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
1096+every HTTP request and a session span on every SSH connection. User-facing error pages are
1097+templated, themed, and never expose internals — the detail goes to the log with the request id,
1098+and 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
1103+decisions 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
1105+whenever you deviate from this spec.
1106+
1107+---
1108+
1109+## 16. Implementation phases
1110+
1111+Each phase ends with the full gate green and its own commits. Do not start a phase before the
1112+previous one is clean.
1113+
1114+1. **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`
1117+2. **config** — TOML + env loading, validation, `config check`/`show`, example file, docs.
1118+3. **store** — schema, both migration sets, `Store` trait, both backends, name validation, tests.
1119+4. **auth** — argon2id, sessions, JWT, `access()`, the full permission matrix test.
1120+5. **git reads** — open/init bare, refs, trees, blobs, log, diff, context expansion, branch
1121+ ahead/behind, fixture harness.
1122+6. **highlight** — inkjet integration, line-aware output, language resolution, attributes
1123+ resolver, snapshot tests.
1124+7. **git signatures** — SSH and GPG verification against registered keys, all four states.
1125+8. **cli core** — clap tree, `user`/`key`/`repo`/`group`/`token` commands against the store,
1126+ `doctor`, `migrate`.
1127+9. **mail** — lettre, templates, invite tokens, `stdout` backend, wire into `user add`.
1128+10. **web shell** — axum, maud layout, top bar, drawer, theming pipeline, asset overrides,
1129+ embedded + disk themes, login/logout/invite, error pages, `/healthz`.
1130+11. **web repo views** — repo home + README, tree, blob with anchors, raw, commit list with
1131+ badges, branches, tags. htmx partials throughout.
1132+12. **web commit view** — file diffs, changed-files tree, per-file collapse, unified **and**
1133+ split, highlighted diff rows, context expansion endpoint.
1134+13. **pack transport** — `git` discovery, subprocess wrapper, smart HTTP read routes, the
1135+ deliberate 403 on HTTP push, hooks installed at init, `hook post-receive`.
1136+14. **ssh** — russh server, host key generation, publickey auth against `keys`, exec dispatch to
1137+ pack processes, permission enforcement, banner for shell attempts.
1138+15. **search** — in-repo, global, qualifier parser, budgets and partial-result reporting.
1139+16. **api** — `/api/v1` surface, JWT middleware, scopes, error shape.
1140+17. **deployment** — Dockerfile, compose.yml, `packages.container`, NixOS module, deployment docs.
1141+18. **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+
1145+Then, and only then, the CI: HCL config parsed with `hcl-rs`, Docker execution via `bollard`,
1146+stages 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.