Tahinli/forge
public0 watching
★ 0
Milestone 1: shared interfaces (contract between workers)
Every worker codes against these names and signatures. If a signature must change, keep the change minimal and say so in your report.
The full plan is at /home/tahinli/.claude/plans/purring-prancing-wirth.md. The research behind it is in docs/research.md.
Global rules
- Edition 2024, resolver 3. Every dependency at its latest release, added with
cargo addinto[workspace.dependencies]; crates use{ workspace = true }. - No
unsafe. Nounwrap()/expect()in non-test code, except at boot with a clear message. - Errors:
thiserrorinside library crates,forge_core::Erroracross crates. - IDs are ULID strings (
ulidcrate). Timestamps are UTC RFC 3339 strings with 9 fractional digits and aZsuffix, produced byforge_core::time::now_stamp(). - Never invoke a shell. Never pass user input to git except through
forge_git::GitCommand.
Module and file ownership
crates/forge-core/src/
lib.rs mod declarations + re-exports
error.rs Error enum
time.rs now_stamp(), stamp_after(Duration)
model.rs domain types (below)
config.rs Config + load()
secret.rs load_or_create_key(), seal(), open()
names.rs normalize_name(), validate_name(), RESERVED
password.rs hash_password(), verify_password()
token.rs generate_token(), hash_token(), constant_time_eq()
session.rs new_session_token(), new_csrf_token()
authz.rs Action, Denied, authorize()
store/mod.rs Store (all DB methods)
store/migrate.rs migration runner (schema_migrations table)
crates/forge-core/migrations/0001_init.sql
crates/forge-git/src/
lib.rs, error.rs
command.rs GitCommand builder
version.rs git_version(), require_min_version()
pktline.rs encode/flush helpers
repo.rs repo_path(), init_bare(), list_branches()
setup.rs write_managed_gitconfig(), write_hook_shims()
service.rs GitService, service_command()
crates/forge-ssh/src/
lib.rs, error.rs
backend.rs GitBackend trait
parse.rs parse_exec()
server.rs run(), host key load/create, russh Handler
crates/forge-web/src/
main.rs arg dispatch: serve (default) | hook <name> | admin create-user
boot.rs serve(): config, store, git checks, gitconfig/hooks, routers, ssh task
state.rs AppState
auth.rs session cookie, current_user(), CSRF check, Basic-auth parse, token auth
git_http.rs smart-HTTP routes
internal.rs internal hook API router (unix socket)
hook_cmd.rs `hook` subcommand client
ssh_backend.rs impl forge_ssh::GitBackend
admin_cli.rs `admin create-user`
views/mod.rs layout + shared view helpers
pages/{setup,login,dashboard,repo,new_repo,settings_keys,settings_tokens,settings_sessions,admin_users}.rs
forge-core types
pub type Id = String;
pub enum Visibility { Public, Private } // stored as "public" / "private"
pub enum Role { Read, Triage, Write, Maintain, Admin } // stored lowercase
pub struct User { pub id: Id, pub username: String, pub email: Option<String>, pub password_hash: String, pub is_admin: bool, pub created_at: String }
pub struct Repo { pub id: Id, pub owner_id: Id, pub owner_name: String, pub name: String, pub description: String, pub visibility: Visibility, pub default_branch: String, pub archived: bool, pub created_at: String }
pub struct SshKey { pub id: Id, pub user_id: Id, pub title: String, pub algorithm: String, pub key_blob: Vec<u8>, pub fingerprint: String, pub created_at: String, pub last_used_at: Option<String> }
pub struct AccessToken { pub id: Id, pub user_id: Id, pub name: String, pub scopes: Vec<String>, pub expires_at: Option<String>, pub created_at: String, pub last_used_at: Option<String> }
pub struct Session { pub id: Id, pub user_id: Id, pub csrf_token: String, pub created_at: String, pub expires_at: String, pub last_seen: String, pub user_agent: Option<String> }
key_blob is the raw SSH wire-format public key blob, the bytes base64-encoded in an authorized_keys line. Lookup is an exact byte comparison.
forge-core functions
// config.rs
pub struct Config { pub database: PathBuf, pub data_dir: PathBuf, pub listen: SocketAddr, pub base_url: String,
pub user_content_base_url: String, pub ssh_listen: SocketAddr, pub ssh_public_host: String,
pub ssh_public_port: u16, pub registration: Registration }
pub enum Registration { Closed, Invite, Open }
impl Config { pub fn load(path: &Path) -> Result<Config> } // writes dev defaults if the file is missing
// secret.rs (XChaCha20-Poly1305)
pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]> // 0600, 32 random bytes
pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> String // "v1:" + base64(nonce || ciphertext)
pub fn open(key: &[u8; 32], sealed: &str) -> Result<Vec<u8>>
// names.rs
pub fn normalize_name(input: &str) -> String // NFKC + lowercase
pub fn validate_name(input: &str) -> Result<String> // returns the folded name or Error::InvalidName
// password.rs (argon2id)
pub fn hash_password(pw: &str) -> Result<String>
pub fn verify_password(pw: &str, hash: &str) -> bool
// token.rs
pub fn generate_token() -> String // "forge_" + base64url(32 random bytes)
pub fn hash_token(token: &str) -> String // hex sha256
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool
// session.rs
pub fn new_session_token() -> String // base64url(32 random bytes)
pub fn new_csrf_token() -> String
// authz.rs (pure, no IO)
pub enum Action { Read, Write, Admin }
pub enum Denied { NotFound, Forbidden } // NotFound when the actor can't even see the repo
pub fn authorize(actor: Option<&User>, repo: &Repo, collaborator_role: Option<Role>, action: Action) -> Result<(), Denied>
// Rules: owner or instance admin → all. Public repo → Read for anyone, including anonymous.
// Private repo with no role → NotFound. Role Read/Triage → Read. Write/Maintain → Read+Write. Admin → all.
// Can see but lacks the right → Forbidden. Archived repos deny Write (Forbidden).
forge-core Store (async, Clone, wraps the turso connection)
impl Store {
pub async fn open(path: &Path) -> Result<Store>; // WAL, synchronous=FULL, foreign_keys=ON, busy_timeout, runs migrations
// users
pub async fn user_count(&self) -> Result<u64>;
pub async fn create_user(&self, username: &str, email: Option<&str>, password_hash: &str, is_admin: bool) -> Result<User>;
pub async fn user_by_id(&self, id: &str) -> Result<Option<User>>;
pub async fn user_by_username(&self, username: &str) -> Result<Option<User>>; // normalized lookup
pub async fn list_users(&self) -> Result<Vec<User>>;
// sessions
pub async fn create_session(&self, user_id: &str, token_hash: &str, csrf_token: &str, expires_at: &str, user_agent: Option<&str>) -> Result<Session>;
pub async fn session_by_token_hash(&self, token_hash: &str) -> Result<Option<Session>>; // only unexpired
pub async fn touch_session(&self, id: &str) -> Result<()>;
pub async fn delete_session(&self, id: &str) -> Result<()>;
pub async fn delete_user_sessions(&self, user_id: &str) -> Result<()>;
pub async fn list_sessions(&self, user_id: &str) -> Result<Vec<Session>>;
// ssh keys
pub async fn add_ssh_key(&self, user_id: &str, title: &str, algorithm: &str, key_blob: &[u8], fingerprint: &str) -> Result<SshKey>;
pub async fn ssh_key_by_blob(&self, key_blob: &[u8]) -> Result<Option<SshKey>>;
pub async fn list_ssh_keys(&self, user_id: &str) -> Result<Vec<SshKey>>;
pub async fn delete_ssh_key(&self, user_id: &str, key_id: &str) -> Result<bool>;
pub async fn touch_ssh_key(&self, key_id: &str) -> Result<()>;
// tokens
pub async fn create_token(&self, user_id: &str, name: &str, token_hash: &str, scopes: &[String], expires_at: Option<&str>) -> Result<AccessToken>;
pub async fn token_by_hash(&self, token_hash: &str) -> Result<Option<AccessToken>>; // only unexpired
pub async fn list_tokens(&self, user_id: &str) -> Result<Vec<AccessToken>>;
pub async fn delete_token(&self, user_id: &str, token_id: &str) -> Result<bool>;
pub async fn touch_token(&self, token_id: &str) -> Result<()>;
// repos
pub async fn create_repo(&self, owner_id: &str, name: &str, description: &str, visibility: Visibility, default_branch: &str) -> Result<Repo>;
pub async fn repo_by_owner_and_name(&self, owner: &str, name: &str) -> Result<Option<Repo>>; // normalized; a trailing ".git" on name stripped by caller
pub async fn repo_by_id(&self, id: &str) -> Result<Option<Repo>>;
pub async fn list_repos_by_owner(&self, owner_id: &str) -> Result<Vec<Repo>>;
pub async fn set_repo_archived(&self, repo_id: &str, archived: bool) -> Result<()>;
pub async fn collaborator_role(&self, repo_id: &str, user_id: &str) -> Result<Option<Role>>;
// events
pub async fn record_push_event(&self, repo_id: &str, actor_id: &str, ref_name: &str, old_oid: &str, new_oid: &str, protocol: &str) -> Result<()>;
}
forge-git
pub struct GitEnv { pub git_bin: PathBuf, pub data_dir: PathBuf } // gitconfig at data_dir/gitconfig, hooks at data_dir/hooks
pub struct GitCommand { .. }
impl GitCommand {
pub fn new(env: &GitEnv, subcommand: &'static str) -> Self; // env_clear(); PATH=/usr/bin:/bin; HOME=data_dir; GIT_CONFIG_NOSYSTEM=1; GIT_CONFIG_GLOBAL=data_dir/gitconfig
pub fn arg(self, a: &'static str) -> Self; // trusted static flags only
pub fn git_dir(self, p: &Path) -> Self; // --git-dir=<p>, placed before the subcommand
pub fn user_revs(self, revs: &[&str]) -> Self; // appends --end-of-options then the revs; rejects revs starting with '-'
pub fn paths(self, paths: &[&str]) -> Self; // appends -- then the paths
pub fn env(self, k: &'static str, v: impl AsRef<OsStr>) -> Self;
pub fn into_tokio(self) -> tokio::process::Command;
}
pub fn repo_path(data_dir: &Path, repo_id: &str) -> PathBuf; // data_dir/repos/<id[0..2]>/<id[2..4]>/<id>.git (lowercased ULID)
pub async fn init_bare(env: &GitEnv, path: &Path, default_branch: &str) -> Result<()>;
pub async fn list_branches(env: &GitEnv, path: &Path) -> Result<Vec<String>>;
pub async fn git_version(env: &GitEnv) -> Result<(u32, u32, u32)>;
pub async fn require_min_version(env: &GitEnv, min: (u32, u32, u32)) -> Result<()>; // min (2,38,0)
pub fn write_managed_gitconfig(data_dir: &Path) -> Result<()>;
pub fn write_hook_shims(data_dir: &Path, forge_bin: &Path) -> Result<()>; // pre-receive, post-receive; 0755
pub enum GitService { UploadPack, ReceivePack } // "git-upload-pack" / "git-receive-pack"
impl GitService { pub fn from_name(s: &str) -> Option<Self>; pub fn name(&self) -> &'static str; }
pub fn service_command(env: &GitEnv, svc: GitService, repo: &Path, stateless_rpc: bool, advertise_refs: bool, git_protocol: Option<&str>) -> GitCommand;
pub fn pkt_line(data: &str) -> Vec<u8>; pub const FLUSH_PKT: &[u8] = b"0000";
forge-ssh
pub struct SshConfig { pub listen: SocketAddr, pub host_key_path: PathBuf, pub auth_timeout: Duration, pub inactivity_timeout: Duration }
pub trait GitBackend: Send + Sync + 'static {
/// Exact-match lookup of the raw public key blob → user id
fn authenticate_key(&self, algorithm: &str, key_blob: &[u8]) -> impl Future<Output = Option<String>> + Send;
/// Authorize and build the git process (stdin/stdout piped by the caller). Err → message sent on stderr, exit 1.
fn prepare(&self, user_id: &str, service: forge_git::GitService, repo_path: &str, git_protocol: Option<&str>)
-> impl Future<Output = Result<tokio::process::Command, String>> + Send;
}
pub fn parse_exec(cmd: &str) -> Result<(forge_git::GitService, String), ParseError>; // "git-upload-pack '/owner/repo.git'"
pub async fn run<B: GitBackend>(config: SshConfig, backend: Arc<B>) -> Result<()>;
Hook protocol (forge-web internal API, over a unix socket at data_dir/internal.sock)
- receive-pack runs with these env vars:
FORGE_INTERNAL_SOCKET,FORGE_INTERNAL_TOKEN,FORGE_ACTOR_ID,FORGE_REPO_ID,FORGE_PROTOCOL(http|ssh). forge-web hook pre-receive|post-receivereads stdin lines<old> <new> <ref>. It POSTs JSON{repo_id, actor_id, protocol, updates:[{old,new,ref}], quarantine_env:{GIT_QUARANTINE_PATH, GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES}}to/hook/pre-receiveor/hook/post-receive, with headerX-Forge-Internal-Token.- Response JSON is
{allow: bool, message: String}. The hook printsmessageto stderr and exits 0 if allowed, 1 if not.