fabrica

hanna/fabrica

feat(ssh): push-to-create a private repo on first push

8a86690 · hanna committed on 2026-07-25

A push (receive-pack) to a non-existent repo owned by the authenticated
user — or by an admin — now auto-creates it at the instance default
visibility: any missing group prefix, the database row, and the bare repo on
disk (rolled back if the on-disk step fails). Any other case stays a 404 so a
stranger cannot probe for repositories. Also log this session's visibility,
settings, and push-to-create decisions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +104 −3UnifiedSplit
crates/ssh/src/server.rs +72 −3
@@ -269,14 +269,26 @@ impl GitHandler {
269269 .ok()
270270 .flatten()
271271 .ok_or_else(not_found)?;
272- let repo = self
272+ let existing = self
273273 .shared
274274 .store
275275 .repo_by_owner_path(&owner_user.id, repo_path)
276276 .await
277277 .ok()
278- .flatten()
279- .ok_or_else(not_found)?;
278+ .flatten();
279+ // Push-to-create: a push (receive-pack) to a non-existent repo owned by
280+ // the pushing user (or by an admin) auto-creates it, at the instance
281+ // default visibility. Any other case is a 404 so a stranger cannot probe
282+ // for repositories.
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+ };
280292
281293 let collaborator = self
282294 .shared
@@ -310,6 +322,63 @@ impl GitHandler {
310322 Err(not_found())
311323 }
312324 }
325+
326+ /// Create a repository on first push: any missing group prefix, the database
327+ /// row (visibility = instance default), and the bare repo on disk. Rolls the
328+ /// row back if the on-disk creation fails.
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+ }
313382 }
314383
315384 /// Run the SSH server until the process exits. A no-op when `ssh.enabled` is false.
docs/decisions.md +32 −0
@@ -584,3 +584,35 @@ subset. Uploads are limited to raster types (PNG/JPEG/GIF/WebP, ≤ 1 MiB); SVG
584584 is refused so no user-supplied markup is ever served, while our own identicons are
585585 SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the
586586 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.