Signed by hanna SSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/ssh/src/server.rs +72 −3 docs/decisions.md +32 −0 crates/ssh/src/server.rs +72 −3 269 . ok ( ) 269 . ok ( ) 270 . flatten ( ) 270 . flatten ( ) 271 . ok_or_else ( not_found) ?; 271 . ok_or_else ( not_found) ?; 272 let repo = self 272 let existing = self 273 . shared 273 . shared 274 . store 274 . store 275 . repo_by_owner_path ( & owner_user. id, repo_path) 275 . repo_by_owner_path ( & owner_user. id, repo_path) 276 . await 276 . await 277 . ok ( ) 277 . ok ( ) 278 . flatten ( ) 278 . flatten ( ) ; 279 . ok_or_else ( not_found) ?; 279 280 281 282 283 let repo = if let Some ( repo) = existing { 284 repo 285 } else if matches ! ( service, git:: pack:: Service :: ReceivePack ) 286 && ( identity. user_id == owner_user. id || identity. is_admin) 287 { 288 self . create_on_push ( & owner_user, repo_path) . await ? 289 } else { 290 return Err ( not_found ( ) ) ; 291 } ; 280 292 281 let collaborator = self 293 let collaborator = self 282 . shared 294 . shared 310 Err ( not_found ( ) ) 322 Err ( not_found ( ) ) 311 } 323 } 312 } 324 } 325 326 327 328 329 async fn create_on_push ( 330 & self , 331 owner : & model:: User , 332 repo_path : & str , 333 ) -> Result < model:: Repo , String > { 334 let ( group_path, leaf) = match repo_path. rsplit_once ( '/' ) { 335 Some ( ( group, leaf) ) => ( Some ( group) , leaf) , 336 None => ( None , repo_path) , 337 } ; 338 let group_id = match group_path { 339 Some ( gp) => Some ( 340 self . shared 341 . store 342 . ensure_group_path ( & owner. id, gp) 343 . await 344 . map_err ( |e| e. to_string ( ) ) ? 345 . id, 346 ) , 347 None => None , 348 } ; 349 let branch = self . shared. config. instance. default_branch. clone ( ) ; 350 let visibility = model:: Visibility :: from_token ( 351 self . shared. config. instance. default_visibility. as_token ( ) , 352 ) 353 . unwrap_or ( model:: Visibility :: Private ) ; 354 let repo = self 355 . shared 356 . store 357 . create_repo ( store:: NewRepo { 358 owner_id: owner. id. clone ( ) , 359 group_id, 360 name: leaf. to_string ( ) , 361 path: repo_path. to_string ( ) , 362 description: None , 363 visibility, 364 default_branch: branch. clone ( ) , 365 } ) 366 . await 367 . map_err ( |e| e. to_string ( ) ) ?; 368 369 let hook = std:: env:: current_exe ( ) . unwrap_or_else ( |_| std:: path:: PathBuf :: from ( "fabrica" ) ) ; 370 if let Err ( err) = git:: create_bare ( 371 & self . shared. config. storage. repo_dir, 372 & repo. id, 373 & branch, 374 & hook, 375 ) { 376 let _ = self . shared. store. delete_repo ( & repo. id) . await ; 377 return Err ( format ! ( "Could not create repository: {err}" ) ) ; 378 } 379 tracing:: info!( owner = %owner. username, path = %repo_path, "push-to-create: repository created" ) ; 380 Ok ( repo) 381 } 313 } 382 } 314 383 315 384
docs/decisions.md +32 −0 584 is refused so no user-supplied markup is ever served, while our own identicons are 584 is refused so no user-supplied markup is ever served, while our own identicons are 585 SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the 585 SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the 586 double-submit CSRF token; `/settings` redirects anonymous visitors to `/login`. 586 double-submit CSRF token; `/settings` redirects anonymous visitors to `/login`. 587 588 ## 2026-07-25 — Three-level visibility, repo settings, and SSH push-to-create 589 590 **Decision:** Repositories carry a GitLab-style **visibility** — `public` 591 (everyone, anonymous gated by `allow_anonymous`), `internal` (any signed-in 592 user), or `private` (owner/collaborators/admins) — replacing the old two-state 593 `is_private` flag. `auth::access` decides read on visibility after the 594 admin/owner/collaborator rules; `instance.default_visibility` (default `private`) 595 sets the default for new repos. Owners/admins get a **Settings** repo tab 596 (rename, visibility, delete) whose mutations post to a fixed `/repo-settings/{id}` 597 prefix (never colliding with the `/{owner}/{*rest}` catch-all) and re-check Admin 598 access, returning `404` — never `403` — to non-admins. An SSH **push to a 599 non-existent repo** owned by the pushing user (or an admin) **auto-creates** it at 600 the default visibility (row + bare repo on disk, rolled back on disk failure). 601 602 **Migration:** `0004_repo_visibility` adds a `visibility TEXT NOT NULL DEFAULT 603 'private'` column backfilled from `is_private`; the `is_private` column is left in 604 place (SQLite cannot drop columns) but no longer read or written. 605 606 **Alternatives:** keeping the boolean and bolting "internal" on as a second flag 607 (two booleans encode four states, one nonsensical); a dedicated POST route per 608 repo path (impossible — repo paths are variable-length under groups); refusing 609 push-to-create (an extra round-trip to the CLes/UI for every new repo). 610 611 **Rationale:** three named states model real instances (internal is the common 612 "logged-in only" tier) and keep `access` a single exhaustively-tested function — 613 its matrix test now enumerates all three visibilities × viewer kinds × the anon 614 flag. `allow_anonymous` now gates only anonymous access to *public* repos, which 615 is what it always meant. The fixed `/repo-settings/{id}` prefix sidesteps the 616 catch-all cleanly and keys on the id (rename-safe). Push-to-create matches the 617 muscle memory of every other forge and stays safe: only the authenticated owner 618 (or an admin) can trigger it, and only on receive-pack.