Tahinli/forge
public0 watching
★ 0
| 1 | # Milestone 1: shared interfaces (contract between workers) |
| 2 | |
| 3 | Every worker codes against these names and signatures. If a signature must change, keep the change minimal and say so in your report. |
| 4 | The full plan is at `/home/tahinli/.claude/plans/purring-prancing-wirth.md`. The research behind it is in `docs/research.md`. |
| 5 | |
| 6 | ## Global rules |
| 7 | - Edition 2024, resolver 3. **Every dependency at its latest release**, added with `cargo add` into `[workspace.dependencies]`; crates use `{ workspace = true }`. |
| 8 | - No `unsafe`. No `unwrap()`/`expect()` in non-test code, except at boot with a clear message. |
| 9 | - Errors: `thiserror` inside library crates, `forge_core::Error` across crates. |
| 10 | - 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()`. |
| 11 | - Never invoke a shell. Never pass user input to git except through `forge_git::GitCommand`. |
| 12 | |
| 13 | ## Module and file ownership |
| 14 | ``` |
| 15 | crates/forge-core/src/ |
| 16 | lib.rs mod declarations + re-exports |
| 17 | error.rs Error enum |
| 18 | time.rs now_stamp(), stamp_after(Duration) |
| 19 | model.rs domain types (below) |
| 20 | config.rs Config + load() |
| 21 | secret.rs load_or_create_key(), seal(), open() |
| 22 | names.rs normalize_name(), validate_name(), RESERVED |
| 23 | password.rs hash_password(), verify_password() |
| 24 | token.rs generate_token(), hash_token(), constant_time_eq() |
| 25 | session.rs new_session_token(), new_csrf_token() |
| 26 | authz.rs Action, Denied, authorize() |
| 27 | store/mod.rs Store (all DB methods) |
| 28 | store/migrate.rs migration runner (schema_migrations table) |
| 29 | crates/forge-core/migrations/0001_init.sql |
| 30 | |
| 31 | crates/forge-git/src/ |
| 32 | lib.rs, error.rs |
| 33 | command.rs GitCommand builder |
| 34 | version.rs git_version(), require_min_version() |
| 35 | pktline.rs encode/flush helpers |
| 36 | repo.rs repo_path(), init_bare(), list_branches() |
| 37 | setup.rs write_managed_gitconfig(), write_hook_shims() |
| 38 | service.rs GitService, service_command() |
| 39 | |
| 40 | crates/forge-ssh/src/ |
| 41 | lib.rs, error.rs |
| 42 | backend.rs GitBackend trait |
| 43 | parse.rs parse_exec() |
| 44 | server.rs run(), host key load/create, russh Handler |
| 45 | |
| 46 | crates/forge-web/src/ |
| 47 | main.rs arg dispatch: serve (default) | hook <name> | admin create-user |
| 48 | boot.rs serve(): config, store, git checks, gitconfig/hooks, routers, ssh task |
| 49 | state.rs AppState |
| 50 | auth.rs session cookie, current_user(), CSRF check, Basic-auth parse, token auth |
| 51 | git_http.rs smart-HTTP routes |
| 52 | internal.rs internal hook API router (unix socket) |
| 53 | hook_cmd.rs `hook` subcommand client |
| 54 | ssh_backend.rs impl forge_ssh::GitBackend |
| 55 | admin_cli.rs `admin create-user` |
| 56 | views/mod.rs layout + shared view helpers |
| 57 | pages/{setup,login,dashboard,repo,new_repo,settings_keys,settings_tokens,settings_sessions,admin_users}.rs |
| 58 | ``` |
| 59 | |
| 60 | ## forge-core types |
| 61 | ```rust |
| 62 | pub type Id = String; |
| 63 | pub enum Visibility { Public, Private } // stored as "public" / "private" |
| 64 | pub enum Role { Read, Triage, Write, Maintain, Admin } // stored lowercase |
| 65 | 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 } |
| 66 | 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 } |
| 67 | 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> } |
| 68 | 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> } |
| 69 | 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> } |
| 70 | ``` |
| 71 | `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. |
| 72 | |
| 73 | ## forge-core functions |
| 74 | ```rust |
| 75 | // config.rs |
| 76 | pub struct Config { pub database: PathBuf, pub data_dir: PathBuf, pub listen: SocketAddr, pub base_url: String, |
| 77 | pub user_content_base_url: String, pub ssh_listen: SocketAddr, pub ssh_public_host: String, |
| 78 | pub ssh_public_port: u16, pub registration: Registration } |
| 79 | pub enum Registration { Closed, Invite, Open } |
| 80 | impl Config { pub fn load(path: &Path) -> Result<Config> } // writes dev defaults if the file is missing |
| 81 | |
| 82 | // secret.rs (XChaCha20-Poly1305) |
| 83 | pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]> // 0600, 32 random bytes |
| 84 | pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> String // "v1:" + base64(nonce || ciphertext) |
| 85 | pub fn open(key: &[u8; 32], sealed: &str) -> Result<Vec<u8>> |
| 86 | |
| 87 | // names.rs |
| 88 | pub fn normalize_name(input: &str) -> String // NFKC + lowercase |
| 89 | pub fn validate_name(input: &str) -> Result<String> // returns the folded name or Error::InvalidName |
| 90 | |
| 91 | // password.rs (argon2id) |
| 92 | pub fn hash_password(pw: &str) -> Result<String> |
| 93 | pub fn verify_password(pw: &str, hash: &str) -> bool |
| 94 | |
| 95 | // token.rs |
| 96 | pub fn generate_token() -> String // "forge_" + base64url(32 random bytes) |
| 97 | pub fn hash_token(token: &str) -> String // hex sha256 |
| 98 | pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool |
| 99 | |
| 100 | // session.rs |
| 101 | pub fn new_session_token() -> String // base64url(32 random bytes) |
| 102 | pub fn new_csrf_token() -> String |
| 103 | |
| 104 | // authz.rs (pure, no IO) |
| 105 | pub enum Action { Read, Write, Admin } |
| 106 | pub enum Denied { NotFound, Forbidden } // NotFound when the actor can't even see the repo |
| 107 | pub fn authorize(actor: Option<&User>, repo: &Repo, collaborator_role: Option<Role>, action: Action) -> Result<(), Denied> |
| 108 | // Rules: owner or instance admin → all. Public repo → Read for anyone, including anonymous. |
| 109 | // Private repo with no role → NotFound. Role Read/Triage → Read. Write/Maintain → Read+Write. Admin → all. |
| 110 | // Can see but lacks the right → Forbidden. Archived repos deny Write (Forbidden). |
| 111 | ``` |
| 112 | |
| 113 | ## forge-core Store (async, `Clone`, wraps the turso connection) |
| 114 | ```rust |
| 115 | impl Store { |
| 116 | pub async fn open(path: &Path) -> Result<Store>; // WAL, synchronous=FULL, foreign_keys=ON, busy_timeout, runs migrations |
| 117 | // users |
| 118 | pub async fn user_count(&self) -> Result<u64>; |
| 119 | pub async fn create_user(&self, username: &str, email: Option<&str>, password_hash: &str, is_admin: bool) -> Result<User>; |
| 120 | pub async fn user_by_id(&self, id: &str) -> Result<Option<User>>; |
| 121 | pub async fn user_by_username(&self, username: &str) -> Result<Option<User>>; // normalized lookup |
| 122 | pub async fn list_users(&self) -> Result<Vec<User>>; |
| 123 | // sessions |
| 124 | pub async fn create_session(&self, user_id: &str, token_hash: &str, csrf_token: &str, expires_at: &str, user_agent: Option<&str>) -> Result<Session>; |
| 125 | pub async fn session_by_token_hash(&self, token_hash: &str) -> Result<Option<Session>>; // only unexpired |
| 126 | pub async fn touch_session(&self, id: &str) -> Result<()>; |
| 127 | pub async fn delete_session(&self, id: &str) -> Result<()>; |
| 128 | pub async fn delete_user_sessions(&self, user_id: &str) -> Result<()>; |
| 129 | pub async fn list_sessions(&self, user_id: &str) -> Result<Vec<Session>>; |
| 130 | // ssh keys |
| 131 | pub async fn add_ssh_key(&self, user_id: &str, title: &str, algorithm: &str, key_blob: &[u8], fingerprint: &str) -> Result<SshKey>; |
| 132 | pub async fn ssh_key_by_blob(&self, key_blob: &[u8]) -> Result<Option<SshKey>>; |
| 133 | pub async fn list_ssh_keys(&self, user_id: &str) -> Result<Vec<SshKey>>; |
| 134 | pub async fn delete_ssh_key(&self, user_id: &str, key_id: &str) -> Result<bool>; |
| 135 | pub async fn touch_ssh_key(&self, key_id: &str) -> Result<()>; |
| 136 | // tokens |
| 137 | pub async fn create_token(&self, user_id: &str, name: &str, token_hash: &str, scopes: &[String], expires_at: Option<&str>) -> Result<AccessToken>; |
| 138 | pub async fn token_by_hash(&self, token_hash: &str) -> Result<Option<AccessToken>>; // only unexpired |
| 139 | pub async fn list_tokens(&self, user_id: &str) -> Result<Vec<AccessToken>>; |
| 140 | pub async fn delete_token(&self, user_id: &str, token_id: &str) -> Result<bool>; |
| 141 | pub async fn touch_token(&self, token_id: &str) -> Result<()>; |
| 142 | // repos |
| 143 | pub async fn create_repo(&self, owner_id: &str, name: &str, description: &str, visibility: Visibility, default_branch: &str) -> Result<Repo>; |
| 144 | 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 |
| 145 | pub async fn repo_by_id(&self, id: &str) -> Result<Option<Repo>>; |
| 146 | pub async fn list_repos_by_owner(&self, owner_id: &str) -> Result<Vec<Repo>>; |
| 147 | pub async fn set_repo_archived(&self, repo_id: &str, archived: bool) -> Result<()>; |
| 148 | pub async fn collaborator_role(&self, repo_id: &str, user_id: &str) -> Result<Option<Role>>; |
| 149 | // events |
| 150 | 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<()>; |
| 151 | } |
| 152 | ``` |
| 153 | |
| 154 | ## forge-git |
| 155 | ```rust |
| 156 | pub struct GitEnv { pub git_bin: PathBuf, pub data_dir: PathBuf } // gitconfig at data_dir/gitconfig, hooks at data_dir/hooks |
| 157 | pub struct GitCommand { .. } |
| 158 | impl GitCommand { |
| 159 | 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 |
| 160 | pub fn arg(self, a: &'static str) -> Self; // trusted static flags only |
| 161 | pub fn git_dir(self, p: &Path) -> Self; // --git-dir=<p>, placed before the subcommand |
| 162 | pub fn user_revs(self, revs: &[&str]) -> Self; // appends --end-of-options then the revs; rejects revs starting with '-' |
| 163 | pub fn paths(self, paths: &[&str]) -> Self; // appends -- then the paths |
| 164 | pub fn env(self, k: &'static str, v: impl AsRef<OsStr>) -> Self; |
| 165 | pub fn into_tokio(self) -> tokio::process::Command; |
| 166 | } |
| 167 | pub fn repo_path(data_dir: &Path, repo_id: &str) -> PathBuf; // data_dir/repos/<id[0..2]>/<id[2..4]>/<id>.git (lowercased ULID) |
| 168 | pub async fn init_bare(env: &GitEnv, path: &Path, default_branch: &str) -> Result<()>; |
| 169 | pub async fn list_branches(env: &GitEnv, path: &Path) -> Result<Vec<String>>; |
| 170 | pub async fn git_version(env: &GitEnv) -> Result<(u32, u32, u32)>; |
| 171 | pub async fn require_min_version(env: &GitEnv, min: (u32, u32, u32)) -> Result<()>; // min (2,38,0) |
| 172 | pub fn write_managed_gitconfig(data_dir: &Path) -> Result<()>; |
| 173 | pub fn write_hook_shims(data_dir: &Path, forge_bin: &Path) -> Result<()>; // pre-receive, post-receive; 0755 |
| 174 | pub enum GitService { UploadPack, ReceivePack } // "git-upload-pack" / "git-receive-pack" |
| 175 | impl GitService { pub fn from_name(s: &str) -> Option<Self>; pub fn name(&self) -> &'static str; } |
| 176 | pub fn service_command(env: &GitEnv, svc: GitService, repo: &Path, stateless_rpc: bool, advertise_refs: bool, git_protocol: Option<&str>) -> GitCommand; |
| 177 | pub fn pkt_line(data: &str) -> Vec<u8>; pub const FLUSH_PKT: &[u8] = b"0000"; |
| 178 | ``` |
| 179 | |
| 180 | ## forge-ssh |
| 181 | ```rust |
| 182 | pub struct SshConfig { pub listen: SocketAddr, pub host_key_path: PathBuf, pub auth_timeout: Duration, pub inactivity_timeout: Duration } |
| 183 | pub trait GitBackend: Send + Sync + 'static { |
| 184 | /// Exact-match lookup of the raw public key blob → user id |
| 185 | fn authenticate_key(&self, algorithm: &str, key_blob: &[u8]) -> impl Future<Output = Option<String>> + Send; |
| 186 | /// Authorize and build the git process (stdin/stdout piped by the caller). Err → message sent on stderr, exit 1. |
| 187 | fn prepare(&self, user_id: &str, service: forge_git::GitService, repo_path: &str, git_protocol: Option<&str>) |
| 188 | -> impl Future<Output = Result<tokio::process::Command, String>> + Send; |
| 189 | } |
| 190 | pub fn parse_exec(cmd: &str) -> Result<(forge_git::GitService, String), ParseError>; // "git-upload-pack '/owner/repo.git'" |
| 191 | pub async fn run<B: GitBackend>(config: SshConfig, backend: Arc<B>) -> Result<()>; |
| 192 | ``` |
| 193 | |
| 194 | ## Hook protocol (forge-web internal API, over a unix socket at data_dir/internal.sock) |
| 195 | - receive-pack runs with these env vars: `FORGE_INTERNAL_SOCKET`, `FORGE_INTERNAL_TOKEN`, `FORGE_ACTOR_ID`, `FORGE_REPO_ID`, `FORGE_PROTOCOL` (`http` | `ssh`). |
| 196 | - `forge-web hook pre-receive|post-receive` reads 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-receive` or `/hook/post-receive`, with header `X-Forge-Internal-Token`. |
| 197 | - Response JSON is `{allow: bool, message: String}`. The hook prints `message` to stderr and exits 0 if allowed, 1 if not. |