gitmote

gitmote  ·  history  ·  branches

feat: auto-generate and persist the cookie key and CI worker secret

776c3b9626b415abfedb593a9a623acbb5a511e0
Atanas Minev <atmin@protonmail.com> · 2026-07-05 09:36

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index c77a74c..1128993 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -3,6 +3,8 @@ package main
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"errors"
"flag"
"fmt"
@@ -239,9 +241,9 @@ func alwaysServable(p string) bool {
// configured (GITMOTE_S3_BUCKET). Without it, the server runs health/version
// only — a dev convenience — and the returned close func is a no-op.
//
-// The management UI (second return) is built when GITMOTE_COOKIE_KEY is set (the
-// HMAC key that signs session cookies); it shares this metadata handle, so it
-// runs alongside the git server.
+// The management UI (second return) is always built: the HMAC key that signs
+// session cookies is GITMOTE_COOKIE_KEY, or an auto-generated key persisted in
+// meta. It shares this metadata handle, so it runs alongside the git server.
//
// The db, cache, and socket derive from GITMOTE_DATA (see dataDir); metadata
// replicates to a bucket-derived replica (see replicaTarget). The push path
@@ -302,25 +304,40 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
// and a WORKER_SECRET (authenticates the report API). With neither configured,
// runs still record but nothing executes.
jobDefID := os.Getenv("SCW_CI_JOB_DEFINITION_ID")
- workerSecret := os.Getenv("WORKER_SECRET")
gitmoteURL := os.Getenv("GITMOTE_URL")
- var trigger ci.Trigger = ci.NoopTrigger{}
- switch {
- case jobDefID != "":
- if workerSecret == "" || gitmoteURL == "" {
+ // Cloud needs a reachable URL for the runner to clone + report back; fail loud
+ // if the job definition is set without one — before provisioning any secret.
+ if jobDefID != "" && gitmoteURL == "" {
+ _ = writer.Close()
+ _ = md.Close()
+ return nil, nil, nil, nil, noop, fmt.Errorf("SCW_CI_JOB_DEFINITION_ID is set but GITMOTE_URL is missing")
+ }
+ // WORKER_SECRET authenticates the runner's report-back. It's meaningless
+ // without a CI substrate, so resolve it lazily only then: env → else
+ // meta get-or-create (generated once, restored on every later boot), so a
+ // plain no-CI run provisions nothing. It reaches the cloud runner injected at
+ // trigger time, never baked into the job definition (dispatcher jobEnv).
+ var workerSecret string
+ if jobDefID != "" || gitmoteURL != "" {
+ workerSecret, err = resolveWorkerSecret(ctx, md)
+ if err != nil {
_ = writer.Close()
_ = md.Close()
- return nil, nil, nil, nil, noop, fmt.Errorf("SCW_CI_JOB_DEFINITION_ID is set but WORKER_SECRET or GITMOTE_URL is missing")
+ return nil, nil, nil, nil, noop, fmt.Errorf("worker secret: %w", err)
}
+ }
+ var trigger ci.Trigger = ci.NoopTrigger{}
+ switch {
+ case jobDefID != "":
// SCW_REGION falls back to the AWS_REGION already set for the object store.
trigger = scaleway.NewJobsClient(os.Getenv("SCW_SECRET_KEY"), envOr("SCW_REGION", os.Getenv("AWS_REGION")), jobDefID)
logger.Info("CI trigger: Scaleway Serverless Jobs", "job_definition_id", jobDefID)
- case workerSecret != "" && gitmoteURL != "":
+ case gitmoteURL != "":
trigger = ci.NewLocalTrigger(runnerBinaryPath(), logger)
logger.Info("CI trigger: local runner", "runner", runnerBinaryPath(), "url", gitmoteURL)
default:
logger.Warn("CI trigger disabled; runs record but do not execute " +
- "(set GITMOTE_URL + WORKER_SECRET for local, or SCW_CI_JOB_DEFINITION_ID for cloud)")
+ "(set GITMOTE_URL for local, or SCW_CI_JOB_DEFINITION_ID + GITMOTE_URL for cloud)")
}
// A successful, branch-advancing push discovers its workflows, records a run,
@@ -378,7 +395,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return nil, nil, nil, nil, noop, err
}
- ui, err := buildUI(md, materializer, objs, guard, secretsSvc, logger)
+ ui, err := buildUI(ctx, md, materializer, objs, guard, secretsSvc, logger)
if err != nil {
_ = cleanup()
return nil, nil, nil, nil, noop, err
@@ -386,16 +403,56 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return handler, ui, reportAPI, md.IsLeader, cleanup, nil
}
-// buildUI constructs the management UI when GITMOTE_COOKIE_KEY is set; otherwise
-// it returns nil (UI disabled) so a misconfigured key never yields an insecurely
-// signed session.
-func buildUI(md *meta.Metadata, mz *repo.Materializer, objs store.Store, guard *auth.Guard, secretsSvc *secrets.Service, logger *slog.Logger) (*webui.Handler, error) {
- key := os.Getenv("GITMOTE_COOKIE_KEY")
- if key == "" {
- logger.Warn("GITMOTE_COOKIE_KEY unset; management UI disabled")
- return nil, nil
+// buildUI constructs the management UI (always on). Its session cookie key is
+// GITMOTE_COOKIE_KEY when set, else an auto-generated key persisted in meta and
+// restored across restart / scale-to-zero — so a session stays valid through an
+// idle→wake and the UI needs no secret config.
+func buildUI(ctx context.Context, md *meta.Metadata, mz *repo.Materializer, objs store.Store, guard *auth.Guard, secretsSvc *secrets.Service, logger *slog.Logger) (*webui.Handler, error) {
+ cookieKey, err := resolveCookieKey(ctx, md)
+ if err != nil {
+ return nil, fmt.Errorf("cookie key: %w", err)
+ }
+ return webui.New(md, mz, objs, guard, secretsSvc, cookieKey, logger)
+}
+
+// resolveCookieKey returns the session cookie key: GITMOTE_COOKIE_KEY when set,
+// else the persisted "cookie_key" server secret, auto-generated on first boot.
+func resolveCookieKey(ctx context.Context, md *meta.Metadata) ([]byte, error) {
+ if v := os.Getenv("GITMOTE_COOKIE_KEY"); v != "" {
+ return []byte(v), nil
+ }
+ return md.GetOrCreateSecret(ctx, "cookie_key", genSecret)
+}
+
+// resolveWorkerSecret returns the CI report-API secret: WORKER_SECRET when set,
+// else the persisted "worker_secret" server secret, auto-generated on first boot.
+func resolveWorkerSecret(ctx context.Context, md *meta.Metadata) (string, error) {
+ if v := os.Getenv("WORKER_SECRET"); v != "" {
+ return v, nil
+ }
+ b, err := md.GetOrCreateSecret(ctx, "worker_secret", genSecret)
+ if err != nil {
+ return "", err
}
- return webui.New(md, mz, objs, guard, secretsSvc, []byte(key), logger)
+ return string(b), nil
+}
+
+// genSecretBytes is the entropy of an auto-generated server secret before
+// hex-encoding: 32 bytes → a 64-char hex string, ample for an HMAC cookie key
+// and the worker-secret bearer compare.
+const genSecretBytes = 32
+
+// genSecret generates a fresh server secret as a hex string's bytes, so the
+// persisted value is printable and usable directly as an HMAC key or a bearer
+// token. It's the get-or-create generator for both auto-provisioned secrets.
+func genSecret() ([]byte, error) {
+ b := make([]byte, genSecretBytes)
+ if _, err := rand.Read(b); err != nil {
+ return nil, err
+ }
+ enc := make([]byte, hex.EncodedLen(len(b)))
+ hex.Encode(enc, b)
+ return enc, nil
}
// hookBinaryPath resolves the pre-receive hook executable: GITMOTE_HOOK if set,
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index a3fdf6f..e39e13e 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -3,14 +3,21 @@ package main
import (
"bytes"
"context"
+ "encoding/hex"
"log/slog"
"net/http"
"net/http/httptest"
+ "os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
"time"
+ "github.com/atmin/gitmote/internal/ci"
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/store"
"github.com/atmin/s3lite"
)
@@ -259,6 +266,211 @@ func TestDataPathOverride(t *testing.T) {
}
}
+// openMeta opens a fresh, local-only metadata DB at a temp path.
+func openMeta(t *testing.T) *meta.Metadata {
+ t.Helper()
+ md, err := meta.Open(context.Background(), meta.Config{
+ LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
+ })
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = md.Close() })
+ return md
+}
+
+func TestResolveWorkerSecretGeneratesPersistsAndOverrides(t *testing.T) {
+ ctx := context.Background()
+ t.Setenv("WORKER_SECRET", "")
+ md := openMeta(t)
+
+ // No env: generated, and a hex string of the expected width.
+ s1, err := resolveWorkerSecret(ctx, md)
+ if err != nil {
+ t.Fatalf("resolveWorkerSecret: %v", err)
+ }
+ if len(s1) != hex.EncodedLen(genSecretBytes) {
+ t.Errorf("generated secret len = %d, want %d hex chars", len(s1), hex.EncodedLen(genSecretBytes))
+ }
+ // Stable across calls (persisted, not regenerated).
+ if s2, _ := resolveWorkerSecret(ctx, md); s2 != s1 {
+ t.Errorf("second resolve = %q, want the persisted %q", s2, s1)
+ }
+
+ // An explicit env wins over the persisted value.
+ t.Setenv("WORKER_SECRET", "explicit-secret")
+ if s3, _ := resolveWorkerSecret(ctx, md); s3 != "explicit-secret" {
+ t.Errorf("resolve with env = %q, want the env value", s3)
+ }
+}
+
+func TestResolveCookieKeyGeneratesPersistsAndOverrides(t *testing.T) {
+ ctx := context.Background()
+ t.Setenv("GITMOTE_COOKIE_KEY", "")
+ md := openMeta(t)
+
+ k1, err := resolveCookieKey(ctx, md)
+ if err != nil {
+ t.Fatalf("resolveCookieKey: %v", err)
+ }
+ if len(k1) == 0 {
+ t.Fatal("generated cookie key is empty")
+ }
+ // A restored session key must be stable, so a signed cookie survives a restart.
+ if k2, _ := resolveCookieKey(ctx, md); string(k2) != string(k1) {
+ t.Errorf("second resolve = %q, want the persisted %q", k2, k1)
+ }
+
+ t.Setenv("GITMOTE_COOKIE_KEY", "explicit-key")
+ if k3, _ := resolveCookieKey(ctx, md); string(k3) != "explicit-key" {
+ t.Errorf("resolve with env = %q, want the env value", k3)
+ }
+}
+
+// captureTrigger records the env of each trigger call.
+type captureTrigger struct{ calls []map[string]string }
+
+func (c *captureTrigger) Trigger(_ context.Context, env map[string]string) error {
+ c.calls = append(c.calls, env)
+ return nil
+}
+
+func TestResolvedWorkerSecretReachesDispatcherAndReportAPI(t *testing.T) {
+ ctx := context.Background()
+ t.Setenv("WORKER_SECRET", "")
+ md := openMeta(t)
+
+ secret, err := resolveWorkerSecret(ctx, md)
+ if err != nil {
+ t.Fatalf("resolveWorkerSecret: %v", err)
+ }
+
+ // The dispatcher injects the resolved secret into the runner env at trigger —
+ // the cloud runner reads WORKER_SECRET from there, never a baked-in job def.
+ s := store.NewMem()
+ mz := repo.New(md, s, t.TempDir())
+ r, head := seedWorkflowRepo(t, md, s)
+ tr := &captureTrigger{}
+ ci.NewDispatcher(ci.Config{
+ Runs: md, Materializer: mz, Trigger: tr,
+ BaseURL: "https://gitmote.test", WorkerSecret: secret,
+ }).Dispatch(ctx, ci.Event{
+ RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main",
+ OldSHA: meta.ZeroSHA, NewSHA: head,
+ })
+ if len(tr.calls) != 1 {
+ t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
+ }
+ if got := tr.calls[0]["WORKER_SECRET"]; got != secret {
+ t.Errorf("injected WORKER_SECRET = %q, want the resolved %q", got, secret)
+ }
+
+ // The report API validates that same secret — closing the loop the runner
+ // rides: injected at trigger, checked on report-back.
+ api := ci.NewReportAPI(md, s, nil, secret, nil)
+ mux := http.NewServeMux()
+ api.Register(mux)
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ if code := claimStatus(t, srv.URL, "wrong-secret"); code != http.StatusUnauthorized {
+ t.Errorf("claim with wrong secret = %d, want 401", code)
+ }
+ // The resolved secret authenticates: a nonexistent job id is a 404, not a 401.
+ if code := claimStatus(t, srv.URL, secret); code == http.StatusUnauthorized {
+ t.Error("claim with resolved secret = 401, want auth to pass")
+ }
+}
+
+// claimStatus issues a job claim to the report API with the given worker secret
+// and returns the HTTP status.
+func claimStatus(t *testing.T, base, secret string) int {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, base+"/internal/ci/jobs/1", nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Header.Set("X-Worker-Secret", secret)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("claim: %v", err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+}
+
+// seedWorkflowRepo builds a real repo with one workflow, loads its objects into
+// the store the materializer reads, and records the repo + branch ref in meta.
+func seedWorkflowRepo(t *testing.T, md *meta.Metadata, s store.Store) (*meta.Repo, string) {
+ t.Helper()
+ ctx := context.Background()
+ const name = "atmin/app"
+
+ src := t.TempDir()
+ git := func(args ...string) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = src
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
+ "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ }
+ git("init", "-b", "main", ".")
+ wf := filepath.Join(src, ".github", "workflows")
+ if err := os.MkdirAll(wf, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(wf, "ci.yml"),
+ []byte("name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git("add", "-A")
+ git("commit", "-m", "seed")
+ head := gitRevParse(t, src)
+
+ objRoot := filepath.Join(src, ".git", "objects")
+ if err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ rel, err := filepath.Rel(objRoot, p)
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ return s.Put(ctx, name+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
+ }); err != nil {
+ t.Fatalf("seed objects: %v", err)
+ }
+
+ r, err := md.CreateRepo(ctx, name, "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ if err := md.CASRef(ctx, r.ID, "refs/heads/main", meta.ZeroSHA, head); err != nil {
+ t.Fatalf("CASRef: %v", err)
+ }
+ return r, head
+}
+
+func gitRevParse(t *testing.T, dir string) string {
+ t.Helper()
+ cmd := exec.Command("git", "rev-parse", "HEAD")
+ cmd.Dir = dir
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git rev-parse: %v\n%s", err, out)
+ }
+ return strings.TrimSpace(string(out))
+}
+
func TestRunFailsOnBadAddr(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
diff --git a/docs/architecture/safety.md b/docs/architecture/safety.md
index 19379c6..822ebae 100644
--- a/docs/architecture/safety.md
+++ b/docs/architecture/safety.md
@@ -53,8 +53,8 @@
5. **CI secrets are encrypted at rest, and the threat model is narrow — say so.**
Per-repo CI secrets are sealed with AES-256-GCM under a **server-held** master
- key (`GITMOTE_CI_SECRET_KEY_V<n>`, a container env var alongside
- `GITMOTE_COOKIE_KEY` and the AWS keys); a per-repo subkey is derived with
+ key (`GITMOTE_CI_SECRET_KEY_V<n>`, a container env var alongside the AWS keys —
+ env-only, never persisted; see §6); a per-repo subkey is derived with
HKDF-SHA256, and the envelope's AAD binds `(repoID, name, version)` so a stolen
ciphertext can't be replayed under a different repo/name/version. Only the
sealed `{v, iv, ct}` lives in s3lite — never the plaintext or the key. So the
@@ -66,3 +66,16 @@
design): the server is the trust boundary. Rotation is a new key version + bump
(old envelopes still decrypt under their own version); values are write-only in
the UI (only names are ever shown) and are never logged.
+
+6. **Two server secrets are auto-provisioned into the replica — the CI master key
+ is not.** The session cookie key (`GITMOTE_COOKIE_KEY`) and the CI worker
+ secret (`WORKER_SECRET`) are generated on first boot and persisted in
+ `server_secrets` when no env overrides them, so a scale-to-zero container keeps
+ sessions valid and in-flight CI authenticated across an idle→wake (a per-boot
+ key would log everyone out and orphan runs). This is acceptable precisely
+ because the replica already holds users, tokens, and ACLs; sessions are
+ short-lived; and the worker secret only authorizes CI report submission, never
+ decryption. The CI **master key** stays env-only by contrast (§5): it decrypts
+ every repo's secrets, so a replica leak must never expose it. An explicit env
+ still wins for both, and generation is get-or-create on the leader (a follower's
+ DB is read-only), so restarts and rolling deploys reuse the one restored value.
diff --git a/internal/meta/schema.sql b/internal/meta/schema.sql
index bf83f61..37ee74d 100644
--- a/internal/meta/schema.sql
+++ b/internal/meta/schema.sql
@@ -92,3 +92,14 @@ CREATE TABLE IF NOT EXISTS ci_secrets (
created_at TEXT NOT NULL,
PRIMARY KEY (repo_id, name)
);
+
+-- Server secrets auto-provisioned on first boot when no env overrides them: the
+-- session cookie key and the CI worker secret. Persisting them (vs. a per-boot
+-- key) keeps sessions valid and in-flight CI authenticated across a scale-to-zero
+-- idle→wake. Deliberately just these two named keys, not a general KV store. The
+-- CI *master* key is NOT here — it stays env-only (safety.md §5). See
+-- Metadata.GetOrCreateSecret and cmd/gitmote.
+CREATE TABLE IF NOT EXISTS server_secrets (
+ name TEXT PRIMARY KEY, -- "cookie_key", "worker_secret"
+ value BLOB NOT NULL
+);
diff --git a/internal/meta/secrets.go b/internal/meta/secrets.go
index a2ff9fa..87dedf0 100644
--- a/internal/meta/secrets.go
+++ b/internal/meta/secrets.go
@@ -74,3 +74,38 @@ func (m *Metadata) DeleteSecret(ctx context.Context, repoID int64, name string)
`DELETE FROM ci_secrets WHERE repo_id = ? AND name = ?`, repoID, name)
return err
}
+
+// GetOrCreateSecret returns the persisted server secret named name, generating
+// and persisting it with gen the first time it's requested. It backs the
+// auto-provisioned session cookie key and CI worker secret (see cmd/gitmote): an
+// absent value is created once — on the very first, uncontended cold start, which
+// is necessarily the leader (a follower opens read-only, so its insert would fail
+// loud, and a restart then reads the leader's value restored from the replica).
+// Narrowly scoped to these named server secrets, not a general key–value store.
+func (m *Metadata) GetOrCreateSecret(ctx context.Context, name string, gen func() ([]byte, error)) ([]byte, error) {
+ var val []byte
+ err := m.db.QueryRowContext(ctx,
+ `SELECT value FROM server_secrets WHERE name = ?`, name).Scan(&val)
+ if err == nil {
+ return val, nil
+ }
+ if !isNoRows(err) {
+ return nil, err
+ }
+ val, err = gen()
+ if err != nil {
+ return nil, err
+ }
+ // ON CONFLICT DO NOTHING keeps a concurrent creator's value; the re-read below
+ // then returns whichever insert won, so every caller agrees on one value.
+ if _, err := m.db.ExecContext(ctx,
+ `INSERT INTO server_secrets (name, value) VALUES (?, ?)
+ ON CONFLICT(name) DO NOTHING`, name, val); err != nil {
+ return nil, err
+ }
+ if err := m.db.QueryRowContext(ctx,
+ `SELECT value FROM server_secrets WHERE name = ?`, name).Scan(&val); err != nil {
+ return nil, err
+ }
+ return val, nil
+}
diff --git a/internal/meta/secrets_test.go b/internal/meta/secrets_test.go
new file mode 100644
index 0000000..3f7883f
--- /dev/null
+++ b/internal/meta/secrets_test.go
@@ -0,0 +1,106 @@
+package meta
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing"
+)
+
+func TestGetOrCreateSecretCreatesOnceAndReuses(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+
+ calls := 0
+ gen := func() ([]byte, error) {
+ calls++
+ return []byte("generated-value"), nil
+ }
+
+ // First call generates and persists.
+ v1, err := m.GetOrCreateSecret(ctx, "cookie_key", gen)
+ if err != nil {
+ t.Fatalf("GetOrCreateSecret (first): %v", err)
+ }
+ if string(v1) != "generated-value" {
+ t.Errorf("value = %q, want the generated value", v1)
+ }
+
+ // Second call reuses the stored value — gen must not run again.
+ v2, err := m.GetOrCreateSecret(ctx, "cookie_key", func() ([]byte, error) {
+ t.Fatal("gen called on an existing secret")
+ return nil, nil
+ })
+ if err != nil {
+ t.Fatalf("GetOrCreateSecret (second): %v", err)
+ }
+ if string(v2) != "generated-value" {
+ t.Errorf("reused value = %q, want the stored value", v2)
+ }
+ if calls != 1 {
+ t.Errorf("gen calls = %d, want 1 (generate once)", calls)
+ }
+
+ // A different name gets its own value.
+ other, err := m.GetOrCreateSecret(ctx, "worker_secret", func() ([]byte, error) {
+ return []byte("other-value"), nil
+ })
+ if err != nil {
+ t.Fatalf("GetOrCreateSecret (other name): %v", err)
+ }
+ if string(other) != "other-value" {
+ t.Errorf("other value = %q, want its own generated value", other)
+ }
+}
+
+func TestGetOrCreateSecretPersistsAcrossReopen(t *testing.T) {
+ ctx := context.Background()
+ path := filepath.Join(t.TempDir(), "meta.sqlite3")
+
+ m := openAt(t, path)
+ v1, err := m.GetOrCreateSecret(ctx, "cookie_key", func() ([]byte, error) {
+ return []byte("stable-key"), nil
+ })
+ if err != nil {
+ t.Fatalf("GetOrCreateSecret: %v", err)
+ }
+ if err := m.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ // Re-open the same file (a restart / scale-to-zero restore): the value is the
+ // one restored, so gen must not run.
+ m2 := openAt(t, path)
+ v2, err := m2.GetOrCreateSecret(ctx, "cookie_key", func() ([]byte, error) {
+ t.Fatal("gen called after reopen; the persisted value should be reused")
+ return nil, nil
+ })
+ if err != nil {
+ t.Fatalf("GetOrCreateSecret after reopen: %v", err)
+ }
+ if string(v2) != string(v1) {
+ t.Errorf("value after reopen = %q, want the persisted %q", v2, v1)
+ }
+}
+
+func TestGetOrCreateSecretGenErrorPropagates(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+
+ want := errors.New("no entropy")
+ if _, err := m.GetOrCreateSecret(ctx, "cookie_key", func() ([]byte, error) {
+ return nil, want
+ }); !errors.Is(err, want) {
+ t.Fatalf("err = %v, want %v", err, want)
+ }
+ // Nothing was persisted, so a later successful gen still creates the value.
+ v, err := m.GetOrCreateSecret(ctx, "cookie_key", func() ([]byte, error) {
+ return []byte("recovered"), nil
+ })
+ if err != nil {
+ t.Fatalf("GetOrCreateSecret after gen error: %v", err)
+ }
+ if string(v) != "recovered" {
+ t.Errorf("value = %q, want the value from the successful gen", v)
+ }
+}
diff --git a/tasks/README.md b/tasks/README.md
index a4687a6..b241cef 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -15,13 +15,9 @@ Two independent chains plus a docs cap — order within a chain matters, the cha
run in parallel:
**Chain A — shrink the surface**
-- [minimal-env-secrets.md](minimal-env-secrets.md) — auto-generate + persist
- `GITMOTE_COOKIE_KEY` and (lazily) `WORKER_SECRET` in meta; CI master key stays
- env-only.
- [onboarding-bootstrap.md](onboarding-bootstrap.md) — first-run **auto-bootstrap**
prints the admin token once to the logs with clear instructions (no setup page,
- no race); `bootstrap --reissue` recovers a lost token. *(needs -secrets for the
- cookie key.)*
+ no race); `bootstrap --reissue` recovers a lost token.
**Chain B — the image**
- [public-registry.md](public-registry.md) — publish `ghcr.io/atmin/gitmote` +
diff --git a/tasks/minimal-env-secrets.md b/tasks/minimal-env-secrets.md
deleted file mode 100644
index 867f221..0000000
--- a/tasks/minimal-env-secrets.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Auto-generate and persist COOKIE_KEY and WORKER_SECRET
-
-Part of [easy to operate](README.md). Make the UI and local CI work with **zero
-secret config**, stable across scale-to-zero.
-
-## Spec
-
-`GITMOTE_COOKIE_KEY` (signs session cookies) and `WORKER_SECRET` (authenticates
-the CI report API) should not be required. Generate each on demand, **persist in
-meta** (survives restart / scale-to-zero via S3 restore), reuse thereafter. An
-env, when set, wins. The CI-secrets master key (`GITMOTE_CI_SECRET_KEY_V<n>`)
-stays **env-only** — it must not live in the replica (safety.md §5); unchanged.
-
-Persist, not ephemeral: a per-boot key would log everyone out on each idle→wake
-and orphan in-flight CI — wrong for a scale-to-zero container.
-
-Scope tightly: this is get-or-create for **exactly these two named keys**, not a
-general key–value abstraction (CLAUDE.md §3 — no speculative generality).
-`WORKER_SECRET` is generated **lazily** — only when a CI trigger is configured
-(it's meaningless with CI off), so a plain no-CI run generates only the cookie key.
-
-## Current
-
-`GITMOTE_COOKIE_KEY` absent → [`buildUI`](../cmd/gitmote/main.go) returns nil, the
-UI is disabled. `WORKER_SECRET` absent → the CI trigger is `NoopTrigger` (or a
-fail-fast when `SCW_CI_JOB_DEFINITION_ID` is set). Both are hand-supplied today.
-The schema is `CREATE TABLE IF NOT EXISTS` + an additive `migrate()` (meta.go), so
-adding a `server_secrets` table needs no migration infra.
-
-## Change
-
-- Add a `server_secrets(name TEXT PRIMARY KEY, value BLOB)` table and a narrow
- `GetOrCreateSecret(ctx, name, gen func() ([]byte, error))` in
- [`internal/meta`](../internal/meta), leader-written like any row.
-- At startup resolve each: env → else meta get-or-create. The **leader** generates
- + persists on first boot (uncontended cold start is leader); later boots / a
- follower read the restored value.
-- Wire the cookie key into `buildUI` (UI always on) and the worker secret into the
- dispatcher + report API — generating the worker secret only when a trigger is
- configured.
-- Note in `safety.md`: these two now live in the replica (distinct from the
- env-only CI master key), and why it's acceptable (the replica already holds
- users/tokens/ACLs; sessions are short-lived; the worker secret only authorizes
- CI report submission).
-
-## Verify
-
-- UI works with **no** `GITMOTE_COOKIE_KEY`; a session stays valid across a
- process restart (same key restored), resetting only if meta/S3 is wiped.
-- **Cloud propagation:** an auto-generated `WORKER_SECRET` reaches the Scaleway
- runner — it's injected at trigger time (`env["WORKER_SECRET"] = d.secret` in
- dispatcher.go), not baked into the job definition. Add a test asserting the
- resolved secret is the one the dispatcher injects and the report API validates.
-- An explicit env overrides the persisted value.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; golden + failure paths.