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