# 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 add` into `[workspace.dependencies]`; crates use `{ workspace = true }`. - No `unsafe`. No `unwrap()`/`expect()` in non-test code, except at boot with a clear message. - Errors: `thiserror` inside library crates, `forge_core::Error` across crates. - IDs are ULID strings (`ulid` crate). Timestamps are UTC RFC 3339 strings with 9 fractional digits and a `Z` suffix, produced by `forge_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 | 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 ```rust 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, 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, pub fingerprint: String, pub created_at: String, pub last_used_at: Option } pub struct AccessToken { pub id: Id, pub user_id: Id, pub name: String, pub scopes: Vec, pub expires_at: Option, pub created_at: String, pub last_used_at: Option } 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 } ``` `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 ```rust // 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 } // 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> // names.rs pub fn normalize_name(input: &str) -> String // NFKC + lowercase pub fn validate_name(input: &str) -> Result // returns the folded name or Error::InvalidName // password.rs (argon2id) pub fn hash_password(pw: &str) -> Result 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, 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) ```rust impl Store { pub async fn open(path: &Path) -> Result; // WAL, synchronous=FULL, foreign_keys=ON, busy_timeout, runs migrations // users pub async fn user_count(&self) -> Result; pub async fn create_user(&self, username: &str, email: Option<&str>, password_hash: &str, is_admin: bool) -> Result; pub async fn user_by_id(&self, id: &str) -> Result>; pub async fn user_by_username(&self, username: &str) -> Result>; // normalized lookup pub async fn list_users(&self) -> Result>; // sessions pub async fn create_session(&self, user_id: &str, token_hash: &str, csrf_token: &str, expires_at: &str, user_agent: Option<&str>) -> Result; pub async fn session_by_token_hash(&self, token_hash: &str) -> Result>; // 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>; // ssh keys pub async fn add_ssh_key(&self, user_id: &str, title: &str, algorithm: &str, key_blob: &[u8], fingerprint: &str) -> Result; pub async fn ssh_key_by_blob(&self, key_blob: &[u8]) -> Result>; pub async fn list_ssh_keys(&self, user_id: &str) -> Result>; pub async fn delete_ssh_key(&self, user_id: &str, key_id: &str) -> Result; 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; pub async fn token_by_hash(&self, token_hash: &str) -> Result>; // only unexpired pub async fn list_tokens(&self, user_id: &str) -> Result>; pub async fn delete_token(&self, user_id: &str, token_id: &str) -> Result; 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; pub async fn repo_by_owner_and_name(&self, owner: &str, name: &str) -> Result>; // normalized; a trailing ".git" on name stripped by caller pub async fn repo_by_id(&self, id: &str) -> Result>; pub async fn list_repos_by_owner(&self, owner_id: &str) -> Result>; 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>; // 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 ```rust 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=

, 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) -> Self; pub fn into_tokio(self) -> tokio::process::Command; } pub fn repo_path(data_dir: &Path, repo_id: &str) -> PathBuf; // data_dir/repos///.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>; 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; 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; pub const FLUSH_PKT: &[u8] = b"0000"; ``` ## forge-ssh ```rust 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> + 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> + Send; } pub fn parse_exec(cmd: &str) -> Result<(forge_git::GitService, String), ParseError>; // "git-upload-pack '/owner/repo.git'" pub async fn run(config: SshConfig, backend: Arc) -> 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-receive` reads stdin lines ` `. 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-receive` or `/hook/post-receive`, with header `X-Forge-Internal-Token`. - Response JSON is `{allow: bool, message: String}`. The hook prints `message` to stderr and exits 0 if allowed, 1 if not.