fabrica

hanna/fabrica

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