krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

2965d74d76d6fa462d8fe7ca5d59c1f615b89ded

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T22:12:46Z

M0: config validation, schema migrations, CLI skeleton

- forged check-config with static contradiction checks and host probes
- SQLite store with embedded up/down migrations, v1 schema, key_epoch seed
- reserved-name and owner/repo name validation
- forge CLI command tree (stubs), forged serve/migrate/admin entry points
 .gitignore                                   |   3 +
 cmd/forge/main.go                            | 143 ++++++++++++++++++
 cmd/forged/main.go                           | 125 ++++++++++++++++
 go.mod                                       |  23 +++
 go.sum                                       |  62 ++++++++
 internal/config/config.go                    | 176 ++++++++++++++++++++++
 internal/config/config_test.go               | 121 +++++++++++++++
 internal/policy/names.go                     |  60 ++++++++
 internal/policy/names_test.go                |  42 ++++++
 internal/protocol/protocol.go                |  26 ++++
 internal/store/migrations/0001_init.down.sql |  22 +++
 internal/store/migrations/0001_init.up.sql   | 210 +++++++++++++++++++++++++++
 internal/store/store.go                      | 167 +++++++++++++++++++++
 internal/store/store_test.go                 |  98 +++++++++++++
 14 files changed, 1278 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5990a7f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/forge
+/forged
+*.db
diff --git a/cmd/forge/main.go b/cmd/forge/main.go
new file mode 100644
index 0000000..5effcfd
--- /dev/null
+++ b/cmd/forge/main.go
@@ -0,0 +1,143 @@
+// forge is the client CLI. It speaks to a forge server over the system ssh
+// binary; it is ergonomics on top of a control plane that is fully usable
+// from bare OpenSSH.
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+
+	"github.com/krazywarez/forge/internal/protocol"
+)
+
+func main() {
+	root := &cobra.Command{
+		Use:           "forge",
+		Short:         "CLI-first git forge client",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+	}
+	root.PersistentFlags().Bool("json", false, "machine-readable output")
+	root.PersistentFlags().String("repo", "", "owner/name (default: inferred from the origin remote)")
+
+	root.AddCommand(
+		authCmd(),
+		repoCmd(),
+		issueCmd(),
+		mrCmd(),
+		webCmd(),
+		adminCmd(),
+		remoteCmd(),
+		initCmd(),
+	)
+
+	if err := root.Execute(); err != nil {
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		os.Exit(protocol.ExitFailure)
+	}
+}
+
+// stub returns a leaf command that fails until its milestone lands.
+func stub(use, short string) *cobra.Command {
+	return &cobra.Command{
+		Use:   use,
+		Short: short,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return fmt.Errorf("not implemented")
+		},
+	}
+}
+
+func group(use, short string, subs ...*cobra.Command) *cobra.Command {
+	c := &cobra.Command{Use: use, Short: short}
+	c.AddCommand(subs...)
+	return c
+}
+
+func authCmd() *cobra.Command {
+	return group("auth", "identity: keys, emails, whoami",
+		stub("whoami", "show the authenticated account"),
+		group("keys", "manage SSH keys",
+			stub("list", "list registered SSH keys"),
+			stub("add", "register an SSH key"),
+			stub("remove", "remove an SSH key"),
+		),
+		group("pgp", "manage OpenPGP keys",
+			stub("list", "list registered PGP keys"),
+			stub("add", "register a PGP key"),
+			stub("remove", "remove a PGP key"),
+		),
+		group("email", "manage email addresses",
+			stub("add", "add an address"),
+			stub("verify", "confirm a verification code"),
+		),
+	)
+}
+
+func repoCmd() *cobra.Command {
+	return group("repo", "create and manage repositories",
+		stub("create", "create a repository"),
+		stub("list", "list repositories"),
+		stub("show", "show repository details"),
+		stub("clone", "clone via ssh"),
+		stub("rename", "rename a repository"),
+		stub("delete", "delete a repository"),
+		stub("fork", "fork a repository"),
+		stub("import", "server-side mirror from a foreign URL"),
+		stub("settings", "get or set repository settings"),
+	)
+}
+
+func issueCmd() *cobra.Command {
+	return group("issue", "issues",
+		stub("create", "open an issue"),
+		stub("list", "list issues"),
+		stub("show", "show an issue"),
+		stub("comment", "comment on an issue"),
+		stub("close", "close an issue"),
+		stub("reopen", "reopen an issue"),
+		stub("label", "add or remove labels"),
+		stub("assign", "assign users"),
+	)
+}
+
+func mrCmd() *cobra.Command {
+	return group("mr", "merge requests",
+		stub("create", "open a merge request"),
+		stub("list", "list merge requests"),
+		stub("show", "show a merge request"),
+		stub("diff", "show the diff"),
+		stub("checkout", "fetch and check out the MR head locally"),
+		stub("comment", "comment on a merge request"),
+		stub("review", "approve or request changes"),
+		stub("merge", "merge (fast-forward or merge-commit)"),
+		stub("close", "close without merging"),
+	)
+}
+
+func webCmd() *cobra.Command {
+	return group("web", "browser session",
+		stub("login", "mint a one-time browser login URL over ssh"),
+	)
+}
+
+func adminCmd() *cobra.Command {
+	return group("admin", "instance administration (admin accounts only)",
+		stub("user", "manage users"),
+		stub("invite", "issue registration invites"),
+		stub("stats", "instance statistics"),
+	)
+}
+
+func remoteCmd() *cobra.Command {
+	return group("remote", "local instance profiles (no server contact)",
+		stub("add", "add a named forge instance"),
+		stub("list", "list configured instances"),
+	)
+}
+
+func initCmd() *cobra.Command {
+	return stub("init", "git init + repo create + set origin, in one step")
+}
diff --git a/cmd/forged/main.go b/cmd/forged/main.go
new file mode 100644
index 0000000..807126d
--- /dev/null
+++ b/cmd/forged/main.go
@@ -0,0 +1,125 @@
+// forged is the forge server daemon. The same binary also runs in hook mode
+// (invoked by git via core.hooksPath) and hosts the host-local admin commands.
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+
+	"github.com/krazywarez/forge/internal/config"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+var configPath string
+
+func main() {
+	root := &cobra.Command{
+		Use:           "forged",
+		Short:         "forge server daemon",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+	}
+	root.PersistentFlags().StringVar(&configPath, "config", "/etc/forge/config.toml", "path to config file")
+
+	root.AddCommand(
+		checkConfigCmd(),
+		serveCmd(),
+		migrateCmd(),
+		adminCmd(),
+	)
+
+	if err := root.Execute(); err != nil {
+		fmt.Fprintln(os.Stderr, "forged:", err)
+		os.Exit(1)
+	}
+}
+
+func checkConfigCmd() *cobra.Command {
+	var noHost bool
+	cmd := &cobra.Command{
+		Use:   "check-config",
+		Short: "validate the configuration and exit",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(configPath)
+			if err != nil {
+				return err
+			}
+			if !noHost {
+				if err := cfg.CheckHost(); err != nil {
+					return err
+				}
+			}
+			fmt.Println("config ok")
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&noHost, "no-host-checks", false, "skip host environment probes (port binding, paths)")
+	return cmd
+}
+
+func serveCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "serve",
+		Short: "run the ssh, http, and git listeners",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return fmt.Errorf("not implemented (M1)")
+		},
+	}
+}
+
+func migrateCmd() *cobra.Command {
+	var to int
+	cmd := &cobra.Command{
+		Use:   "migrate",
+		Short: "apply schema migrations",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(configPath)
+			if err != nil {
+				return err
+			}
+			s, err := store.Open(cfg.Server.Root + "/forge.db")
+			if err != nil {
+				return err
+			}
+			defer s.Close()
+			if err := s.MigrateTo(to); err != nil {
+				return err
+			}
+			v, err := s.Version()
+			if err != nil {
+				return err
+			}
+			fmt.Println("schema version", v)
+			return nil
+		},
+	}
+	cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)")
+	return cmd
+}
+
+func adminCmd() *cobra.Command {
+	admin := &cobra.Command{
+		Use:   "admin",
+		Short: "host-local administration",
+	}
+	notImplemented := func(use, short string) *cobra.Command {
+		return &cobra.Command{
+			Use:   use,
+			Short: short,
+			RunE: func(cmd *cobra.Command, args []string) error {
+				return fmt.Errorf("not implemented (M1)")
+			},
+		}
+	}
+	admin.AddCommand(
+		notImplemented("user", "create and manage users"),
+		notImplemented("invite", "issue registration invites"),
+		notImplemented("email", "verify user emails"),
+		notImplemented("backup", "consistent backup: repos first, then database"),
+		notImplemented("gc", "run git gc across repositories"),
+		notImplemented("stats", "instance statistics"),
+	)
+	return admin
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0b6db10
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,23 @@
+module github.com/krazywarez/forge
+
+go 1.27.0
+
+require (
+	github.com/BurntSushi/toml v1.6.0
+	github.com/spf13/cobra v1.10.2
+	modernc.org/sqlite v1.57.0
+)
+
+require (
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/mattn/go-isatty v0.0.24 // indirect
+	github.com/ncruces/go-strftime v1.0.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/spf13/pflag v1.0.9 // indirect
+	golang.org/x/sys v0.47.0 // indirect
+	modernc.org/libc v1.74.4 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.11.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2e44787
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,62 @@
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
+modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
+modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
+modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..cee65fd
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,176 @@
+// Package config loads and validates the forged server configuration.
+package config
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"os"
+	"strconv"
+
+	"github.com/BurntSushi/toml"
+)
+
+type Config struct {
+	Server       Server       `toml:"server"`
+	SSH          SSH          `toml:"ssh"`
+	HTTP         HTTP         `toml:"http"`
+	GitDaemon    GitDaemon    `toml:"git_daemon"`
+	Web          Web          `toml:"web"`
+	Registration Registration `toml:"registration"`
+	Limits       Limits       `toml:"limits"`
+	Mail         Mail         `toml:"mail"`
+}
+
+type Server struct {
+	Root    string `toml:"root"`
+	SiteURL string `toml:"site_url"`
+}
+
+type SSH struct {
+	Mode     string   `toml:"mode"` // embedded | system
+	Port     int      `toml:"port"`
+	HostKeys []string `toml:"host_keys"`
+}
+
+type HTTP struct {
+	Addr string `toml:"addr"`
+	TLS  string `toml:"tls"` // acme | files | off
+}
+
+type GitDaemon struct {
+	Enabled bool `toml:"enabled"`
+	Port    int  `toml:"port"`
+}
+
+type Web struct {
+	Mode         string `toml:"mode"` // view_only | accounts
+	PasswordAuth bool   `toml:"password_auth"`
+}
+
+type Registration struct {
+	Mode string `toml:"mode"` // closed | invite | open
+}
+
+type Limits struct {
+	MaxPackBytes    int64 `toml:"max_pack_bytes"`
+	MaxBlobBytes    int64 `toml:"max_blob_bytes"`
+	CloneTimeoutSec int   `toml:"clone_timeout"`
+	SSHAuthRate     int   `toml:"ssh_auth_rate"`
+}
+
+type Mail struct {
+	SMTPHost string `toml:"smtp_host"`
+	From     string `toml:"from"`
+}
+
+// Default returns the configuration used when a key is absent from the file.
+func Default() Config {
+	return Config{
+		Server: Server{Root: "/var/lib/forge"},
+		SSH:    SSH{Mode: "embedded", Port: 22},
+		HTTP:   HTTP{Addr: ":443", TLS: "acme"},
+		Web:    Web{Mode: "view_only"},
+		Registration: Registration{
+			Mode: "closed",
+		},
+		GitDaemon: GitDaemon{Port: 9418},
+		Limits: Limits{
+			MaxPackBytes:    2 << 30, // 2 GiB
+			MaxBlobBytes:    100 << 20,
+			CloneTimeoutSec: 3600,
+			SSHAuthRate:     10,
+		},
+	}
+}
+
+// Load reads path, applies defaults, and validates. It does not probe the
+// host (see CheckHost) so it is safe in tests and on non-target machines.
+func Load(path string) (Config, error) {
+	cfg := Default()
+	md, err := toml.DecodeFile(path, &cfg)
+	if err != nil {
+		return cfg, err
+	}
+	if u := md.Undecoded(); len(u) > 0 {
+		return cfg, fmt.Errorf("unknown config key %q", u[0].String())
+	}
+	return cfg, cfg.Validate()
+}
+
+func oneOf(field, val string, allowed ...string) error {
+	for _, a := range allowed {
+		if val == a {
+			return nil
+		}
+	}
+	return fmt.Errorf("%s must be one of %v, got %q", field, allowed, val)
+}
+
+// Validate applies the static contradiction checks from the plan.
+func (c Config) Validate() error {
+	var errs []error
+
+	if c.Server.Root == "" {
+		errs = append(errs, errors.New("server.root is required"))
+	}
+	if c.Server.SiteURL == "" {
+		errs = append(errs, errors.New("server.site_url is required"))
+	}
+	if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
+		errs = append(errs, err)
+	}
+	if c.SSH.Port < 1 || c.SSH.Port > 65535 {
+		errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
+	}
+	if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil {
+		errs = append(errs, err)
+	}
+	if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
+		errs = append(errs, err)
+	}
+	if err := oneOf("registration.mode", c.Registration.Mode, "closed", "invite", "open"); err != nil {
+		errs = append(errs, err)
+	}
+
+	// Contradictions.
+	if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
+		errs = append(errs, fmt.Errorf(
+			"registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
+			c.Registration.Mode))
+	}
+	if c.SSH.Mode == "system" && c.Registration.Mode != "closed" {
+		errs = append(errs, fmt.Errorf(
+			"ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible"))
+	}
+	if c.Web.PasswordAuth && c.Web.Mode == "view_only" {
+		errs = append(errs, errors.New(
+			"web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists"))
+	}
+
+	return errors.Join(errs...)
+}
+
+// CheckHost performs environment probes that only make sense on the target
+// machine: port availability for the embedded listener and root existence.
+func (c Config) CheckHost() error {
+	var errs []error
+
+	if st, err := os.Stat(c.Server.Root); err != nil {
+		errs = append(errs, fmt.Errorf("server.root: %w", err))
+	} else if !st.IsDir() {
+		errs = append(errs, fmt.Errorf("server.root %q is not a directory", c.Server.Root))
+	}
+
+	if c.SSH.Mode == "embedded" {
+		addr := net.JoinHostPort("", strconv.Itoa(c.SSH.Port))
+		ln, err := net.Listen("tcp", addr)
+		if err != nil {
+			errs = append(errs, fmt.Errorf("ssh.port %d is not bindable (already in use by another daemon?): %w", c.SSH.Port, err))
+		} else {
+			ln.Close()
+		}
+	}
+
+	return errors.Join(errs...)
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..d827108
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,121 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func writeConfig(t *testing.T, body string) string {
+	t.Helper()
+	p := filepath.Join(t.TempDir(), "config.toml")
+	if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	return p
+}
+
+const minimal = `
+[server]
+root = "/var/lib/forge"
+site_url = "https://forge.example"
+`
+
+func TestLoadMinimal(t *testing.T) {
+	cfg, err := Load(writeConfig(t, minimal))
+	if err != nil {
+		t.Fatal(err)
+	}
+	// Defaults applied.
+	if cfg.SSH.Mode != "embedded" || cfg.SSH.Port != 22 {
+		t.Errorf("ssh defaults wrong: %+v", cfg.SSH)
+	}
+	if cfg.Web.Mode != "view_only" {
+		t.Errorf("web default wrong: %+v", cfg.Web)
+	}
+	if cfg.Registration.Mode != "closed" {
+		t.Errorf("registration default wrong: %+v", cfg.Registration)
+	}
+}
+
+func TestContradictions(t *testing.T) {
+	cases := []struct {
+		name    string
+		body    string
+		wantErr string
+	}{
+		{
+			"registration open without smtp",
+			minimal + "\n[registration]\nmode = \"open\"\n",
+			"requires [mail] smtp_host",
+		},
+		{
+			"system ssh with open registration",
+			minimal + "\n[ssh]\nmode = \"system\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"forge@example\"\n",
+			"requires registration.mode = \"closed\"",
+		},
+		{
+			"password auth in view_only",
+			minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n",
+			"password_auth",
+		},
+		{
+			"bad ssh mode",
+			minimal + "\n[ssh]\nmode = \"tcp\"\n",
+			"ssh.mode",
+		},
+		{
+			"unknown key",
+			"[server]\nroot = \"/var/lib/forge\"\nsite_url = \"https://forge.example\"\nbogus = 1\n",
+			"unknown config key",
+		},
+		{
+			"missing site_url",
+			"[server]\nroot = \"/var/lib/forge\"\n",
+			"site_url",
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := Load(writeConfig(t, tc.body))
+			if err == nil {
+				t.Fatalf("expected error containing %q, got nil", tc.wantErr)
+			}
+			if !strings.Contains(err.Error(), tc.wantErr) {
+				t.Fatalf("error %q does not contain %q", err, tc.wantErr)
+			}
+		})
+	}
+}
+
+func TestValidCombinations(t *testing.T) {
+	cases := []struct {
+		name string
+		body string
+	}{
+		{
+			"invite with smtp",
+			minimal + "\n[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"forge@example\"\n",
+		},
+		{
+			"system ssh closed registration",
+			minimal + "\n[ssh]\nmode = \"system\"\n",
+		},
+		{
+			"accounts web with password auth",
+			minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n",
+		},
+		{
+			"closed registration, no smtp at all",
+			minimal,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := Load(writeConfig(t, tc.body)); err != nil {
+				t.Fatal(err)
+			}
+		})
+	}
+}
diff --git a/internal/policy/names.go b/internal/policy/names.go
new file mode 100644
index 0000000..b450182
--- /dev/null
+++ b/internal/policy/names.go
@@ -0,0 +1,60 @@
+// Package policy holds access-control and naming rules.
+package policy
+
+import (
+	"fmt"
+	"regexp"
+)
+
+// reservedNames are forbidden as usernames and org names because they are, or
+// will be, top-level web routes (the UI serves /<owner>/<name>). Any change to
+// the httpd mux's top-level routes must be reflected here; the httpd package
+// asserts this in its tests.
+var reservedNames = map[string]bool{
+	"admin":    true,
+	"api":      true,
+	"archive":  true,
+	"explore":  true,
+	"login":    true,
+	"logout":   true,
+	"new":      true,
+	"raw":      true,
+	"register": true,
+	"settings": true,
+	"static":   true,
+}
+
+// namePat matches valid user, org, and repo names: lowercase alphanumerics,
+// dot, dash, underscore; must start with an alphanumeric. Dots are further
+// restricted by ValidateName to avoid "." / ".." and ".git" suffixes.
+var namePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
+
+// ValidateOwnerName checks a username or org name.
+func ValidateOwnerName(name string) error {
+	if err := ValidateName(name); err != nil {
+		return err
+	}
+	if reservedNames[name] {
+		return fmt.Errorf("name %q is reserved", name)
+	}
+	return nil
+}
+
+// ValidateName checks a repo name (reserved words are allowed for repos;
+// routes are namespaced under the owner).
+func ValidateName(name string) error {
+	if !namePat.MatchString(name) {
+		return fmt.Errorf("invalid name %q: lowercase letters, digits, '.', '-', '_' only; must start with a letter or digit; max 63 chars", name)
+	}
+	if name == "." || name == ".." {
+		return fmt.Errorf("invalid name %q", name)
+	}
+	if len(name) > 4 && name[len(name)-4:] == ".git" {
+		return fmt.Errorf("invalid name %q: must not end in .git", name)
+	}
+	return nil
+}
+
+// Reserved reports whether name is a reserved route word. Exported so the
+// httpd tests can assert route/reserved-list agreement.
+func Reserved(name string) bool { return reservedNames[name] }
diff --git a/internal/policy/names_test.go b/internal/policy/names_test.go
new file mode 100644
index 0000000..f2b00a5
--- /dev/null
+++ b/internal/policy/names_test.go
@@ -0,0 +1,42 @@
+package policy
+
+import "testing"
+
+func TestValidateOwnerName(t *testing.T) {
+	valid := []string{"alice", "krz", "a", "user-1", "a.b_c", "0day"}
+	for _, n := range valid {
+		if err := ValidateOwnerName(n); err != nil {
+			t.Errorf("ValidateOwnerName(%q) = %v, want nil", n, err)
+		}
+	}
+
+	invalid := []string{
+		"",
+		"Alice",     // uppercase
+		"-lead",     // bad first char
+		".hidden",   // bad first char
+		"a b",       // space
+		"repo.git",  // .git suffix
+		"..",        //
+		"login",     // reserved
+		"admin",     // reserved
+		"static",    // reserved
+		"api",       // reserved
+		"register",  // reserved
+	}
+	for _, n := range invalid {
+		if err := ValidateOwnerName(n); err == nil {
+			t.Errorf("ValidateOwnerName(%q) = nil, want error", n)
+		}
+	}
+}
+
+func TestRepoNameAllowsReservedWords(t *testing.T) {
+	// Repo routes are namespaced under the owner, so reserved words are fine.
+	if err := ValidateName("api"); err != nil {
+		t.Errorf("ValidateName(\"api\") = %v, want nil", err)
+	}
+	if err := ValidateName("repo.git"); err == nil {
+		t.Error("ValidateName(\"repo.git\") = nil, want error")
+	}
+}
diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
new file mode 100644
index 0000000..1e80247
--- /dev/null
+++ b/internal/protocol/protocol.go
@@ -0,0 +1,26 @@
+// Package protocol defines the wire contract shared by the CLI and server:
+// exit codes, the JSON response envelope, and (later) the SSH command
+// tokenizer.
+package protocol
+
+// Version is the control-plane protocol version. It is embedded in every JSON
+// response envelope; clients check it opportunistically and refuse on major
+// mismatch. Bump the major on breaking envelope or command changes.
+const Version = 1
+
+// Exit codes shared by the CLI and by control commands run over bare ssh.
+const (
+	ExitOK        = 0
+	ExitFailure   = 1 // general failure
+	ExitUsage     = 2 // usage error
+	ExitNotFound  = 3
+	ExitDenied    = 4
+	ExitProtocol  = 5 // server/protocol error
+)
+
+// Envelope wraps every JSON response from a control command.
+type Envelope struct {
+	ProtocolVersion int    `json:"protocol_version"`
+	Data            any    `json:"data,omitempty"`
+	Error           string `json:"error,omitempty"`
+}
diff --git a/internal/store/migrations/0001_init.down.sql b/internal/store/migrations/0001_init.down.sql
new file mode 100644
index 0000000..0632f4f
--- /dev/null
+++ b/internal/store/migrations/0001_init.down.sql
@@ -0,0 +1,22 @@
+DROP TABLE settings;
+DROP TABLE invites;
+DROP TABLE web_sessions;
+DROP TABLE audit_log;
+DROP TABLE events;
+DROP TABLE commit_signatures;
+DROP TABLE mr_reviews;
+DROP TABLE mr_comments;
+DROP TABLE merge_requests;
+DROP TABLE issue_assignees;
+DROP TABLE issue_labels;
+DROP TABLE labels;
+DROP TABLE issue_comments;
+DROP TABLE issues;
+DROP TABLE repo_access;
+DROP TABLE repos;
+DROP TABLE org_members;
+DROP TABLE orgs;
+DROP TABLE pgp_keys;
+DROP TABLE ssh_keys;
+DROP TABLE emails;
+DROP TABLE users;
diff --git a/internal/store/migrations/0001_init.up.sql b/internal/store/migrations/0001_init.up.sql
new file mode 100644
index 0000000..cec064d
--- /dev/null
+++ b/internal/store/migrations/0001_init.up.sql
@@ -0,0 +1,210 @@
+CREATE TABLE users (
+    id         INTEGER PRIMARY KEY,
+    username   TEXT NOT NULL UNIQUE,
+    is_admin   INTEGER NOT NULL DEFAULT 0,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+
+CREATE TABLE emails (
+    id          INTEGER PRIMARY KEY,
+    user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    address     TEXT NOT NULL UNIQUE,
+    verified_at TEXT,
+    verified_by TEXT CHECK (verified_by IN ('smtp','admin')),
+    is_primary  INTEGER NOT NULL DEFAULT 0,
+    CHECK ((verified_at IS NULL) = (verified_by IS NULL))
+);
+CREATE INDEX emails_user ON emails(user_id);
+
+CREATE TABLE ssh_keys (
+    id           INTEGER PRIMARY KEY,
+    user_id      INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    fingerprint  TEXT NOT NULL UNIQUE,
+    algo         TEXT NOT NULL,
+    blob         BLOB NOT NULL,
+    scope        TEXT NOT NULL DEFAULT 'full',
+    created_at   TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    last_used_at TEXT
+);
+CREATE INDEX ssh_keys_user ON ssh_keys(user_id);
+
+CREATE TABLE pgp_keys (
+    id          INTEGER PRIMARY KEY,
+    user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    fingerprint TEXT NOT NULL UNIQUE,
+    armored     TEXT NOT NULL,
+    uids_json   TEXT NOT NULL DEFAULT '[]',
+    expires_at  TEXT,
+    revoked_at  TEXT,
+    created_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+CREATE INDEX pgp_keys_user ON pgp_keys(user_id);
+
+CREATE TABLE orgs (
+    id         INTEGER PRIMARY KEY,
+    name       TEXT NOT NULL UNIQUE,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+
+CREATE TABLE org_members (
+    org_id  INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    role    TEXT NOT NULL CHECK (role IN ('member','admin')),
+    PRIMARY KEY (org_id, user_id)
+);
+
+CREATE TABLE repos (
+    id             INTEGER PRIMARY KEY,
+    owner_kind     TEXT NOT NULL CHECK (owner_kind IN ('user','org')),
+    owner_id       INTEGER NOT NULL,
+    name           TEXT NOT NULL,
+    visibility     TEXT NOT NULL CHECK (visibility IN ('public','private')),
+    default_branch TEXT NOT NULL DEFAULT 'main',
+    fork_of        INTEGER REFERENCES repos(id) ON DELETE SET NULL,
+    issue_counter  INTEGER NOT NULL DEFAULT 0,
+    mr_counter     INTEGER NOT NULL DEFAULT 0,
+    settings_json  TEXT NOT NULL DEFAULT '{}',
+    created_at     TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    UNIQUE (owner_kind, owner_id, name)
+);
+
+CREATE TABLE repo_access (
+    repo_id      INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+    subject_kind TEXT NOT NULL CHECK (subject_kind IN ('user','org')),
+    subject_id   INTEGER NOT NULL,
+    role         TEXT NOT NULL CHECK (role IN ('read','write','admin')),
+    PRIMARY KEY (repo_id, subject_kind, subject_id)
+);
+
+CREATE TABLE issues (
+    id         INTEGER PRIMARY KEY,
+    repo_id    INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+    number     INTEGER NOT NULL,
+    author_id  INTEGER NOT NULL REFERENCES users(id),
+    title      TEXT NOT NULL,
+    body       TEXT NOT NULL DEFAULT '',
+    state      TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    UNIQUE (repo_id, number)
+);
+
+CREATE TABLE issue_comments (
+    id         INTEGER PRIMARY KEY,
+    issue_id   INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
+    author_id  INTEGER NOT NULL REFERENCES users(id),
+    body       TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+CREATE INDEX issue_comments_issue ON issue_comments(issue_id);
+
+CREATE TABLE labels (
+    id      INTEGER PRIMARY KEY,
+    repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+    name    TEXT NOT NULL,
+    color   TEXT NOT NULL DEFAULT '',
+    UNIQUE (repo_id, name)
+);
+
+CREATE TABLE issue_labels (
+    issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
+    label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
+    PRIMARY KEY (issue_id, label_id)
+);
+
+CREATE TABLE issue_assignees (
+    issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
+    user_id  INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    PRIMARY KEY (issue_id, user_id)
+);
+
+CREATE TABLE merge_requests (
+    id             INTEGER PRIMARY KEY,
+    repo_id        INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+    number         INTEGER NOT NULL,
+    author_id      INTEGER NOT NULL REFERENCES users(id),
+    source_repo_id INTEGER REFERENCES repos(id) ON DELETE SET NULL,
+    source_ref     TEXT NOT NULL,
+    target_ref     TEXT NOT NULL,
+    title          TEXT NOT NULL,
+    body           TEXT NOT NULL DEFAULT '',
+    state          TEXT NOT NULL DEFAULT 'open'
+                   CHECK (state IN ('open','merged','closed','source_gone')),
+    head_sha       TEXT NOT NULL DEFAULT '',
+    created_at     TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    updated_at     TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    UNIQUE (repo_id, number)
+);
+
+CREATE TABLE mr_comments (
+    id         INTEGER PRIMARY KEY,
+    mr_id      INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
+    author_id  INTEGER NOT NULL REFERENCES users(id),
+    body       TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+CREATE INDEX mr_comments_mr ON mr_comments(mr_id);
+
+CREATE TABLE mr_reviews (
+    id          INTEGER PRIMARY KEY,
+    mr_id       INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
+    reviewer_id INTEGER NOT NULL REFERENCES users(id),
+    verdict     TEXT NOT NULL CHECK (verdict IN ('approve','request_changes','comment')),
+    head_sha    TEXT NOT NULL,
+    stale       INTEGER NOT NULL DEFAULT 0,
+    created_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+CREATE INDEX mr_reviews_mr ON mr_reviews(mr_id);
+
+CREATE TABLE commit_signatures (
+    repo_id         INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+    commit_sha      TEXT NOT NULL,
+    state           TEXT NOT NULL CHECK (state IN (
+                        'verified','signed_unknown_key','signed_email_mismatch',
+                        'signed_key_expired','signed_key_revoked',
+                        'bad_signature','unsigned')),
+    signer_user_id  INTEGER REFERENCES users(id) ON DELETE SET NULL,
+    key_fingerprint TEXT,
+    key_epoch       INTEGER NOT NULL,
+    checked_at      TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    PRIMARY KEY (repo_id, commit_sha)
+);
+CREATE INDEX commit_signatures_fpr ON commit_signatures(key_fingerprint);
+
+CREATE TABLE events (
+    id         INTEGER PRIMARY KEY,
+    repo_id    INTEGER REFERENCES repos(id) ON DELETE CASCADE,
+    actor_id   INTEGER REFERENCES users(id) ON DELETE SET NULL,
+    kind       TEXT NOT NULL,
+    data_json  TEXT NOT NULL DEFAULT '{}',
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+CREATE INDEX events_repo ON events(repo_id, id);
+
+CREATE TABLE audit_log (
+    id         INTEGER PRIMARY KEY,
+    actor_id   INTEGER REFERENCES users(id) ON DELETE SET NULL,
+    action     TEXT NOT NULL,
+    data_json  TEXT NOT NULL DEFAULT '{}',
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+
+CREATE TABLE web_sessions (
+    token_hash TEXT PRIMARY KEY,
+    user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    expires_at TEXT NOT NULL
+);
+
+CREATE TABLE invites (
+    code_hash  TEXT PRIMARY KEY,
+    email      TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    used_at    TEXT
+);
+
+CREATE TABLE settings (
+    key   TEXT PRIMARY KEY,
+    value TEXT NOT NULL
+);
+INSERT INTO settings (key, value) VALUES ('key_epoch', '1');
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..c691687
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,167 @@
+// Package store owns SQLite access and schema migrations.
+package store
+
+import (
+	"database/sql"
+	"embed"
+	"fmt"
+	"io/fs"
+	"sort"
+	"strconv"
+	"strings"
+
+	_ "modernc.org/sqlite"
+)
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+type Store struct {
+	DB *sql.DB
+}
+
+// Open opens (creating if needed) the database at path with WAL mode and
+// foreign keys enforced. Use ":memory:" in tests.
+func Open(path string) (*Store, error) {
+	dsn := path + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"
+	if path == ":memory:" {
+		dsn = ":memory:?_pragma=foreign_keys(ON)"
+	}
+	db, err := sql.Open("sqlite", dsn)
+	if err != nil {
+		return nil, err
+	}
+	if err := db.Ping(); err != nil {
+		db.Close()
+		return nil, err
+	}
+	return &Store{DB: db}, nil
+}
+
+func (s *Store) Close() error { return s.DB.Close() }
+
+type migration struct {
+	version int
+	name    string
+	up      string
+	down    string
+}
+
+func loadMigrations() ([]migration, error) {
+	entries, err := fs.ReadDir(migrationFS, "migrations")
+	if err != nil {
+		return nil, err
+	}
+	byVersion := map[int]*migration{}
+	for _, e := range entries {
+		name := e.Name()
+		// <version>_<name>.<up|down>.sql
+		base, ok := strings.CutSuffix(name, ".sql")
+		if !ok {
+			return nil, fmt.Errorf("migration %q: not .sql", name)
+		}
+		var dir string
+		if b, ok := strings.CutSuffix(base, ".up"); ok {
+			base, dir = b, "up"
+		} else if b, ok := strings.CutSuffix(base, ".down"); ok {
+			base, dir = b, "down"
+		} else {
+			return nil, fmt.Errorf("migration %q: missing .up/.down", name)
+		}
+		verStr, rest, ok := strings.Cut(base, "_")
+		if !ok {
+			return nil, fmt.Errorf("migration %q: missing version prefix", name)
+		}
+		ver, err := strconv.Atoi(verStr)
+		if err != nil {
+			return nil, fmt.Errorf("migration %q: bad version: %w", name, err)
+		}
+		m := byVersion[ver]
+		if m == nil {
+			m = &migration{version: ver, name: rest}
+			byVersion[ver] = m
+		}
+		sqlBytes, err := migrationFS.ReadFile("migrations/" + name)
+		if err != nil {
+			return nil, err
+		}
+		if dir == "up" {
+			m.up = string(sqlBytes)
+		} else {
+			m.down = string(sqlBytes)
+		}
+	}
+	var ms []migration
+	for _, m := range byVersion {
+		if m.up == "" || m.down == "" {
+			return nil, fmt.Errorf("migration %d %q: missing up or down file", m.version, m.name)
+		}
+		ms = append(ms, *m)
+	}
+	sort.Slice(ms, func(i, j int) bool { return ms[i].version < ms[j].version })
+	for i, m := range ms {
+		if m.version != i+1 {
+			return nil, fmt.Errorf("migration versions not contiguous at %d", m.version)
+		}
+	}
+	return ms, nil
+}
+
+// Version returns the current schema version (0 = empty database).
+func (s *Store) Version() (int, error) {
+	var v int
+	err := s.DB.QueryRow("PRAGMA user_version").Scan(&v)
+	return v, err
+}
+
+// MigrateUp applies all pending migrations.
+func (s *Store) MigrateUp() error { return s.migrateTo(-1) }
+
+// MigrateTo migrates up or down to the given version. 0 empties the schema.
+func (s *Store) MigrateTo(target int) error { return s.migrateTo(target) }
+
+func (s *Store) migrateTo(target int) error {
+	ms, err := loadMigrations()
+	if err != nil {
+		return err
+	}
+	if target < 0 {
+		target = len(ms)
+	}
+	if target > len(ms) {
+		return fmt.Errorf("no such schema version %d (max %d)", target, len(ms))
+	}
+	cur, err := s.Version()
+	if err != nil {
+		return err
+	}
+	step := func(sqlText string, newVersion int) error {
+		tx, err := s.DB.Begin()
+		if err != nil {
+			return err
+		}
+		defer tx.Rollback()
+		if _, err := tx.Exec(sqlText); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
+			return err
+		}
+		return tx.Commit()
+	}
+	for cur < target {
+		m := ms[cur]
+		if err := step(m.up, m.version); err != nil {
+			return fmt.Errorf("migration %d up: %w", m.version, err)
+		}
+		cur = m.version
+	}
+	for cur > target {
+		m := ms[cur-1]
+		if err := step(m.down, m.version-1); err != nil {
+			return fmt.Errorf("migration %d down: %w", m.version, err)
+		}
+		cur = m.version - 1
+	}
+	return nil
+}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 0000000..7a2e74c
--- /dev/null
+++ b/internal/store/store_test.go
@@ -0,0 +1,98 @@
+package store
+
+import (
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func open(t *testing.T) *Store {
+	t.Helper()
+	s, err := Open(filepath.Join(t.TempDir(), "forge.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { s.Close() })
+	return s
+}
+
+func TestMigrateUpDown(t *testing.T) {
+	s := open(t)
+
+	if err := s.MigrateUp(); err != nil {
+		t.Fatal(err)
+	}
+	v, err := s.Version()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if v < 1 {
+		t.Fatalf("version %d after MigrateUp", v)
+	}
+
+	// Seeded settings row exists.
+	var epoch string
+	if err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&epoch); err != nil {
+		t.Fatal(err)
+	}
+	if epoch != "1" {
+		t.Fatalf("key_epoch = %q, want 1", epoch)
+	}
+
+	// Down to empty, then back up.
+	if err := s.MigrateTo(0); err != nil {
+		t.Fatal(err)
+	}
+	var n int
+	if err := s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&n); err != nil {
+		t.Fatal(err)
+	}
+	if n != 0 {
+		t.Fatalf("%d tables remain after down-migration to 0", n)
+	}
+	if err := s.MigrateUp(); err != nil {
+		t.Fatal(err)
+	}
+	// Idempotent at latest.
+	if err := s.MigrateUp(); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestKeyFingerprintGloballyUnique(t *testing.T) {
+	s := open(t)
+	if err := s.MigrateUp(); err != nil {
+		t.Fatal(err)
+	}
+	mustExec := func(q string, args ...any) {
+		t.Helper()
+		if _, err := s.DB.Exec(q, args...); err != nil {
+			t.Fatal(err)
+		}
+	}
+	mustExec("INSERT INTO users (username) VALUES ('alice'), ('bob')")
+	mustExec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (1, 'SHA256:aaa', 'ed25519', x'00')")
+
+	// Same fingerprint on a different account must be rejected.
+	_, err := s.DB.Exec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (2, 'SHA256:aaa', 'ed25519', x'00')")
+	if err == nil || !strings.Contains(err.Error(), "UNIQUE") {
+		t.Fatalf("duplicate ssh fingerprint across accounts: err = %v, want UNIQUE violation", err)
+	}
+
+	mustExec("INSERT INTO pgp_keys (user_id, fingerprint, armored) VALUES (1, 'FPR1', '-----')")
+	_, err = s.DB.Exec("INSERT INTO pgp_keys (user_id, fingerprint, armored) VALUES (2, 'FPR1', '-----')")
+	if err == nil || !strings.Contains(err.Error(), "UNIQUE") {
+		t.Fatalf("duplicate pgp fingerprint across accounts: err = %v, want UNIQUE violation", err)
+	}
+}
+
+func TestForeignKeysEnforced(t *testing.T) {
+	s := open(t)
+	if err := s.MigrateUp(); err != nil {
+		t.Fatal(err)
+	}
+	_, err := s.DB.Exec("INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (999, 'SHA256:zzz', 'ed25519', x'00')")
+	if err == nil {
+		t.Fatal("insert with dangling user_id succeeded; foreign keys are off")
+	}
+}